Skip to content

duly_catalog_apply finds no catalog items through the real action dispatcher — it reports a successful run of zero #79

Closed

Description

@os-warren

Found while covering the catalog apply path for #65. Not fixed there — a different defect class from that card's missing validation rule, and the fix touches src/actions/catalog.handlers.ts plus a test fake, neither of which is #65's surface.

What was measured

applyCatalogHandler reads its catalog with an ObjectQL query envelope:

// src/actions/catalog.handlers.ts
const items = await engine.find('duly_catalog_item', {
  where: { position_code: positionCode, active: true },
});

ctx.engine is built by buildActionEngineFacade (@objectstack/runtime 17.2.0), which treats its second argument as a bare filter and adds the envelope itself:

async find(object, query) {
  const where = query && Object.keys(query).length ? { where: query } : {};
  const rows = await ql.find(object, { ...where, context });
  ...
}

So through the real dispatcher the handler's own where becomes { where: { where: { position_code, active } } }. No row has a field called where, the read comes back empty, with no error, and the action returns { created: 0, catalog_items: 0 } — a successful run that did nothing.

Measured against a booted in-memory kernel, dispatching through data.executeAction('global', 'duly_catalog_apply', …) with one matching active recurring item and one user. The only variable between the two runs is which facade is passed:

facade handed to the handler catalog_items created
the runtime's own ({ where: query } wrapping) 0 0
a flat one (query passed through as the filter) 1 1

Blast radius

Same mechanism, same file — these follow from the wrapping and were not each dispatched end to end:

  • syncCatalogHandler's duty read, engine.find('duly_duty', { where: { source: 'catalog' } }), returns nothing, so a sync scans 0 and reports every duty unchanged. Its unfiltered catalog read is the one call that still works, because Object.keys({}).length === 0 skips the wrapping entirely.
  • resolveBusinessUnit's sys_user_position read returns nothing, so a duty that should be anchored is created unanchored — silently, since "no position row" is a legitimate day-one state the handler is written to tolerate.

Nothing catches this today: pnpm validate, typecheck, test and build are all green. test/catalog-instantiate.test.ts's FakeEngine implements find(object, query) reading query.where, i.e. it honours the handler's convention rather than the runtime's, so all 78-duty assertions pass against a shape production never produces.

The contract question underneath

ActionEngineFacade.find(object, query) in @objectstack/spec types its query parameter as a plain record of string to unknown, and says nothing about whether it is a filter or an envelope. The runtime's implementation is the only thing that decides, and the app read it the other way. Worth fixing here and raising upstream so the shape is declared rather than discovered — a second application will read it the same way this one did.

(This paragraph was rewritten after filing: GitHub's body sanitizer silently ate the TypeScript generic that stated the type, leaving a bare Record — the same class of edit-time mangling that eats short angle-bracketed fragments in issue and PR bodies.)

Suggested fix

Pass the filter flat, matching what the runtime actually does:

const items = await engine.find('duly_catalog_item', { position_code: positionCode, active: true });

...for all four engine.find calls, and change FakeEngine.find in test/catalog-instantiate.test.ts to match, or the suite goes green on the old shape. A test that dispatches through data.executeAction with the runtime's own facade is what would have caught this; #65's PR adds one as a tripwire (test/catalog-apply-cadence.test.ts, the last describe) — delete it as part of this fix, it is written to go red when this is closed.

Acceptance

  • duly_catalog_apply, dispatched with the facade the runtime builds, creates the duties for a matching position_code
  • duly_catalog_sync scans the catalog-sourced duties it is supposed to scan
  • the tripwire in test/catalog-apply-cadence.test.ts is deleted, not adjusted

Generated by Claude Code

Activity

  1. self-assigned this
    on Sep 1, 2026
  2. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Claiming this.

    • session: session_01SqkTcrxUFci7nqXdbBSe2p
    • branch: claude/issue-79-engine-find-filter-shape
    • worktree: ../duly-issue-79

    Working the four engine.find call sites in src/actions/catalog.handlers.ts, the FakeEngine in test/catalog-instantiate.test.ts, and deleting the tripwire in the last describe of test/catalog-apply-cadence.test.ts. Adding coverage that dispatches through data.executeAction with the runtime's own facade.


    Generated by Claude Code

  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor
    {
      "issue": 79,
      "status": "done",
      "branch": "claude/issue-79-engine-find-filter-shape",
      "pr": "https://github.com/objectstack-ai/duly/pull/87",
      "premise_still_valid": true,
      "summary": "Confirmed the issue's premise against origin/main before writing code: buildActionEngineFacade in node_modules/@objectstack/runtime/dist/index.js:2304-2309 is verbatim as quoted, and a probe through the real action route reproduced the defect end to end (HTTP 200, success:true, catalog_items 0, created 0, zero duties written). All five engine.find calls in src/actions/catalog.handlers.ts now pass the filter flat — the issue says four; the fifth is resolveBusinessUnit's sys_user_position read, which the blast-radius section names but the count missed. FakeEngine.find in test/catalog-instantiate.test.ts now reads the flat query as the filter (it read query.where, which is why 78 duty assertions were green against a shape production never produced), the #79 tripwire in the last describe of test/catalog-apply-cadence.test.ts is deleted rather than adjusted and its two facades folded into one, and a new test/catalog-engine-facade.test.ts dispatches through HttpDispatcher.handleActions so the RUNTIME builds ctx.engine — no double is written in that file. A tolerant `query.where ?? query` rung was deliberately not added anywhere (contract-first); the contract half is referenced in a code comment as objectstack-ai/objectstack#14175, not duplicated and not waited on.",
      "tests": "All four gates green on 0c510fd (the PR head commit, run after the final commit; tree confirmed byte-identical to HEAD afterwards — `git status` empty, `git diff --stat` 0 lines). Exit codes captured before any pipe, via redirect-then-read: `GATE validate EXIT=0` (one expected warning, hierarchy-security provider absent — AGENTS.md says that warning is this repo's expected state), `GATE typecheck EXIT=0`, `GATE test EXIT=0` with vitest's own line `Test Files 22 passed (22) · Tests 559 passed (559)`, `GATE build EXIT=0` with `✓ Build complete (597ms)`. Reverse verification (mutation, no build/dist leg — these suites import ../src/… directly, nothing resolves through dist/, so no rebuild applies): pre-fix src/actions/catalog.handlers.ts restored from origin/main over the fixed tree; the mutation was confirmed ON DISK by grep before anything ran — 1 occurrence of the envelope read `engine.find('duly_duty', { where: { source: 'catalog' } })` present and 0 of the flat form, with the script aborting rather than reporting had those counts not flipped (an editor's exit code proves nothing). Predicted direction was red across all three catalog suites, and that is what was observed: `❯ test/catalog-apply-cadence.test.ts (3 tests | 2 failed)`, `❯ test/catalog-engine-facade.test.ts (8 tests | 7 failed)`, `❯ test/catalog-instantiate.test.ts (31 tests | 14 failed)`, `vitest exit: 1` — 23 failures where before the fix only the tripwire could see the defect. The single new test that stays green under the mutation is the capability control, correctly, since it does not depend on the query shape. The mutation script carried `trap '<restore>' EXIT INT TERM`; restore confirmed by the same greps inverting back (0 envelope / 1 flat) and a clean `git status`. Every heavy run went through scripts/pm/os-verify-lock.sh (one 190s queue wait behind issue-73's holder, waited in-turn).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    (Supersedes the previous comment. Its HTML-comment form of this marker was eaten by GitHub's body sanitizer on write — the same edit-time mangling the issue body itself records. Literal text survives, so the marker is spelled out here. The JSON below is unchanged apart from removing angle brackets the sanitizer also ate.)

    {
      "issue": 79,
      "status": "done",
      "branch": "claude/issue-79-engine-find-filter-shape",
      "pr": "https://github.com/objectstack-ai/duly/pull/87",
      "premise_still_valid": true,
      "summary": "Confirmed the issue's premise against origin/main before writing code: buildActionEngineFacade in node_modules/@objectstack/runtime/dist/index.js lines 2304-2309 is verbatim as quoted, and a probe through the real action route reproduced the defect end to end (HTTP 200, success true, catalog_items 0, created 0, zero duties written). All five engine.find calls in src/actions/catalog.handlers.ts now pass the filter flat — the issue says four; the fifth is resolveBusinessUnit's sys_user_position read, which the blast-radius section names but the count missed. FakeEngine.find in test/catalog-instantiate.test.ts now reads the flat query as the filter (it read query.where, which is why 78 duty assertions were green against a shape production never produced), the tripwire in the last describe of test/catalog-apply-cadence.test.ts is deleted rather than adjusted and its two facades folded into one, and a new test/catalog-engine-facade.test.ts dispatches through HttpDispatcher.handleActions so the RUNTIME builds ctx.engine — no double is written in that file. A tolerant `query.where ?? query` rung was deliberately not added anywhere (contract-first); the contract half is referenced in a code comment as objectstack-ai/objectstack#14175, not duplicated and not waited on.",
      "tests": "All four gates green on 0c510fd (the PR head commit, run after the final commit; tree confirmed byte-identical to HEAD afterwards — `git status` empty, `git diff --stat` 0 lines). Exit codes captured before any pipe, via redirect-then-read: `GATE validate EXIT=0` (one expected warning, hierarchy-security provider absent — AGENTS.md says that warning is this repo's expected state), `GATE typecheck EXIT=0`, `GATE test EXIT=0` with vitest's own line `Test Files 22 passed (22) · Tests 559 passed (559)`, `GATE build EXIT=0` with `Build complete (597ms)`. Reverse verification (mutation; no build/dist leg applies — these suites import ../src/... directly, nothing resolves through dist/, so there is nothing to rebuild): pre-fix src/actions/catalog.handlers.ts restored from origin/main over the fixed tree; the mutation was confirmed ON DISK by grep before anything ran — 1 occurrence of the envelope read `engine.find('duly_duty', { where: { source: 'catalog' } })` present and 0 of the flat form, with the script exiting 90 rather than reporting had those counts not flipped (an editor's exit code proves nothing). Predicted direction was red across all three catalog suites, and that is what was observed: `test/catalog-apply-cadence.test.ts (3 tests | 2 failed)`, `test/catalog-engine-facade.test.ts (8 tests | 7 failed)`, `test/catalog-instantiate.test.ts (31 tests | 14 failed)`, `vitest exit: 1` — 23 failures where before the fix only the tripwire could see the defect. The single new test that stays green under the mutation is the capability control, correctly, since it does not depend on the query shape. The mutation script carried a restore trap on EXIT INT TERM; restore confirmed by the same greps inverting back (0 envelope / 1 flat) and a clean `git status`. Every heavy run went through scripts/pm/os-verify-lock.sh (one 190s queue wait behind issue-73's holder, waited in-turn, never across a turn boundary).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions