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.tsconstitems=awaitengine.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:
asyncfind(object,query){constwhere=query&&Object.keys(query).length ? {where: query} : {};constrows=awaitql.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:
constitems=awaitengine.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_codeduly_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
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.tsplus a test fake, neither of which is #65's surface.What was measured
applyCatalogHandlerreads its catalog with an ObjectQL query envelope:ctx.engineis built bybuildActionEngineFacade(@objectstack/runtime 17.2.0), which treats its second argument as a bare filter and adds the envelope itself:So through the real dispatcher the handler's own
wherebecomes{ where: { where: { position_code, active } } }. No row has a field calledwhere, 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:catalog_itemscreated{ where: query }wrapping)querypassed through as the filter)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, becauseObject.keys({}).length === 0skips the wrapping entirely.resolveBusinessUnit'ssys_user_positionread 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,testandbuildare all green.test/catalog-instantiate.test.ts'sFakeEngineimplementsfind(object, query)readingquery.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/spectypes itsqueryparameter 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:
...for all four
engine.findcalls, and changeFakeEngine.findintest/catalog-instantiate.test.tsto match, or the suite goes green on the old shape. A test that dispatches throughdata.executeActionwith 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 lastdescribe) — 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 matchingposition_codeduly_catalog_syncscans the catalog-sourced duties it is supposed to scantest/catalog-apply-cadence.test.tsis deleted, not adjustedGenerated by Claude Code