feat(spec)!: HookSchema refuses a hook body that targets a stored-metadata table, with the runtime's prescription (#21565) - #21592
Conversation
…adata table, with the runtime's prescription The refused set is the runtime bind's: a hook carrying a body whose object names sys_metadata or sys_metadata_history (string or any list member), judged by isStoredMetadataBodyObject. A code handler and the wildcard stay outside it. The JSON-stage hook derives through safeExtend so the check survives the handler narrowing. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ntry and changeset Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
… stored-metadata table Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
The object-level check has no JSON Schema form, so the published Hook file and the four installed-package files embedding it are wider than the parse at the hook node. Hand-edited, as the ledger requires. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ree kernel leaf hook.zod.ts needs only the predicate. Importing it from metadata-type-redaction.ts pulled that module's closure (the credential derivation and the conversion chain) into the hook schema's source graph, which the generated skills/objectstack-data reference index reads: seven unrelated schema pointers. The leaf is re-exported from the redaction module, so every importer and the kernel entry keep the same objects. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ok-family-target-refused
… leaf gen:export-origins output after the family set and its predicate moved to src/kernel/stored-metadata-body-objects.ts; the kernel entry re-exports the same two declarations from their new origin. Confirmed by a fresh build: check:export-origins green, and a generator re-run rewrote 0 shards. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ok-family-target-refused
📓 Docs Drift CheckThis PR changes 1 package(s): 26 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 — 138 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 eabcfba71633df6a5c0f8c476a8dbc71c3ffaa1f && git checkout eabcfba71633df6a5c0f8c476a8dbc71c3ffaa1f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d2e687fdff379a5ac35bb51a07c75e4dd524df50 bab28dbaeae6c7ce5585689e993eaf07c85839e9 && git checkout -B drift-repro d2e687fdff379a5ac35bb51a07c75e4dd524df50 && git merge --no-ff bab28dbaeae6c7ce5585689e993eaf07c85839e9
node scripts/docs-audit/affected-docs.mjs --json d2e687fdff379a5ac35bb51a07c75e4dd524df50
|
Contract reviewServed-tier: PR #21592 (card #21565), the authoring half of #21520's ruling A ( Gates, read as they stand at 2026-10-03T14:05Z: 34 check-runs on the head — 28 ① Derived judgmentsEach accept-set and public-surface change the diff implies, judged against
② Semver level
Clause-②: yes (narrowing) ③ Boundary flagsThe dev report carries
Nothing to escalate. No runtime file is in the diff, so the floor is unchanged, as the claim required. Implemented-by: VERDICT: PASS Generated by Claude Code |
…ok-family-target-refused dropped-refinements.baseline.json: both sides' sites kept (main's ui/ObjectFormProps entry and this branch's five hook sites); the measured counts stack to 218 schemas and 659 sites. registry.ts merged without a conflict; its generated regions are re-checked by the generator next. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
ACCEPT — PR #21592 @
|
Fixes #21565
Clause-②: yes (narrowing)
Summary
HookSchemanow refuses, at parse, a hook that carries abodyand whoseobjectnames a table of stored metadata (sys_metadataorsys_metadata_history). The message names the table, says the runtime refuses to register such a hook so it never runs, and ends with the runtime's own prescription: change metadata through the metadata API. This is the authoring half of the stored-metadata body boundary (#21520, ruling A5965059068), routed by triage5967370168as aHookSchemarefinement through the same family predicate the runtime boundary judges by. There is no second list of tables, no door-local check inprotocol.ts, and no runtime change.Because the refusal sits on the schema, every door that parses a hook refuses the same hook at once: the metadata save door (
PUT /api/v1/meta/hook/NAME,422 INVALID_METADATA),defineHook(),defineStack(STACK_SCHEMA_INVALID, 422, athooks.N.object),os validate, and an artifact's parse.The refused set is exactly the runtime bind's (A2)
Read from
origin/main(bd70706713landed the boundary):packages/runtime/src/sandbox/body-runner.ts:297:hookBodyRunnerFactoryreturns early for a hook with nobody, so a code handler never reaches the boundary.body-runner.ts:310-311: for any body, the factory asksstoredMetadataBodyHookBindingRefusaland throws beforeHookBodySchema.safeParse(:313), so every body form is refused, not only sandboxed JS.packages/runtime/src/stored-metadata-body-boundary.ts:56-58, 66-67, 80-82: the target is read as a list (a string is a list of one), filtered byisStoredMetadataBodyObject, and one family member refuses the whole hook.body-runner.ts:328and:341: a wildcard'*'names no family table, so it binds; the runtime logs once and never runs its body for a family table's event. It is not refused.The refinement (
packages/spec/src/data/hook.zod.ts:121-139, attached at:478) refuses exactly that set: a hook whosebodyis present, whoseobject(the string, or any list member) satisfiesisStoredMetadataBodyObject. One issue per family member, atobjectfor a string target and atobject.Nfor a list member. Not wider: a codehandlerhook on a family table and a wildcard body hook parse unchanged. Not narrower: an expression body is refused like a JS body, and['*', 'sys_metadata']is refused at the family member.One predicate, one prescription (A3)
hook.zod.tsimportsisStoredMetadataBodyObject. The runtime reaches the same function object through@objectstack/spec/kernel; a pin asserts identity (toBe) between the kernel module's export and the one the schema imports.kernel/metadata-type-redaction.tsdid not cycle and the bundle tree-shook it, but it pulled that module's closure (the datasource credential derivation and, throughshared/metadata-collection.zod.ts, the conversion chain) into the hook schema's source graph.check:skill-refsthen went stale: the generatedskills/objectstack-data/references/_index.mdgrew from 37 to 44 dependency pointers, seven of them unrelated schemas. Regenerating it would have put askills/**file (a Tier H governed surface) into this PR for no reason. The smallest fix: the set and the predicate moved, unchanged, into the import-free leafpackages/spec/src/kernel/stored-metadata-body-objects.ts, andmetadata-type-redaction.tsre-exports both, so every importer and thekernelentry keep the very same objects. Only theexport-origins/kernel.jsonorigin path changed (generator output). The table list exists once.stored-metadata-body-boundary.ts:41) thatpackages/speccannot import, and no shared constant exists. The spec message repeats it word for word, with no tracker number.Census (A1)
Every hook-shaped declaration (an object literal with both
objectandevents) whoseobjectresolves to a family table, by a TypeScript AST walk:packages/**+examples/**at44072fc2b994668373f2The three hits are all in
packages/runtime/src/stored-metadata-body-boundary.pin.test.ts, the runtime floor's own refusal fixtures (lines 108, 109 and 290, each with a body). No authored app hook targets a family table. Lines 108 and 109 reach the runtime throughAppPluginraw (zeroHookSchemareferences inpackages/runtime/src), so the refinement never sees them. Line 290 is the composed pin's probe throughPUT /api/v1/meta/hook/NAME; that test records the door's answer and deliberately does not assert it. Control: a scratch fixture with a family target held in aconstand a family table inside a list was found (2 of 2); hotcrm'scrm_accounthook was not.Pins (A5)
packages/spec/src/data/hook-body-stored-metadata-target.test.ts: each family table refused atobjectwith the prescription; both body forms refused; list members refused atobject.N, one member enough;defineHook, the registeredhooktype schema the save door validates against,defineStack(code+statusenvelope athooks.1.object) and an artifact's parse (throughsafeExtend) each refuse it. Controls, byte-identical parse output: a codehandlerhook on each family table, a wildcard body hook, ordinary string and list targets. The ADR-0087 entry is registered with no conversion and no tombstone.packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts, section 6, beside the door's other save refusals: the gatePUT /api/v1/meta/hook/NAMEreaches, as the administrator, answers422 INVALID_METADATAwith the issue atobject(orobject.1), the prescription, and nothing stored; the same body hook on an ordinary object saves as before.protocol.tsis untouched.stored-metadata-body-boundary.test.ts,stored-metadata-body-boundary.pin.test.tsandsandbox/body-runner.test.tspass. The composed pin, which saves a family-target body hook over real HTTP as the administrator, now printsmetadata door save of a runtime-authored family-table hook answered: 422(it answered 200 when this card was filed).Ablation (A7)
From the committed head
8c605b9785, throughscripts/ablation-replace.mjs(anchors must hit, writes verified on disk) under a trap restore, with the spec rebuilt in each leg andscripts/ablation-dist-preflight.mjsreading the builtdist/before any verdict:.superRefine(refuseBodyOnStoredMetadataTarget)attach removed fromHookSchema(hook.zod.tsblob6cc51014to35c8ea56, marker count 1 to 0). Removing the refinement also un-drops its five dropped-refinement sites, and the build'sgen:schemaholds that ledger exact, sodropped-refinements.baseline.jsonwas reverted in the same leg; its mutated blob equals the pre-refinement baseline (8b331234, the file at152e36aacc^). Build exit 0; preflight--absent: the marker is absent from all 228 built files.HEAD(6cc51014,412d577d),git diff HEADempty, tree clean; rebuilt; preflight: the marker is present in 20 built files and the tree is clean. Spec pin 18/18, door pin 18/18.hook.zod.tsalone; the build refused at the dropped-refinement ledger (the five sites it names were no longer dropped),dist/kept the refinement (preflight--absentexit 1), and that leg's green door run measured the old artifact. The refusal is itself a reading: the ledger catches the refinement's removal at build.Kit and release (A6)
hook-body-stored-metadata-target-refusedat protocol 18 (packages/spec/src/migrations/entries/semantic/), with the registry regions regenerated. No key is removed, so no tombstone; no D2 conversion, because a refused hook carries no intent a rewrite could keep (retargeting it, dropping its body or deleting it each changes what the author wrote, and the runtime never ran it). A stored hook row of this shape still loads with a[metadata_spec_invalid]warning and is still never bound.dropped-refinements.baseline.json: the object-level check has no JSON Schema form, so the publisheddata/Hookfile and the four installed-package files embedding it record one more dropped site each (653 to 658). Hand-edited, as that ledger requires.@objectstack/specminor, with the BREAKING banner,Clause-②: yes (narrowing), the ADR-0087 marker (registered), the FROM → TO table and the one-line fix.stack.zod.ts: the JSON-stage hook derives fromHookSchemawith.safeExtend()instead of.extend(); zod refuses.extend()over a refined object, andsafeExtendkeeps the check..superRefine()leavesz.inferandz.inputunchanged;check:api-surface,check:declaration-mapandcheck:export-originsare green with no change toapi-surface/ordeclaration-map/.Gates (local)
Head
478e364795(this branch after mergingorigin/mainat901e7cf13a; the merge brought onlyrest,cloud-connectionand dogfood files):node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 95 commands for these 11 paths; every one was run with its exit code written to disk, pluscheck-changeset-no-majorwith this body as the--eventpayload. All exit 0.--ranreconcile:95 derived famil(ies) accounted for — 95 run, 0 NOT-MEASURED (a DERIVED zero — all 95 recorded an exit code and none of them is 3).check:generated:All 15 generated artifacts are up to date;check:migration-registry:registry.ts is current (357 semantic, 246 retired-key, 218 retired-def);check:api-surface:public API surface + factory signatures unchanged;check:livenessexit 0;check:dts-closure:71 built package(s) swept - 169/169 declared declaration file(s) present;check-adr-0087-registration:1 declared-breaking changeset(s), each carrying an ADR-0087 disposition(registered hook-body-stored-metadata-target-refused);check-changeset-no-major --event:LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose packages/**/src/** it moves is graded patch;check-empty-changesetexit 0;check:doc-authoringandcheck:nul-bytesexit 0.478e364795: spec pin 18/18; door pin file 18/18; runtime floor (3 files) 51/51, the composed pin printinganswered: 422.8c605b9785(the samepackages/spec,metadata-protocolandruntimesources as the head):@objectstack/specbuildexit 0,testexit 0 (607 files, 17970 passed, 1 todo),typecheckexit 0 (test layer included);@objectstack/metadata-protocoltestexit 0 (207 files passed, 3 skipped),typecheckexit 0 (its tsconfig includes the test file).Acceptance notes
saveMetaItem, the function the route reaches); the over-HTTP reading is the runtime composed pin's printed status above, which that test records but does not assert.Generated by Claude Code