Repository navigation
feat(verify): the handle gains a system-context update door and a predicate update door - #22517
Conversation
… doors (WIP)
hooks.run takes { system: true } on 'update' (the { isSystem: true } context a
system job writes under), and hooks.updateWhere is the engine's predicate path
(multi: true), refused when the engine's own dispatch would write by id.
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
…r on the real engine Each claim is read off what only the engine produces on that path: the permission gate's refusal and its absence for the system, the hook chain's caller, per-row pre-image and dispatch verdict, the declared validation's refusal, and the record-change flow's runs and writes. Docs and changeset. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b55816a009224285aa29663c735f7dc0e36f9f85 && git checkout b55816a009224285aa29663c735f7dc0e36f9f85
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee8751d41e61a18f7819e4d3ad2c340f51ab2418 257aeb788b4b1ce919cc392fdaf77b3592f6e895 && git checkout -B drift-repro ee8751d41e61a18f7819e4d3ad2c340f51ab2418 && git merge --no-ff 257aeb788b4b1ce919cc392fdaf77b3592f6e895
node scripts/docs-audit/affected-docs.mjs --json ee8751d41e61a18f7819e4d3ad2c340f51ab2418
|
Contract reviewServed-tier: PR #22517 (card #22301, items 2 and 3) read as the net diff against the merge-base ① Derived judgmentsRight:
Wrong:
② Semver level
③ Boundary flagsThe report's
The three out-of-scope findings (carrier: none):
Reviewer flags:
Check-runs on this headRead at 2026-10-09T17:17Z; 36 check-runs: 29 success, 6 skipped, 1 failure, 1 in progress. The seven required contexts:
Advisory: Patch roundSame dev, same branch, one commit:
No other file. The spec touch is beyond the claim's named surface ("the generated artifacts the diff moves"); the seat's patch order authorises it. Implemented-by: VERDICT: FAIL Generated by Claude Code |
…EST in the error-code ledger The verify handle's two update doors refuse a malformed call in-process with a thrown Error carrying code / status / statusCode, and the provenance guard requires the stamping package's own owner key to list the code. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Second review on PR #22517 (card #22301, items 2 and 3), after the FAIL ① Derived judgmentsRight:
Wrong: none in the diff's code. The one wrong judgment is in the changeset set, below. ② Semver level
③ Boundary flagsThe report's
Out-of-scope findings: none new this round. The round-1 escalation ( Reviewer flags:
Check-runs on this headRead at 2026-10-09T19:04Z; 42 check-runs: 37 success, 5 skipped, none failed, none in progress. The seven required contexts: Patch roundSame dev, same branch, one commit, one file:
Implemented-by: VERDICT: FAIL Generated by Claude Code |
… the error-code ledger Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Third review on PR #22517 (card #22301, items 2 and 3), after the round-2 FAIL ① Derived judgmentsRight:
Wrong: none. ② Semver level
③ Boundary flagsThe report's The round-2 The round-1 escalation ( Reviewer flags:
Check-runs on this headRead at 2026-10-09T19:36Z, after the last run completed; 42 check-runs: 37 success, 5 skipped, none failed, none in progress. The seven required contexts: Nothing in this record holds the PR. The diff touches no governed surface and is 627 changed lines; the record is owed by the claim's own Implemented-by: VERDICT: PASS Generated by Claude Code |
Part of #22301: items 2 and 3, as one stage (PM claim
6083271651). Items 4 to 8, and item 1's remaining composition gap, stay open on the card.Clause-②: yes (widening)
What this adds
The in-process handle of
@objectstack/verifygains the two update doors that hotcrm still reaches through local helpers (systemUpdateandpredicateUpdatein hotcrmtest/helpers/verify-stack.ts). Each door is the engine's ownObjectQL.update, the call those helpers make by hand on the booted kernel. Nothing is re-implemented and nothing writes to a driver directly.hooks.run(object, 'update', { id, ...fields }, { system: true })runsupdate(object, { ...input, id }, { where: { id }, context: { isSystem: true } }). That is the by-id writehooks.runalready makes for a person, under the context a system job writes under. No permission gate applies. The bound hooks still run (they seesession.isSystemand nouserId), declared validations still refuse, and the record-change trigger fires its flows with no trigger user.seedremains the fixture door; it also setsskipTriggersandseedReplay.hooks.updateWhere(object, where, data, opts)runsupdate(object, data, { where, multi: true, context }), the engine's predicate path. No REST door reaches that path:POST /data/:object/updateManywrites by id. The engine dispatchesbeforeUpdateandafterUpdateonce per matched row (dispatch.mode: 'per-row'), each bound to that row's own pre-image asprevious, so record-change flows evaluate per row. The door resolves the affected-row count. The caller is{ as: token }or{ system: true }.Public API (
@objectstack/verifyminor,@objectstack/specminor)AsSystem:{ system: true }, besideAsUser.VerifyHandle.hooks.rungains a second overload,run(object, 'update', input, opts: AsSystem), which resolves anEngineRow. The existing signature is unchanged.VerifyHandle.hooks.updateWhere(object, where: EngineRow, data: EngineRow, opts: AsUser | AsSystem), which resolves anumber.packages/restorpackages/objectqlchanges.@objectstack/spec(minor,.changeset/22301-spec-ledger-verify-provenance.md,Clause-②: yes (widening: a new owner provenance row in the published error-code ledger)):ERROR_CODE_LEDGER["@objectstack/verify"]listsINVALID_REQUEST. No code is added, andErrorCode,RegisteredErrorCodeandREGISTERED_ERROR_CODESare unchanged.What the doors refuse themselves
Both doors answer
INVALID_REQUEST/400(an existing ADR-0112 ledger code) before a caller is resolved or the engine is touched:{ system: true }oninsertordelete. The system context is the update doors' only. A system insert of fixture rows isseed. A system insert or delete that fires record triggers has no door: it belongs to item 5's stage, not this one.{ as, system: true }together: a caller named twice.hooks.updateWherewith awherethat is not an object.hooks.updateWherecalled in a way the engine's own update dispatch (resolveEngineUpdateDispatch, exported by@objectstack/objectqlfor exactly this kind of caller) would write by id: awherenaming only anid, or anidindatabeside awherethat selects by nothing else. The door is the predicate path, so it does not hand back a by-id row where it promises a count. Anidinside a real predicate ({ id, batch }, the compare-and-set spelling) stays a predicate update. Anidindatabeside a real predicate is the engine's own refusal, unchanged.Every other refusal (permission, validation, a hook's throw) is the engine's own error, rethrown unchanged.
Premises, re-read on
origin/maine148ca9842seedonly inserts:handle.tsseedcallsql.insert(object, rows, { context: SEED_CONTEXT }). Holds.hooks.runalways runs as a person: it resolvedcontextFor(opts.as), which refuses a token that resolves to nouserId. It addresses one row byinput.id. Holds.updateManyiterates by id:metadata-protocol/src/protocol.tsrunUpdateManyLooprefuses a row withoutid, then updates each one by id. Holds.ObjectQL.updatetakes the predicate branch on the dispatch verdictmulti, thendispatchPerRowBeforeHooks/buildPerRowAfterContexts(ADR-0058's bulk addendum) dispatch once per matched row with that row'sprevious. It resolves the driver's affected-row count (engine.ts, thedriver.updateManyexit). Holds.{ isSystem: true }is the spec's named system opt-in (ExecutionContextdocs).isSystemalone does not suppress trigger dispatch; onlyskipTriggersdoes (SEED_WRITE_EXECUTION_CONTEXTdocs, andseed-ownership-claim-dispatch.dogfood.test.tsmeasured a bare{ isSystem: true }write firing app hooks and record flows). This PR re-measures it on the handle (pins below).gh apiis refused for that repository in this session, but a plaingit clone --depth 1 --sparseover the proxy works. The helpers were read at hotcrmac162c9, read-only. hotcrm's suite was not run.hotcrm mapping (hotcrm
ac162c9, read-only)systemUpdate(stack, object, doc): 61 calls in 26 files, all by idstack.hooks.run(object, 'update', doc, { system: true })update(object, doc, { where: { id: doc.id }, context: { isSystem: true } })predicateUpdate(stack, object, doc, where, as): 1 call (flow-billing-handoff.test.ts:232,where: { name })stack.hooks.updateWhere(object, where, doc, { as })update(object, doc, { where, multi: true, context: contextFor(as) })escalation-task-subject.test.ts:143expectssystemUpdateto be refused by the app's own hook. Through the door that refusal is still the engine's own error.Tests (at
992de817)packages/verify/src/handle.update-doors.test.ts: 9 tests on onebootStackof a neutral fixture. The fixture has an editableupd_deal, anupd_casea fresh member may read but not edit, a capture hook on each (beforeUpdate/afterUpdate: caller,previous,dispatch.mode), and a record-change flow on each that writes a ledger row (from=previous,to=record).hooks.runupdate ofupd_caseis refusedPERMISSION_DENIED/403and the row is unchanged (control). The same update with{ system: true }is written.isSystem: true, nouserId,previous= the pre-image,mode: 'record'. The flow ran once,completed, with notrigger.userId, and wrote its ledger row. Control: the same update as the admin reaches the same hooks and the same flow, carrying the admin's id.ValidationError(code: 'VALIDATION_FAILED',fields: [{ field: '_record', code: 'rule_violation' }]) and the row is unchanged. Control: an in-range value is written.{ system: true }oninsertanddelete, and{ as, system }, each answerINVALID_REQUEST/400. Nothing is written and no hook is dispatched.won, and two do not.updateWhereresolves3, all three carry the payload, and the two non-matching rows are unchanged.beforeUpdateand oneafterUpdate, each withprevious= that row's own seeded stage,userId= the member, andmode: 'per-row'. Non-matching rows got none.stage != previous.stage) wrote one ledger row for each matched row whose own pre-image differed (2 rows, eachfromits own stage), and none for the row alreadywon(it is in the count of 3). One sharedpreviouswould fire for all three rows or for none.upd_caseis refusedPERMISSION_DENIED/403and the rows are unchanged. The same call with{ system: true }resolves2, and the hooks sawisSystem, no user andmode: 'per-row'.where: { id },{}with anidindata, and nowhereeach answerINVALID_REQUEST/400, with nothing written and no hook dispatched. Control:where: { id, batch }goes through and resolves1.The package:
pnpm --filter @objectstack/verify testgave 24 files / 196 tests passed, andpnpm --filter @objectstack/verify typecheckexited 0. The test layer is compiled:tsconfig.test.jsonlists the new file.Ablations
Each ablation went through
node scripts/ablation-replace.mjs(WRAP mode, restore armed on EXIT/INT/TERM) onpackages/verify/src/handle.tsat992de817, under one lock acquisition. The test imports the handle from source (./harness.js), so the subject has nodist/leg. The direction was predicted before the run. Each mutation landed (anchor 1 → 0, blob changed) and was restored to the HEAD blobdb78a676withgit diff HEADempty. The tree ended with 0 porcelain lines.SEED_CONTEXT, which addsskipTriggersandseedReplay)updateManyshapemode: 'per-row'pins; count, untouched rows, per-rowpreviousand per-row flows stay green{ system: true }on insert/delete is disabledA2 is why the dispatch-verdict pin exists. Per-row hooks with their own
previous, an exact count and per-row flows also hold for a loop of by-id writes. Only the engine'sdispatch.modetells the two apart.Gates
At
ca122badb2, the final commit after the patch round (contract review FAIL6085768920, seat order6085806321),node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 88 commands. The spec path added 25,check:error-code-provenanceamong them. Each was run with its exit code captured before any pipe:check:error-code-provenance("OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver").dual-build-cjs-loads: PREREQUISITE NOT MET (whole-tree dist), left to CI on the seat's instruction.doc-formula-expressionsandlean-entry-closure: a dist prerequisite that the shared verify lock could not serve, left to CI.--ranverdict:✓ dispatch-gates --ran: 88 derived famil(ies) accounted for — 85 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).Round 3 at
257aeb788b(one changeset file, per review6087465393):check-changeset-no-major,check-adr-0087-registration,check-empty-changeset,check:changeset-gate-self-tests,check-changeset-fixedandcheck:nul-byteseach exit 0.Round 1 at
992de817derived 63 commands, of which 62 ran and 1 was NOT MEASURED (dual-build-cjs-loads).CI owns the jobs that no local command covers: Test Core, Dogfood, Build Core, the workspace type-check and
pnpm lint.Acceptance notes
packages/verify/README.mdenumerates every handle door and said "there is no way to run as nobody;seedand the defaultrowsrun as the system principal". Both lines describe the doors this PR adds, so they are updated here, and the deviation is declared in the report.ValidationErrorcarries no status. It hascodeandfields; the REST boundary mapsVALIDATION_FAILEDto 400. The validation pin therefore assertscodeandfields, not a status. Not filed: no public door answers wrong. Carrier: none.sys_automation_runrow lands after the triggering write returns. It was absent right after the system update and present 500 ms later, while the engine's run log (automation.listRuns) and the flow's own writes are there synchronously. The pins read the run log. A suite that readssys_automation_runimmediately after a write can race it; hotcrm'sflowRunshelper reads that table. Root cause NOT MEASURED. Not filed (an observation). Carrier: none.trigger.userId). A user-less insert or delete trigger, and a record the engine no longer holds, are still item 5's.hooks.run's two older call-shape refusals (an unknown operation, a missinginput.id) are still plainErrors with nocode. The new ones carryINVALID_REQUEST/400. Carrier: none.Generated by Claude Code