Skip to content

feat(spec)!: HookSchema refuses a hook body that targets a stored-metadata table, with the runtime's prescription (#21565) - #21592

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21565-hook-family-target-refused
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21565-hook-family-target-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21565
Clause-②: yes (narrowing)

Summary

HookSchema now refuses, at parse, a hook that carries a body and whose object names a table of stored metadata (sys_metadata or sys_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 A 5965059068), routed by triage 5967370168 as a HookSchema refinement through the same family predicate the runtime boundary judges by. There is no second list of tables, no door-local check in protocol.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, at hooks.N.object), os validate, and an artifact's parse.

The refused set is exactly the runtime bind's (A2)

Read from origin/main (bd70706713 landed the boundary):

  • packages/runtime/src/sandbox/body-runner.ts:297: hookBodyRunnerFactory returns early for a hook with no body, so a code handler never reaches the boundary.
  • body-runner.ts:310-311: for any body, the factory asks storedMetadataBodyHookBindingRefusal and throws before HookBodySchema.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 by isStoredMetadataBodyObject, and one family member refuses the whole hook.
  • body-runner.ts:328 and :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 whose body is present, whose object (the string, or any list member) satisfies isStoredMetadataBodyObject. One issue per family member, at object for a string target and at object.N for a list member. Not wider: a code handler hook 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)

  • Predicate. hook.zod.ts imports isStoredMetadataBodyObject. 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.
  • Why the kernel leaf. Importing the predicate from kernel/metadata-type-redaction.ts did not cycle and the bundle tree-shook it, but it pulled that module's closure (the datasource credential derivation and, through shared/metadata-collection.zod.ts, the conversion chain) into the hook schema's source graph. check:skill-refs then went stale: the generated skills/objectstack-data/references/_index.md grew from 37 to 44 dependency pointers, seven of them unrelated schemas. Regenerating it would have put a skills/** 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 leaf packages/spec/src/kernel/stored-metadata-body-objects.ts, and metadata-type-redaction.ts re-exports both, so every importer and the kernel entry keep the very same objects. Only the export-origins/kernel.json origin path changed (generator output). The table list exists once.
  • Prescription. The runtime keeps its sentence in a module-private constant (stored-metadata-body-boundary.ts:41) that packages/spec cannot 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 object and events) whose object resolves to a family table, by a TypeScript AST walk:

tree files hook declarations (outside tests) family-target hits
objectstack packages/** + examples/** at 44072fc2b9 8140 317 (24) 3
the same at this branch's head 8148 319 (24) the same 3
hotcrm at 94668373f2 568 44 (40) 0

The 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 through AppPlugin raw (zero HookSchema references in packages/runtime/src), so the refinement never sees them. Line 290 is the composed pin's probe through PUT /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 a const and a family table inside a list was found (2 of 2); hotcrm's crm_account hook was not.

Pins (A5)

  • spec packages/spec/src/data/hook-body-stored-metadata-target.test.ts: each family table refused at object with the prescription; both body forms refused; list members refused at object.N, one member enough; defineHook, the registered hook type schema the save door validates against, defineStack (code + status envelope at hooks.1.object) and an artifact's parse (through safeExtend) each refuse it. Controls, byte-identical parse output: a code handler hook 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.
  • the save door packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts, section 6, beside the door's other save refusals: the gate PUT /api/v1/meta/hook/NAME reaches, as the administrator, answers 422 INVALID_METADATA with the issue at object (or object.1), the prescription, and nothing stored; the same body hook on an ordinary object saves as before. protocol.ts is untouched.
  • the runtime floor, unchanged: stored-metadata-body-boundary.test.ts, stored-metadata-body-boundary.pin.test.ts and sandbox/body-runner.test.ts pass. The composed pin, which saves a family-target body hook over real HTTP as the administrator, now prints metadata 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, through scripts/ablation-replace.mjs (anchors must hit, writes verified on disk) under a trap restore, with the spec rebuilt in each leg and scripts/ablation-dist-preflight.mjs reading the built dist/ before any verdict:

  • Mutate. The .superRefine(refuseBodyOnStoredMetadataTarget) attach removed from HookSchema (hook.zod.ts blob 6cc51014 to 35c8ea56, marker count 1 to 0). Removing the refinement also un-drops its five dropped-refinement sites, and the build's gen:schema holds that ledger exact, so dropped-refinements.baseline.json was reverted in the same leg; its mutated blob equals the pre-refinement baseline (8b331234, the file at 152e36aacc^). Build exit 0; preflight --absent: the marker is absent from all 228 built files.
    • spec pin: 10 failed, 8 passed (the 10 refusal cases red; the 8 controls stayed green);
    • door pin: 3 failed, 15 passed (the three family-target saves; the ordinary-object control and the file's other sections stayed green).
  • Restore. Both blobs equal HEAD (6cc51014, 412d577d), git diff HEAD empty, 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.
  • A first attempt was void and is discarded. It mutated hook.zod.ts alone; the build refused at the dropped-refinement ledger (the five sites it names were no longer dropped), dist/ kept the refinement (preflight --absent exit 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)

  • D3 entry hook-body-stored-metadata-target-refused at 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 published data/Hook file and the four installed-package files embedding it record one more dropped site each (653 to 658). Hand-edited, as that ledger requires.
  • Changeset @objectstack/spec minor, 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 from HookSchema with .safeExtend() instead of .extend(); zod refuses .extend() over a refined object, and safeExtend keeps the check.
  • Types (A4): .superRefine() leaves z.infer and z.input unchanged; check:api-surface, check:declaration-map and check:export-origins are green with no change to api-surface/ or declaration-map/.

Gates (local)

Head 478e364795 (this branch after merging origin/main at 901e7cf13a; the merge brought only rest, cloud-connection and dogfood files):

  • The derived union. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 95 commands for these 11 paths; every one was run with its exit code written to disk, plus check-changeset-no-major with this body as the --event payload. All exit 0. --ran reconcile: 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).
  • Named by the dispatch, verdict lines from that run:
    • 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:liveness exit 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-changeset exit 0;
    • check:doc-authoring and check:nul-bytes exit 0.
  • Pins at 478e364795: spec pin 18/18; door pin file 18/18; runtime floor (3 files) 51/51, the composed pin printing answered: 422.
  • Package suites at 8c605b9785 (the same packages/spec, metadata-protocol and runtime sources as the head): @objectstack/spec build exit 0, test exit 0 (607 files, 17970 passed, 1 todo), typecheck exit 0 (test layer included); @objectstack/metadata-protocol test exit 0 (207 files passed, 3 skipped), typecheck exit 0 (its tsconfig includes the test file).
  • Not measured locally, declared to CI: the CI-only steps the derivation names (Test Core shards, Dogfood, Temporal Conformance, Build Core, the workspace type-check programs).

Acceptance notes

  • The census covers this repository and hotcrm. Deployed metadata was not measured.
  • The new pins are unit-level at the door (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

claude added 8 commits October 3, 2026 11:13
…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>
… 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>
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 17 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/export-origins/kernel.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d2e687fdff379a5ac35bb51a07c75e4dd524df50.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/export-origins/kernel.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d2e687fdff379a5ac35bb51a07c75e4dd524df50 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from eabcfba71633df6a5c0f8c476a8dbc71c3ffaa1f — the merge of head bab28dbaeae6c7ce5585689e993eaf07c85839e9 into base d2e687fdff379a5ac35bb51a07c75e4dd524df50, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d2e687fdff379a5ac35bb51a07c75e4dd524df50 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 478e364795a42a1bc8fad36c96a70266a56e7f03
Local-runs: none

PR #21592 (card #21565), the authoring half of #21520's ruling A (5965059068), reviewed read-only against: the card's body and every comment (the grade 5967370168, the unlock 5968011459, the claim 5968496458, the dev report 5969807278); PR #21563 as landed (bd70706713); the PR body, its file list and the net diff against main (the head is a merge of main at 901e7cf13a, so the merge base is main itself: 11 files, +573 / -31); and the head's check-runs. The hotcrm clone was read for the census only. Nothing was built, run or re-run.

Gates, read as they stand at 2026-10-03T14:05Z: 34 check-runs on the head — 28 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), path-filtered), 3 in_progress, 0 failed. Green: Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Dogfood Regression Gate (1/3), Dogfood Regression Gate (2/3), Dogfood Regression Gate (3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (3/6), Test Core (5/6), Test Core (6/6), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. Still running, and NOT read as passes: Test Core (1/6), Test Core (2/6), Test Core (4/6). Their conclusions are the gate verdicts; this record does not substitute for them. Lint & Repo Gates carries check:generated, check:api-surface, check:export-origins, check:migration-registry and check:skill-refs, so those five derived families are green.

① Derived judgments

Each accept-set and public-surface change the diff implies, judged against body-runner.ts and stored-metadata-body-boundary.ts on main.

  1. The refused set equals the runtime bind's — right. On main, hookBodyRunnerFactory returns early for a hook with no body (body-runner.ts:297), asks storedMetadataBodyHookBindingRefusal before HookBodySchema.safeParse (:310-313, so every body form is refused, not only JS), and the boundary reads object as a list (a string is a list of one), filters it by isStoredMetadataBodyObject, and one family member refuses the whole hook (stored-metadata-body-boundary.ts:56-82); a wildcard names no family table and binds (body-runner.ts:328, :341). The refinement (hook.zod.ts:121-139, attached at :478) returns when body === undefined, otherwise walks the string-or-list target through the same predicate and adds one custom issue per family member, at object or object.N. Not wider: a code handler hook on a family table and a wildcard body hook parse unchanged (pinned byte-identical). Not narrower: an expression body is refused like a JS body, and ['*', 'sys_metadata'] is refused at object.1. Exactness of the match (Set.has, no case folding, no prefix) is the runtime's own, because the predicate is the runtime's own.
  2. One predicate, no second list — right. The set and isStoredMetadataBodyObject moved verbatim into the import-free leaf kernel/stored-metadata-body-objects.ts; metadata-type-redaction.ts imports them and re-exports them by name, and kernel/index.ts is unchanged (it still star-exports metadata-type-redaction and does not export the leaf directly, so no duplicate-name ambiguity). An ES re-export is the same binding, so every importer on main — eleven modules across runtime, objectql, metadata-protocol, mcp, plugin-audit and service-analytics, each through @objectstack/spec/kernel or ./metadata-type-redaction — receives the very same objects; the new spec pin asserts toBe identity between the two module paths. The table list exists once in the tree. The reason for the leaf (keeping the redaction module's closure out of the hook schema's source graph, which check:skill-refs reads) is consistent with the diff: no skills/** file moved, and the Governed Surface Queue Guard is green.
  3. Public surface unchanged — right. @objectstack/spec exposes 21 fixed subpath entries and no per-file pattern, so the leaf is not a new import path; api-surface/ and declaration-map/ are untouched in the diff; only export-origins/kernel.json moved two origin paths, which is a generated provenance ledger and not the API. .superRefine() leaves z.infer and z.input of HookSchema unchanged and keeps .shape (objectql's HookSchema.shape.retryPolicy read still resolves). check:api-surface and check:export-origins ride Lint & Repo Gates, which is green on the head.
  4. Every door refuses at once — right. getMetadataTypeSchema('hook') is HookSchema (metadata-type-schemas.ts:93), so the save door's 422 INVALID_METADATA, defineHook, defineStack (STACK_SCHEMA_INVALID at hooks.N.object), os validate and the artifact stage share the one check. The door pin runs the gate the route reaches (saveMetaItem, writeFace: 'meta-envelope', actor usr_admin) beside the file's other save-refusal pins, and protocol.ts is untouched, as the grade required. The pinned sibling objectui only calls HookSchema.parse for its metadata-admin client validation, so the Studio form inherits the same refusal, and nothing there derives from HookSchema with .extend().
  5. stack.zod.ts derives with safeExtend — right, and required. jsonStageHooksKey() is the only derivation from HookSchema in the tree (packages, scripts, examples, apps, and hotcrm). zod 4 refuses .extend() over an object that carries checks and .safeExtend() carries them over; the kept check is what the artifact-stage pin measures (hooks.0.object refused; an ordinary body hook and a lowered string handler on a family table parse; an inline callable is still refused), and Build Core is green on the head, which constructed every schema eagerly through gen:schema.
  6. Dropped-refinements ledger +5 — right. The object-level check has no JSON Schema form, so data/Hook gains the root site and the four installed-package schemas that embed the artifact-stage hook each gain a manifest.hooks.element site: 653 to 658, publishedSchemasWithDroppedRefinements unchanged. The four artifact-stage sites exist only because safeExtend kept the check, so the ledger corroborates item 5. Build Core green on the head means gen:schema measured exactly this ledger there.
  7. Migration registry — right as it stands. One D3 semantic entry hook-body-stored-metadata-target-refused at protocol 18 inside the generated semantic:18 region, matching the new entry file, plus one rationale fragment in the hand-authored STEP18_RATIONALE list, which is the registry's designed insert point (order 67 is unique there, and ties are id-broken in any case). No key is removed, so no tombstone and no RETIRED_KEYS_BY_MAJOR row. No D2 conversion, which is right: the only mechanical rewrites (retarget, strip the body, delete) each change what the author wrote, and the runtime never ran the hook. A stored row of this shape now loads under the existing [metadata_spec_invalid] leniency and stays unbound. Serial: feat(spec)!: four object-form members take the shape the form reads; fields, sections held (#21464, stage 3) #21590 also adds a step-18 entry; whichever lands second re-merges and regenerates. Judged as this head stands, the registry is consistent with its entries directory; check:migration-registry rides Lint & Repo Gates, which is green on the head.
  8. Census — the three hits are probes, not authored writers — right. All three sit in packages/runtime/src/stored-metadata-body-boundary.pin.test.ts, the runtime floor's own refusal fixtures. Lines 108 and 109 are members of a raw object literal handed to AppPlugin: the file calls neither defineStack, defineHook nor HookSchema, and no non-test file under packages/runtime/src references HookSchema or parses a stack through a spec schema (the binder takes the bundle raw), so the refinement never sees them. Line 290 is the composed probe through PUT /meta/hook/NAME, whose status that test records and deliberately does not assert; it now reads 422, and the file is unchanged by this PR. hotcrm at the dev's 94668373f2 (its origin/main) has no hook whose object names a family table. Zero authored writers; the hits are named in the PR body and in the changeset, which is what the grade asked for.
  9. The prescription — right, with one accepted repetition. The runtime keeps its sentence in a module-private constant (stored-metadata-body-boundary.ts:41) that packages/spec cannot import (spec is upstream of runtime), so the spec repeats the sentence word for word. That is a second copy of a sentence, not a second list of tables; the grade's ⛔ is on the list, and the list exists once.

② Semver level

@objectstack/spec minor with the BREAKING banner, Clause-②: yes (narrowing) in both the PR body (its second line) and the changeset, the ADR-0087 marker registered hook-body-stored-metadata-target-refused matching the D3 entry's id, a FROM → TO table and the one-line fix. That is what the diff publishes: an accept-set narrowing on a published authoring surface, with no key removed and no export moved. yes takes at least minor, (narrowing) is BREAKING, and the launch-window convention ships it minor, as #21563 did for the runtime half. @objectstack/metadata-protocol changes only a test file and publishes nothing, so one changeset is right, and skip-changeset does not apply. Check Changeset is green on the head.

Clause-②: yes (narrowing)

③ Boundary flags

The dev report carries open_questions: [] and out_of_scope_findings: []. Its four deviations, each answered:

  • Resumed after a container restart; kept export-origins/kernel.json after re-running its generator. Answered: the file's diff is exactly the two origin-path lines the move implies, and nothing else. check:export-origins rides Lint & Repo Gates, which is green on the head.
  • The A1 stop condition was not taken on three census hits. Answered, no hold: see item 8. The hits are the runtime floor's refusal probes, two never schema-parsed and one recorded-not-asserted; naming them in the PR satisfies the grade's "any hit is named before the refinement lands". Commit d200459269 need not be held.
  • The door pin is at saveMetaItem, not an HTTP harness. Answered: it is the gate the route reaches, built with the route's face and actor, beside the file's other save-refusal pins; the over-HTTP reading is the composed runtime pin's recorded 422. Accepted as the pin the grade asked for.
  • Model-free commit trailers. Outside this record's contract. Read anyway: neither the diff nor any of the branch's commit messages carries a model identifier.

Nothing to escalate. No runtime file is in the diff, so the floor is unchanged, as the claim required.

Implemented-by: claude/issue-21565-hook-family-target-refused
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21592 @ bab28dba (#21565)

domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5968496458 · 2026-10-03T15:08Z

  • Shape (read on GitHub): a draft against main. The first line is Fixes #21565, and Clause-②: yes (narrowing) sits at a line start. 11 files, +573 / −31.

  • Review: the contract review at CONTRACT_REVIEW_TIER, 5969922144, reads PASS on 478e3647.

    • The refused set equals the runtime bind's exactly (PR fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) #21563's boundary): a hook that carries a body, in any form, and whose object names sys_metadata or sys_metadata_history, as a string or as any list member. A code-handler hook and a wildcard body hook stay accepted, byte for byte. The message ends with the runtime's prescription word for word.
    • One predicate: the set and isStoredMetadataBodyObject moved, unchanged, into an import-free kernel leaf and are re-exported from metadata-type-redaction.ts. All 11 importers, @objectstack/spec/kernel among them, see the very same objects, and the public surface is unchanged. Without the move, the hook schema's source graph would have pulled in unrelated schemas, which shows up in the generated skills/** index (a governed surface).
    • Every authoring door refuses: the metadata save (422 INVALID_METADATA), defineHook, defineStack, os validate and an artifact's parse. stack.zod.ts's safeExtend is the only HookSchema derivation in the tree, and it is required.
    • The census: no authored app hook targets a family table, in this repository or in hotcrm (94668373f2). The 3 hits are the runtime floor's own refusal probes.
    • Release: @objectstack/spec minor, BREAKING, one ADR-0087 marker (hook-body-stored-metadata-target-refused, D3, no conversion).
  • The merge round after the review (478e3647 → bab28dba): PR feat(spec)!: four object-form members take the shape the form reads; fields, sections held (#21464, stage 3) #21590 landed first, so this branch merged main (d2e687fdff, carrying 958cfe29be) through os-regen-merge.sh. The dev's addendum is 5970217965. This seat read the delta:

    Every gate re-ran green at bab28dba: the build, check:generated, check:migration-registry, check:export-origins, check:api-surface, both pins and the full spec test. Nothing the review judged moved.

  • CI on bab28dba: 32 success and 3 skipped by design (Build Docs, Console Pin Gate, packed-tarball smoke).

  • Governed surface: not governed (check-governed-merges --pr 21592).

Next: ready, auto-merge, the queue. On merge, Fixes #21565 closes the card.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants