Repository navigation
feat(verify): hooks.run takes the system principal on an insert and a delete - #22596
Conversation
A system insert or delete is the engine's own insert / delete under
{ isSystem: true }, the write an integration or a system job makes: no
permission gate, the hooks and declared validations run, and the
record-change trigger fires the object's flows with no trigger user. A
delete-triggered flow is handed the pre-image and its get_record finds a
row the engine no longer holds. A call naming two callers stays refused.
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
…ithout one Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 3 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 6b2385dedc8351b7cf1b76db9396e9585e7a3768 && git checkout 6b2385dedc8351b7cf1b76db9396e9585e7a3768
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 76bc1e03a3237150934a35370406a5ddd7cde4fb 798e68ffb8c5f2165934205cd52f411ecc7067b5 && git checkout -B drift-repro 76bc1e03a3237150934a35370406a5ddd7cde4fb && git merge --no-ff 798e68ffb8c5f2165934205cd52f411ecc7067b5
node scripts/docs-audit/affected-docs.mjs --json 76bc1e03a3237150934a35370406a5ddd7cde4fb |
Contract reviewServed-tier: Head resolved from ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged:
② Semver levelPackages this diff publishes in: The new note's own text, read as the CHANGELOG it becomes: every behavioural sentence matches a pin in ①. Two phrasings are carried verbatim from the items 2–3 note and pre-date this PR: "Every other refusal is the engine's own error" (the handle's own DELIBERATE CORRECTION —
|
…le still makes The pending note's example, a system principal on an insert or delete, is a door after this branch; the refusal it still makes is a call naming two callers, on either write door. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Round 2, on the patch round the seat ordered ( ① Derived judgmentsCarry-over. The code diff is unchanged from the round-1 head — New on this head, judged: the rewritten spec note's claim that both write doors answer ② Semver levelCarry-over. Packages this diff publishes in: DELIBERATE CORRECTION — two
|
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <[email protected]>
|
Regen-provenance: 6093488852 ·
The head moved after the contract review PASS
The record carries to Generated by Claude Code |
Landing pre-checks at
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 38024748561 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue ejection:
|
The ejection, measured: not this PR's. The re-queue is left to a person, because the three-fact rule does not admit it
Measured, at the signature's own file:
Corrected reading of "the siblings passed":
Why the seat does not re-queue: the landing rule re-queues a new signature once only when three facts hold.
The evidence above says this PR does not cause the red, but this seat does not route around the rule. The PR stays open, with auto-merge off, until a person re-queues it or the rule's conditions change. The maintainer is told in this seat's round report. Generated by Claude Code |
Re-queue at
|
Part of #22301
Clause-②: yes (widening)
Item 5 of the card: a user-less record trigger on an insert or a delete, and a flow over a record the engine no longer holds. Items 6 and 7 and item 1's remaining composition gap are not in this PR, and the card stays open for them.
What changes
hooks.run(object, 'insert' | 'delete', input, { system: true })now runs. It is the same engine call a person's write makes (ql.insert(object, input, { context }),ql.delete(object, { where: { id }, context })), withcontextset to{ isSystem: true }(the handle's existingSYSTEM_CONTEXT). This is the write an integration or a system job makes. Until nowhooks.runrefused{ system: true }on anything butupdate, withINVALID_REQUEST/400, and that refusal is removed.AsSystemoverload ofhooks.runwidens from'update'to'insert' | 'update' | 'delete'. Its JSDoc says what runs: the hooks (they seesession.isSystemand nouserId), the declared validations, and the record-change trigger with no trigger user. It also says what does not run: the permission gate. It says what a delete-triggered flow is handed, and how this door differs fromseed, which setsskipTriggers.{ as, system: true }) is still refused withINVALID_REQUEST/400before the engine is touched, on every operation.packages/verify/README.md: the handle example and the caller and refusal bullets..changeset/22301-verify-system-insert-delete-door.md:@objectstack/verifyminor,Clause-②: yes (widening)..changeset/22301-verify-update-doors.mdsaid "Onlyupdatetakes it.{ system: true }oninsertordelete… is refused"; it now keeps only the two-caller refusal and points at this entry..changeset/22301-spec-ledger-verify-provenance.mdgave "{ system: true }on an insert or delete" as anINVALID_REQUESTexample; it now names a call naming two callers ({ as: token, system: true }) and callshooks.runandhooks.updateWherethe two write doors (patch round, after the contract review FAIL6093381495).check-empty-changesetandCheck Changesetare RED on these twoMrows by design (the "DELIBERATE CORRECTION" class). The same-head contract review PASS6093488852names both notes and judges each rewritten sentence, which is the confirmation the landing rule takes.What was measured before the door was opened
These were measured on a booted stack (
requires: ['automation', 'triggers']) atd6c37919c7. A scratch probe (since deleted) called the bootedobjectqlwith{ isSystem: true }, which is exactly the call the door would make.origin/main, the refusal sat athandle.ts:486–:491, and the JSDoc said so at:201–:203. Theinsertanddeletearms already made the engine calls above, so lifting the refusal hands those callsSYSTEM_CONTEXT. Confirmed.beforeInsertandafterInsertseeisSystem: trueand nouserId. Arecord-after-createflow runs (completed), with trigger{ type: 'record-after-create', object, recordId }and nouserId.beforeDeleteandafterDeleteseeisSystem: true, nouserId, and the pre-image asprevious. Arecord-after-deleteflow runs (completed) with no triggeruserId.skipTriggerssuppresses the trigger (trigger-record-change/src/record-change-trigger.ts:291);isSystemdoes not.record-after-deleteflow is handed the pre-image asrecordand asprevious(both readstatus: 'closing'), and itsget_recordof{record.id}answers no row. On the same delete, arecord-before-deleteflow's identicalget_recordfinds the row. The system delete door reaches this with no further door.seedis unchanged and still skips record-change flows (pinned below).Other readings from the same probe:
ql.deleteanswerstruefor a system delete and for a person's delete alike. See the Acceptance notes.readonly: truefield is KEPT by a system insert and stripped from an admin's insert.Pins:
packages/verify/src/handle.system-insert-delete.test.ts(7 tests)The fixture is neutral.
sid_casecan be read, but not created or deleted, by a fresh member. It has one capture hook and three record-change flows: on create, before a delete, and after a delete. The two delete flows read the case back withget_recordand writeheldorgoneto a ledger.PERMISSION_DENIED/403and leaves no row; the system insert writes the row.record-after-createflow runs with no trigger user and writes its ledger row.adminIdat the hook and on the run, andseedof the same shape fires no run.VALIDATION_FAILEDand leaves no row. Control: an in-range value is written.PERMISSION_DENIED/403and leaves the row; the system delete removes it.record-after-deleteflow runs with no trigger user.adminIdon the run.record_status: 'closing',previous_status: 'closing',held: 'gone'. The before-delete flow on the same delete readsheld: 'held', the control showinggoneis the engine's answer about a deleted row.{ as, system: true }) on an insert and on a delete: refusedINVALID_REQUEST/400, with no row written or deleted, no hook dispatched and no flow run.handle.update-doors.test.ts: the old pin "the system is accepted on update only" asserted the refusal this PR removes. Its insert and delete legs are dropped, since they are now doors pinned above. Its two-caller leg onupdatestays.Ablations
Both ran on the committed head
915f88e526, throughnode scripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed). Each was restored toHEADbloba6e175d1c0d3withgit diff HEADempty. The direction of each was predicted before the run.throw callShapeRefusal(…)whensystem && operation !== 'update'). Predicted: the 6 system insert and delete pins red, the two-caller pin and all 9 update-door pins green. Observed: 6 failed, 10 passed, and each failure is the ablation's own refusal (6 hits of its message in the log).queue-timeout) and is NOT MEASURED. It was restored by the tool.hooks.run. Predicted: the 3 flow-run pins red here, plus the item 2 flow pin inhandle.update-doors.test.ts; the row-level pins green. Observed: 4 failed, 12 passed, exactly those four.Verification
All at
84a4a1e07f, the final head (a merge oforigin/main25be87612d, which touches none of this PR's paths), unless named otherwise.pnpm exec turbo run build --filter='@objectstack/verify^...' --concurrency=2rebuilt after the merge, 46/46 tasks.@objectstack/verifyfull suite:vitest run --maxWorkers=2passed, 27 files / 216 tests.pnpm --filter @objectstack/verify typecheck(tsc --noEmitandcheck:test-typecheck) exited 0, at915f88e526and again at84a4a1e07f.tsc -p tsconfig.test.json --listFileslists 27/27 of the package's test files, the new one included.packages/qa/dogfood): the@objectstack/dogfood^...closure was built (63/63;@objectstack/verifyrebuilt as a cache miss, and itsdist/index.d.tscarries the widened overload 1 time and the old one 0 times), thenpnpm --filter @objectstack/dogfood typecheckexited 0 at915f88e526. The dogfood fake stack typeshooksasnever, so an overload change cannot break it..d.ts, from dogfood: a scratch file callinghooks.run('o', 'insert' | 'delete', …, { system: true })compiled (exit 0). Adding'upsert'turnedtscred with TS2769 "No overload matches this call". The file was removed andgit statusis clean.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwith no paths gives 63 commands; the 4 extra families from the dispatch-time list (speccheck:empty-state,check:liveness,check:strictness-ledger,check:variant-docs) also ran.check-empty-changeset, the deliberate correction above.check:dual-build-cjs-loadsis NOT MEASURED: it needs a whole-workspace build, which this dispatch excludes, so it is left to CI.--ranreconciliation over the exit-coded record: 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN.Acceptance notes
deletearm's return type.hooks.run(…, 'delete', …)is typed as a promise ofEngineRow, and the engine answerstrue, measured for a person's delete and a system delete. The type is older than this PR on the person overload, and the system overload repeats it. Nothing reads a field of it: in hotcrmf0afcbd, nohooks.run(…, 'delete', …)call site reads the answer beyond asserting that it is defined. Noted, not filed.ValidationErrorhas nostatusat the engine door;requireId's and the bad-operation call-shape errors carry nocode.get_record. The engine runs a record-change flow inside the write, while it still holds the row (the synchronous run log), so no write produces that state. The card's report carries this as an open question with options.For hotcrm (
runRecordFlow,test/helpers/verify-stack.ts:255; read at hotcrmf0afcbd)The helper has 8 call sites in 2 files:
test/flow-record-change.test.ts:633,:647,:684and:689. Each is an integration-written lead or contact whose record-after-create flow must run with no trigger user.:249, an ownerless contact. The owner stamp is the person's; a system insert leaves an unset owner null in the fixture.:500and:501, a deal bornpending. Ifapproval_statusis held byreadonly: true, the fixture reading above says a system insert keeps it.test/flow-escalation-ownerless-case.test.ts:233, the ghost case: an update-triggered flow over a row the engine does not hold. See the note above.Generated by Claude Code