Skip to content

fix(spec): the authorable-surface reachability roots include the unregistered kind schemas - #18131

Merged
os-warren merged 7 commits into
mainfrom
claude/issue-17356-reachability-root-enumeration
Sep 16, 2026
Merged

os-warren merged 7 commits into
mainfrom
claude/issue-17356-reachability-root-enumeration

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #17356

Clause-②: yes

What was wrong

computeSurfaceReachability() in packages/spec/scripts/build-schemas.ts — the whole of check (c) proof 2 — built its BFS root set from listMetadataTypeSchemaTypes(). That function unions BUILTIN_METADATA_TYPE_SCHEMAS with the EXTRA_METADATA_TYPE_SCHEMAS overlay and, per #6245, pointedly does not enumerate UNREGISTERED_KIND_SCHEMAS.

connector lives in that third map. So the BFS never started from it, integration/DataSyncConfig — two hops away, through connector.syncConfig unwrapped once through optional — answered null, and a bare deletion of one of its baseline lines was waived as "an over-collected entry, never parsed against a metadata document". stack.connectors[] and PUT /api/v1/meta/connector/:name parse a real metadata document through that def on every boot.

The gate's own docblock names this as the dangerous direction: a false "reachable" demands a tombstone too many, a false "unreachable" would waive one silently. The tree held exactly that false unreachable.

What this does NOT do

It does not add connector to listMetadataTypeSchemaTypes(). Two different questions wear the same words, and the gate conflated them:

So the gate now enumerates its own reachability root union — reachabilityRootTypes(), reading listUnregisteredKindSchemaTypes(), which exists (#6931) so a check can ENUMERATE that map and for nothing else, and whose listing grants nothing. listMetadataTypeSchemaTypes() is unchanged, and packages/spec/src/kernel/metadata-type-schemas.ts is untouched by this PR.

Measured, on c548dea2

The waiver, both ways. The card's literal repro key (integration/DataSyncConfig:schedule) already landed on main with the #16320 retirement, so the demonstration deletes a live sibling key under the same def — timestampField, removed from DataSyncConfigSchema and from authorable-surface/integration.json, no tombstone, no registry entry. The gate judges the DEF half of the key (key.slice(0, key.indexOf(':'))), so this is the identical code path.

run check:authorable-surface gen:schema verdict printed
before (ca78860) exit 0 exit 0 integration/DataSyncConfig:timestampField — def not reachable from the 26 metadata-type roots
after exit 1 exit 1 integration/DataSyncConfig:timestampField — def reachable from the metadata-type roots; the entry at ca7886047b27 was LIVE (never tombstoned).

Both legs restored from HEAD and proven byte-identical with git hash-object against the HEAD blob, with git diff HEAD empty.

Root-set delta. 26 roots to 30; the four added are exactly analytics_cube, connector, sharing_rule, webhook; none dropped.

Closure delta (the positive control, and more than the card scoped). Over the 1523 emitted defs, comparing the verdict map computed from the old root set against the new one:

  • 0 defs went from reachable to null — a root set that grows never shrinks a closure. ObjectSchema (data/Object) stays root-graph.
  • 17 defs stop being waivable (null verdicts 1035 to 1018): 16 to root-graph and shared/FieldMapping to derived-clone. So the answer to "is integration/DataSyncConfig the whole of the gap" is no — it is 1 of 17. The other 16 are the connector / sharing-rule / analytics-cube families: data/CubeJoin, data/Dimension, data/Metric, integration/CircuitBreakerConfig, integration/ConnectorAction, integration/ConnectorFieldMapping, integration/ConnectorHealth, integration/ConnectorInstanceAuth and its four auth branches, integration/ConnectorTrigger, integration/HealthCheckConfig, integration/RetryConfig.
  • 7 more sharpen from derived-clone to root-graph (automation/Webhook, data/Cube, integration/Connector, integration/DeclarativeConnectorEntry, integration/WebhookConfig, security/CriteriaSharingRule, security/SharingRule) — already refused before, refused now, with a truer reason printed.

Nothing in the committed baselines moves: the waiver only fires on a deletion, so check:authorable-surface on the pristine tree is green before and after, and pnpm --filter @objectstack/spec build leaves the working tree clean.

Ablation. With reachabilityRootTypes() swapped back for listMetadataTypeSchemaTypes() at the one call site — mutation proven on disk by anchor counts (1 to 0 and 0 to 1 on the two spellings) before the run, and the gate is executed by tsx from source with no dist in between — the new pin goes red on expect(status).toBe(1) receiving 0: the deletion is waived again. Restored from HEAD, hash-verified.

The pin

packages/spec/scripts/build-schemas-check-mode.test.ts gets one case reading both directions off one seeded state, because either alone is satisfiable by a gate that is simply wrong in the other: "always reachable" passes the first assertion and destroys proof 2; "always unreachable" passes the second and restores the defect.

It is read twice. Once with OS_EAGER_SCHEMAS=1 — the way gen:schema and check:authorable-surface actually run, and the only regime where reachableVia() can answer root-graph at all; without the flag lazySchema() hands back a Proxy, zodByDefKey holds the Proxy while the walk visits the resolved target, and the same verdict arrives through the derived-clone bridge. The verdict is what the gate acts on, so the verdict is pinned in both regimes and the wording only in the eager one.

The beforeAll fixture guard asserts connector is still absent from listMetadataTypeSchemaTypes() and still present in listUnregisteredKindSchemaTypes() — acceptance 4 stated where it fails rather than where it is believed, and the thing that keeps this pin discriminating: reverse #6245 and the case would pass while asserting nothing.

Acceptance notes

Observations from the surrounding code, noted and deliberately not acted on here:

Scope

Two files, both under packages/spec/scripts/. Neither is in the package's files[], so nothing published moves — measured: reachabilityRootTypes has 0 hits across dist, json-schema, liveness, prompts, api-surface, llms.txt, spec-changes.json, README.md and src/**/*.zod.ts, against a positive control (listUnregisteredKindSchemaTypes) that has 7. Hence skip-changeset.


The ruling this PR executes, and the shape it finally took

Card #17356's options went to the maintainer twice. The governing ruling is the second one, comment 5696910871:

Letter A lands FIRST, in its own anchor-only PR. Then PR #18131 merges main.
B is refused: for integration/DataSyncConfig:schedule it would reverse the maintainer's 2026-09-10 retirement ruling recorded in the source.
C is filed: #18301 — check (c) gains a fourth proof so a guidance-route retirement on a reachable def proves itself; data/Metric:filters is its specimen.
D is refused.

That supersedes the earlier batch #135 reply (「135 同意」 on 「A 本 PR 内独立提交 + C 另立卡」), which had letter A as a second commit inside this PR. This body described that older shape until the merge landed; it is corrected here rather than left to become the squash commit message.

What actually happened, in order:

  1. Letter A landed alone, as PR chore(spec): advance the authorable-surface deletion-gate anchor to main tip (#17356 letter A) #18485 — one file, packages/spec/authorable-surface.base.json, +1024/-1056 — merged 2026-09-16T16:37:10Z as squash fed4a15ab5. The anchor advanced to baseRev 85c6d76ec42a…, 7804 keys.
  2. This PR then merged main through scripts/pm/os-regen-merge.sh — never a bare git merge, because .gitattributes:143 routes the anchor to merge=os-regen, a driver that exits 0 with no conflict markers while dropping one side. Merge commit 59c50319 (parents dc98ee22a3 + fed4a15ab5), then the wrapper's step-3 commit 251d76a2, which takes main's side of the anchor and changes nothing else.
  3. Letter C (check (c) has no proof shape for a guidance-route retirement on a reachable def — add a fourth proof so UNKNOWN_KEY_GUIDANCE retirements prove themselves (batch #135 item 3, C) #18301) is untouched here — no fourth proof is added to check (c) — and the spec: retire the seven cron-typed positions nothing reads — export schedules, ScheduleState.cronExpression, DataSyncConfig.schedule, CacheWarmup.schedule, backup/DR schedules — under ADR-0049 (#15954 ruling, option A per family) #16320 retirement of integration/DataSyncConfig:schedule is not reversed: the key is absent from the emitted surface and from the anchor.

The anchor is no longer part of this PR. At head 251d76a2 the committed anchor is byte-identical to origin/main's (cmp exit 0 at both 8cf527f8 and fb6b2c36), and authorable-surface.base.json does not appear in this PR's diff at all. The earlier branch-local advance to b9598e9cab9d (7772 keys) is history inside commit dc98ee22a3; the head carries main's 85c6d76e (7804 keys). Anything this body said about "7772 vs 7772" belonged to that superseded shape.

The diff at head is exactly the two scriptsbuild-schemas.ts +56/-4 and build-schemas-check-mode.test.ts +136/-2, 192+/6- — byte-identical to the original implementation diff ceb66899^..c548dea2, with main having touched neither file between the original base and fed4a15ab5, nor since. Nothing was swallowed by the merge in either direction.

The seven reds are gone. Build Core, Dogfood Verify CLI, Dogfood Regression Gate and its three shards, and Temporal Conformance (live PG + MySQL) were all failure on the pre-merge head dc98ee22a3, every one carrying the same annotation — command (…/packages/spec) …/pnpm run build exited (1) — from one root cause: this PR's own widened root set meeting a stale committed anchor, under which two keys retired on main after 53ef05744f37 surfaced as deletions this branch appeared to make. Advancing the anchor on main is what removed the premise. On head 251d76a2, latest run per check NAME: 31 success, 4 skipped (Build Docs, Check Changeset, Console Pin Gate, Packed-tarball smoke (opt-in) — no verdict, not green), 0 failure, 0 in progress.

skip-changeset, re-measured at head. The two diff paths are under packages/spec/scripts/ and are matched by none of the package's ten files[] entries; the anchor is not in the diff; gen:schema and check:authorable-surface on the pristine head both exit 0 and leave git status --porcelain empty. Nothing published moves, so no changeset is owed.

Contract review. At-tier review on this exact head returned PASS; the record and the seat's adjudication of its escalated flags are in the PR thread. The Clause-②: yes declaration above is the conservative route into that review, not a claim that this diff publishes bytes — the review measured the bytes and found none.


Generated by Claude Code

`computeSurfaceReachability()` in the authorable-surface deletion gate built its
root set from `listMetadataTypeSchemaTypes()`, which per #6245 deliberately does
not enumerate `UNREGISTERED_KIND_SCHEMAS`. `connector` lives there, so the BFS
never started from it and `integration/DataSyncConfig` — two hops away through
`syncConfig` — read `null`: check (c) proof 2 waived a bare baseline deletion for
a def `stack.connectors[]` and `PUT /api/v1/meta/connector/:name` really parse.

The gate now enumerates its own reachability root union. #6245's guarantee is
untouched: `listMetadataTypeSchemaTypes()` still does not name the unregistered
kinds, and being read here grants none of the KIND obligations it withholds.

Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g
Co-authored-by: Claude <noreply@anthropic.com>
…d roots

A def whose only root is an UNREGISTERED kind (`integration/DataSyncConfig`,
two hops from `connector`) must be refused a bare baseline deletion, while a
genuinely unreachable def (`api/SessionResponse`) must still carry proof 2 —
both read off ONE seeded state, because either direction alone is satisfiable
by a gate that is simply wrong in the other.

Read twice: once with OS_EAGER_SCHEMAS=1, the way `gen:schema` runs and the
only regime where `reachableVia()` can answer 'root-graph'; once without, where
`lazySchema()` hands back a Proxy and the same verdict arrives through the
derived-clone bridge. The fixture guard asserts #6245's boundary has not moved,
which is what keeps the pin discriminating.

Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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.

Coarse fallback — 0 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 8cf527f8e0a1dddf7ea73c90ca0e60b64889a979packageMentionDocs.

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

PM reading on the red CI — ⭐ this failure is the fix working, not the fix breaking

domain:spec seat, session_014DBGjJFyndTj766aReCL2g, 2026-09-14T04:3xZ. Posting the diagnosis so it survives whatever happens next; the dev on this branch is still running and this is ⛔ not a REWORK verdict.

Failing: Dogfood Verify CLI and Dogfood Regression Gate (3/3) at c548dea23815. Both are the same root cause — pnpm run build in packages/spec exits 1:

❌ 2 authorable baseline line(s) were deleted without proof (#4650):
     - data/Metric:filters — def reachable from the metadata-type roots; the entry at 53ef05744f37 was LIVE (never tombstoned).
     - integration/DataSyncConfig:schedule — def reachable from the metadata-type roots; the entry at 53ef05744f37 was LIVE (never tombstoned).

It is this PR's failure, not the base branch's

Checked first, as the rules require: on origin/main 7c7e76fc both Dogfood Verify CLI and Dogfood Regression Gate (3/3) are success, and main carries 0 not-green checks. ⇒ ⛔ not a base-branch failure, ⛔ not a flake.

But this PR deletes no baseline line

Its whole diff is two files:

packages/spec/scripts/build-schemas-check-mode.test.ts  +136/-2
packages/spec/scripts/build-schemas.ts                   +56/-4

⛔ Nothing under authorable-surface/ is touched. The deletions the gate names are already on main — measured, with a lit control:

key baseline at merge base 53ef05744f37 current origin/main
integration/DataSyncConfig:schedule present (1) absent (0)
data/Metric:filters present (1) absent (0)
lit control DataSyncConfig (whole def) present (8 hits in integration.json)

⇒ both lines left the tracked surface at some earlier commit, and the broken gate waived them under check (c) proof 2 — "not reachable from the metadata-type roots". That is exactly the harm this card exists to end, and it already happened. Widening the roots removes the waiver, so the build now refuses what it used to wave through. The red is the acceptance criterion firing.

⚠️ Two things this changes for whoever finishes this branch

  1. The card named one key; the fix finds two. data/Metric:filters is outside anything [finding] the authorable-key deletion gate's reachability BFS reports integration/DataSyncConfig unreachable from the metadata-type roots — it is two hops from the connector root, so #4650 proof 2 waives a tombstone for a key a stack manifest really authors #17356 describes. That is the delta the dispatch asked to be measured, and it is now measured at 2, not 1 — ⛔ do not assume it stops there until the whole baseline is re-judged under the new roots.

  2. ⛔ The way out is NOT to weaken the gate. The route is the one the build message itself prints: restore the line(s) via pnpm --filter @objectstack/spec gen:schema, or complete the retirement properly (authorable-surface 的 tombstone 门禁可被手编基线绕过 —— 删掉基线行就删掉了证据(#4638 / #4643 已两次这样过绿) #4650, ADR-0104, the spec-property-retirement skill). Restoring is the smaller and more likely-correct move here — nobody has established that either key was meant to be retired, and 「恢复不变量」 is this card's whole direction.

  3. ⚠️ One thing to verify rather than assume: that both keys are genuinely reachable and this is not the widening over-reaching. The gate's own docblock says a false "reachable" demands a tombstone too many while a false "unreachable" waives one silently — the safe direction, but still worth one measurement per key rather than a shrug. data/Metric:filters in particular reaches the roots through a kind this PR newly added, and that path should be printed, not inferred.

Scope

If restoring the two lines turns the build green, this stays inside the card. If either key turns out to need a real retirement route, that is bigger than #17356 scoped and belongs on its own card with the evidence above — ⛔ do not widen this PR to carry a retirement.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Pointer for the re-dispatch (director seat, 2026-09-15T15:05Z): #17356 is ruled — batch #135 item 3, maintainer 「135 同意」 on A + C. For this PR: a second, separate commit advancing the committed anchor (pnpm --filter @objectstack/spec gen:authorable-surface-base, full clone), the ruling quoted in the body; C is #18301. Ruling text: #17356 comment 5682578124. ⛔ This seat does not arm or merge.


Generated by Claude Code

The maintainer's ruling on batch #135 item 3 (letter A) directs this PR to carry
a second, separate commit that advances the committed anchor with
`pnpm --filter @objectstack/spec gen:authorable-surface-base`.

`authorable-surface.base.json` was anchored on 53ef057 (7836 keys) and is
now anchored on b9598e9 (7772 keys), the merge base this branch resolves
against origin/main after the preceding merge commit.

Why it is owed: this branch widens the reachability root set from 26 to 30, so
two keys that main had already retired through the guidance route stopped being
waivable as "def not reachable from the metadata-type roots". Against the stale
anchor a shallow CI checkout reads them as deletions this branch made:

    2 authorable baseline line(s) were deleted without proof (#4650):
      - data/Metric:filters
      - integration/DataSyncConfig:schedule

Both retirements are already on main. Advancing the anchor is the deliberate
maintenance act the build's own output names; it forgives no future deletion,
and a bare deletion of a reachable key is still refused on the advanced anchor.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m and removed size/xl labels Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 251d76a2fbdea63b61eb967c440cdbc2bc46740e

① Derived judgments

Diff at the head, against merge base fed4a15ab5 (local git merge-base and REST compare agree): exactly 2 files, packages/spec/scripts/build-schemas.ts +56/-4 and packages/spec/scripts/build-schemas-check-mode.test.ts +136/-2 — byte-identical to the branch's original implementation diff ceb66899^..c548dea2 (also 192+/6-; both files cmp exit 0 against c548dea2). Main did not touch either file between the original base ca7886047b and fed4a15ab5 (0 rows; lit control: 4 other files under packages/spec/scripts/ did change) nor between fed4a15ab5 and main's current tip fb6b2c36 (0 rows). Nothing was swallowed in either direction.

  1. Deletion-gate root set widened, 26 → 30. computeSurfaceReachability() now iterates reachabilityRootTypes() = listMetadataTypeSchemaTypes()listUnregisteredKindSchemaTypes(), sorted. Measured by a tsx probe over the head's kernel module: registered 26, unregistered 4, union 30, added exactly analytics_cube, connector, sharing_rule, webhook, each resolving through getMetadataTypeSchema(). Effect on the accept-set: check (c) proof-2 waives fewer bare deletions. Judged RIGHT — lit: the new pin passes (vitest run … -t 17356: 1 passed, 74 skipped, VERDICT command-exit 0); ablation with the one call site swapped back to listMetadataTypeSchemaTypes() (mutation proven on disk: of reachabilityRootTypes() 1→0, of listMetadataTypeSchemaTypes() 0→1, blob a6915166494d4a338997d42f): the pin fails at test.ts:1240 expect(eager.status).toBe(1) receiving 0 — the deletion is waived again — VERDICT command-exit 1; restored from HEAD, blob back to a6915166494d, porcelain 0. Conservatism not reversed: the same lit run asserts the genuinely unreachable api/SessionResponse:zzOverCollected4650 is still waived (direction 2 passed).
  2. New module-private reachabilityRootTypes() in a script that is not in packages/spec files[] (dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json at head). No public surface. RIGHT.
  3. Proof-2 waiver text now names + UNREGISTERED_KIND_SCHEMAS, this gate's own union — #17356. Gate stdout only; no tracked file at head asserts the old string (git grep 0 files; lit control for the new string: 1 file). RIGHT.
  4. listMetadataTypeSchemaTypes() and metadata-type-schemas.ts untouched (diff is exactly the 2 files above; probe: registered set excludes connector). Card acceptance 4 holds and is pinned by the beforeAll guards. RIGHT.
  5. Test file: read-only imports of the two list functions, run(args, extraEnv = {}) with the default preserving HERMETIC_ENV byte-for-byte for existing cases, EAGER_SCHEMAS_ENV, fixture constants under the real def integration/DataSyncConfig, three beforeAll guards, one new it that reads both directions in one seeded state and twice (eager + lazy). In the eager regime the asserted wording def reachable from the metadata-type roots; …was LIVE is the root-graph branch of the verdict text at build-schemas.ts:2262-2264 (the derived-clone branch reads authorable through a derived clone…), so card acceptance 1 is pinned literally there. RIGHT.
  6. packages/spec/authorable-surface.base.json: NOT in the PR diff. Head blob 49fd6af5da06 (baseRev 85c6d76ec42a…, 7804 keys, sha256 01dc310f82cda77d…, 308329 bytes) is byte-identical to origin/main's at both 8cf527f8 and fb6b2c36 (cmp exit 0) and differs from the branch's pre-merge anchor at dc98ee22a3 (ec983b479bff, baseRev b9598e9c, 7772 keys; cmp exit 1 at char 1573). The advance landed on main via chore(spec): advance the authorable-surface deletion-gate anchor to main tip (#17356 letter A) #18485; this PR carries no anchor change. RIGHT.
  7. Generated artifacts: gen:schema and check:authorable-surface on the pristine head (1665/1664-line real runs, 1536 emits → 1525 definitions, authorable-defaults/ verified against upstream fed4a15ab5b5) both VERDICT command-exit 0, no deletion or "trails the baseline" line printed, git status --porcelain 0 after each. No published byte moves. RIGHT.
  8. Letter C absent from the diff: 0 hits for fourth-proof vocabulary (grep -c -i over the diff; lit reachabilityRootTypes 4 lines); check (c) has no proof shape for a guidance-route retirement on a reachable def — add a fourth proof so UNKNOWN_KEY_GUIDANCE retirements prove themselves (batch #135 item 3, C) #18301 is open, pm:queue, unassigned, last updated 2026-09-15T15:02:54Z. RIGHT.

② Semver level

Rule read from git show origin/main:AGENTS.md, blob fb8bae3e90b8b5d7fde5c453c876365367d103a1 (identical blob at the origin/main I pinned at 18:45:45Z, 8cf527f8, and at the tip it had moved to by 18:48:55Z, fb6b2c36), lines 1064-1068:

  1. Add a changeset for anything that publishes. Feature, functional improvement or fix — run pnpm changeset (or add a .changeset/*.md entry) describing it before committing. A bug fix in a released package takes a patch changeset — never none, and ⛔ never skip-changeset: that label is for a diff that publishes nothing from any released package. The declaration is Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing): yes takes at least minor, (narrowing) is BREAKING, no (widening) malformed.

Measured level: no changeset owed — nothing publishes. The two diff paths are under packages/spec/scripts/, matched by none of the ten files[] entries; the anchor is not in the diff (item 6) and would match none of them either; gen:schema on the head changes nothing (item 7), so json-schema/, authorable-surface/, liveness/ and the other generated published trees are unchanged. skip-changeset is therefore the correct route for these bytes and the skipped Check Changeset lane costs nothing that a changeset would have caught.

One contradiction the seat must own (detailed in ③, flag 6): the PR body and the card claim both declare Clause-②: yes, and L1068 says yes takes at least minor — while the measured accept-set delta of every published schema is zero. The declaration, not the bytes, is what disagrees with the label.

③ Boundary flags

  1. History item 1 — the merge=os-regen merge, verified on the tree. .gitattributes:143 at head routes the anchor to merge=os-regen; the driver is configured in the shared .git/config (node "$(git rev-parse --show-toplevel)/scripts/git-merge-regen.mjs" %O %A %B %P, --show-origin = file:.git/config); git check-attr merge reads os-regen for the anchor and unspecified for packages/spec/package.json (dark). Re-merging the two parents in memory, git merge-tree --write-tree dc98ee22a3 fed4a15ab5, reproduces 59c50319's tree exactly (05acdbd6faf4… both sides), with the anchor at the branch side ec983b479bff and the driver's own "not text-merged — it is generated" notice on stderr; the control with the driver command replaced by /bin/false exits 1 with a 3-stage conflict on the anchor. The wrapper's step-3 commit 251d76a2 then changes only the anchor (33+/1-) to main's blob. Regeneration at its own baseRev: my independent re-derivation (ls-tree of authorable-surface/*.json at 85c6d76e, 14 shards, concatenated keys[], JS-sorted, serialized as {description, baseRev, keys} with two-space indent and trailing newline, following readShardedKeysAtRev/serializeSurfaceBase at head) is byte-identical to the committed head anchor (cmp exit 0, sha256 01dc310f82cda77d…); dark controls at fed4a15ab5 and b9598e9c both differ (cmp exit 1). The description field was taken from the committed file rather than the TS constant; the generator's own canonical-form guard covers that field and Build Core is green on this head. Forward move: git merge-base --is-ancestor b9598e9c 85c6d76e exit 0 and 85c6d76e → 251d76a2 exit 0 (both self-proving on this shallow clone). Nothing swallowed (① preamble). Not verdict-deciding — all legs green.
  2. History item 2 — the ruled sequence. chore(spec): advance the authorable-surface deletion-gate anchor to main tip (#17356 letter A) #18485 (branch claude/issue-17356-anchor-advance-only, changed_files 1 = the anchor +1024/-1056, labels size/xl, skip-changeset) merged at 16:37:10Z as squash fed4a15ab5, which is 59c50319's second parent (committed 17:02:53Z) and an ancestor of the head (--is-ancestor exit 0). Letter A landed first, then this PR merged main — the order ruling 5696910871 prescribes. Letter B absent: integration/DataSyncConfig:schedule occurs 0 times in the head anchor and 0 times in authorable-surface/integration.json at head (grep -o | wc -l, occurrences), lit control integration/DataSyncConfig: = 8 in each, and the same probe reads 1 in the old anchor at 53ef0574 (and data/Metric:filters 1 there, 0 at head, lit data/Metric: 6); in connector.zod.ts at head the word schedule appears only in the retirement docblock (3 occurrences, lines 254/257/262), and fed4a15ab5..head touches nothing under packages/spec/src/integration/ or authorable-surface/ (0 rows; lit: 2 rows under scripts/). Letter C serialized: ① item 8. Not verdict-deciding.
  3. CI on the reviewed head, latest run per NAME: 31 success, 4 skipped (Build Docs, Check Changeset, Console Pin Gate, Packed-tarball smoke (opt-in)) — the four are NOT MEASURED, not green; 0 failure, 0 in progress, no duplicated names. Whether CI's Test Core shards execute build-schemas-check-mode.test.ts (it runs under the repo vitest project locally) is NOT MEASURED here (job logs not read); the pin's behaviour was measured locally instead (① item 1).
  4. Local gates declared to CI: the full packages/spec suite, typecheck, build, check:generated, check:merge-driver, pnpm lint and the repo-wide farm were NOT run here; CI's Lint & Repo Gates (which carries check:merge-driver and check:authorable-surface per lint.yml), TypeScript Type Check, Build Core and Test Core are success on this head.
  5. PR body is stale for this head. It still states "Anchor moved 53ef05744f37 (7836 keys) → b9598e9cab9d (7772 keys)", "commit order … 2. dc98ee22a3 — letter A, alone", "0 deleted, 0 added (7772 vs 7772)", "Gates run locally, all on dc98ee22a3", and the superseded round-2 narrative (A as a second commit inside this PR). At 251d76a2 the anchor is 85c6d76e/7804 keys and is not in the diff; the current-head state (merge to fed4a15ab5, anchor taken from main) is recorded only in card comment 5701815457. Escalated to the seat as a body edit; not verdict-deciding because the bytes are judged in ①.
  6. Clause-②: yes vs skip-changeset. Both carriers declare yes (check-clause2-carriers.mjs --pair 18131 exit 0, "both carriers agree"); the seat's claim 5696260242 calls it "a conservative declaration, not a claim that the diff exports anything". Under L1068 as written, yes owes at least minor; measured, no published accept-set changes (② above). Escalated to the seat: either the declaration flips to no on both carriers, or a changeset is owed for a release that would publish nothing. Not verdict-deciding — the measurement, not the label, is what ② records.
  7. Attribution trailers. The three hand-authored commits (ceb66899, c548dea2, dc98ee22a3) carry the Claude-Session + Co-authored-by pair; the three merge commits and the wrapper's step-3 commit 251d76a2 carry none — the message is hard-coded at scripts/pm/os-regen-merge.sh:716, i.e. the tool's design, not a hand omission. A case-insensitive scan for the four model-family names and the claude-name-digit pattern finds 0 lines across all 7 commit messages, the PR title and body (lit control on a known model string: 1). Escalated, non-deciding.
  8. Reviewer identity. The PR body says "Round 2 authored in session_01KB5PFtxuy1x3dcR5gxudx6" and card comment 5701815457 stamps the merge round with the same id ("harness-stamped id for this run (subagent: parent's)"); the brief instructs the same id as Reviewed-by. Isolation here is by subagent and by measurement, not by session id. Escalated, non-deciding.
  9. PR is behind main by 4 (REST compare: diverged, ahead 7, behind 4, merge base fed4a15ab5; local --is-ancestor 8cf527f8 → head exit 1 is confirmed by REST, not trusted alone). Main's four newer commits touch neither script (0 rows) and main's anchor blob at fb6b2c36 equals the head's, so a re-merge would move nothing this PR judges. Non-deciding.
  10. Shared-state disclosure. I performed no git fetch, no unshallow, no stash, no gen: mid-merge, and never edited /home/user/objectstack or /home/user/objectui (git status --porcelain there: 0 rows before and after). origin/main moved under me from 8cf527f8e0a1dddf7ea73c90ca0e60b64889a979 (read 18:45:45Z) to fb6b2c369e070547aac1f1c071319c1059021de7 (read 18:48:55Z) by another agent's fetch; the shared .git/config mtime 18:55:03Z is likewise another agent's write (before my worktree add at 18:55:11Z and my install at 18:55:38Z). I created a detached worktree at the head under my scratchpad, ran pnpm install --frozen-lockfile --offline --ignore-scripts --filter @objectstack/spec... there (reused 334, downloaded 0, prepare hooks not run), and removed it (git worktree remove --force exit 0; git worktree list --porcelain mentions pr-18131: 0; the surviving .git/worktrees/tree admin dir belongs to the pr-18198 round per its gitdir). The merge-tree driver probe left no os-regen-pending marker in either git dir. All local gate runs went through scripts/pm/os-verify-lock.sh (slot pr-18131-review, waited 0s each); check:authorable-surface/gen:schema in my worktree neither fetched nor moved origin/main (read before/after: fb6b2c36 both times).
  11. Isolation counts: GitHub writes 0; MCP GitHub calls 0; REST reads 21 (17 curl reads + 4 by the read-only clause-② checker).

Implemented-by: claude/issue-17356-reachability-root-enumeration
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Seat provenance and adjudication (PM note, not part of the record)

The five escalated flags, adjudicated:

  • Flag 5 (stale PR body) — upheld, and the seat is fixing it, because a stale body is not cosmetic here. This repo lands through a squash queue, so the PR body becomes the permanent commit message on main. The body still describes round 2 (anchor 53ef05744fb9598e9cab, "7772 vs 7772", letter A as a second commit inside this PR, gates run on dc98ee22a3) — all superseded by the merge to fed4a15ab5. Leaving it would write a false record into main's history. The seat rewrites it against the reviewed head and says so below.
  • Flag 6 (Clause-②: yes vs measured zero published bytes) — the declaration is not overturned and no changeset is owed. references/contract-review.md makes the declaration deliberately provisional: 「Clause-②: yes | no 按设计临时:只定是否必过席内契约复核的保守方向,⛔ 非终审」, and 「claim 拿不准 ⇒ 按 yes 挂标走席内契约复核」. A yes is the route to this review, not a claim that bytes publish; the same file says 「声明被复核推翻 ⛔ 不作席位过失」. The review has now measured the bytes — two files under packages/spec/scripts/, matched by none of the ten files[] entries, gen:schema moving nothing — so skip-changeset is correct for these bytes, and the level question is answered by the measurement rather than by the label. ⛔ The seat does not flip the declaration after the fact: that would rewrite the gate's own audit trail of why this review happened.
  • Flag 7 (no attribution trailers on the merge commits) — accepted as tool design, not a hand omission. scripts/pm/os-regen-merge.sh:716 hard-codes the step-3 message. Worth a one-line fix in that script eventually; ⛔ not this PR's, and ⛔ not a landing blocker. Noted here so the next reader of the history knows why four of seven commits carry no trailer.
  • Flag 8 (reviewer identity) — correct as recorded. An isolated review subagent has no session id of its own, so Reviewed-by: carries the session that adopts it; references/contract-review.md says exactly that. Independence here rests on the subagent boundary and on the tier census above, ⛔ not on the id string.
  • Flag 9 (behind main by 4) — no re-merge. The four newer commits touch neither script (0 rows) and main's anchor blob at fb6b2c36 equals this head's, so a re-merge would move nothing this review judged. The merge queue builds against the tip; if it reports the branch behind at landing time it is re-merged then, through scripts/pm/os-regen-merge.sh.

Seat correction carried from the merge round: the dispatch for that round asserted needs:contract-review was on both carriers. It was on this PR only; the round measured that and reported it rather than acting, and the seat repaired the split at 18:45:13Z. The adjudication is on card #17356.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 19:09
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 2882528 Sep 16, 2026
47 checks passed
@os-warren
os-warren deleted the claude/issue-17356-reachability-root-enumeration branch September 16, 2026 19:36
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…tack-ai#18243)

Fixes objectstack-ai#18229

## What changed

`scripts/pm/check-half-states.mjs` only. H31 compares the two carriers
of the
`needs:contract-review` gate, and it took `prDeliversCard` — the wide
body-first/branch-fallback delivery relation — as its population. That
relation
counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks
rather
than a delivery of it, so an epic tracker whose sub-PRs are gated
correctly drew
a row saying the gate was missing from the card half of a dual carrier.
The
row's remedy text is action-shaped, so acting on it hangs the gate on a
card
nothing will ever close — where the stroke that clears gates never
arrives.

Three edits:

1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read
of
`deliveryEvidence`'s existing grading, never a second keyword parser. It
answers "does this PR CLOSE #n", which is the question the gate's
clearing
stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer
false.
2. **H31's own population filter** — the carrier comparison runs over
the PRs
   `bindingClosesCard` accepts. Those rows are byte-identical to before.
3. **A DECLINED row for every other binding** — a weak-bound delivering
PR whose
carrier differs from the card's still produces a row, one that names
both
carriers and the binding it read, states that it declined to judge, and
prescribes no write. A weak-bound PR whose carrier agrees produces
nothing,
   exactly as before.

`TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one
spelling
across H13's exemption list and H31's new clause.

## Why this seam, and how the shared invariant survives

The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids
it
(「⛔ Do not narrow it here to serve H8: that would make the live half
invisible
to the rows that exist to see it」), and H8's open side, H35's sibling
resolver,
`claimDelivery`, H53 and the `check-clause2-carriers` pairing all still
read it
wide. The narrowing is H31's own filter over the population that
relation hands
it, so the rows still agree about which PR delivers which card and
differ only
about which binding makes a CARRIER PAIR — a question only H31 asks.
Both
docblocks that state the invariant were updated to say so rather than
left to
imply the old thing.

Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's
own
uniform: a split that was never judged renders identically to a board
whose two
carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」.
Hence the declined row, and hence its scope: it speaks only where the
carriers
actually differ, because a standing row per tracker per sweep is the
disease,
not the cure. The declined row is deliberately NOT marked
`UNJUDGED_MARKER` —
that marker buys trim priority ahead of judged rows, and a decline must
never
sort ahead of a real carrier split in the same `gate` band.

## Measured on the live board

Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run
34919049964,
carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212
(draft,
via a `Part of` declaration). Replayed offline against the 9 open PRs
and the 9
open cards those PRs name (the exact H31 candidate set — a card no open
PR names
can never reach the comparison):

| card | `tracking` | delivering evidence | row before | row after |
|---|---|---|---|---|
| objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none |
| objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none |
| objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none |
| objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none |
| objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none |
| objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding |
**DECLINED row** |
| objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none |
| objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none |
| objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none |

One row changes, and it is the expected one. Every other delivering
binding on
the live board is a closing keyword, so nothing else moves.

## Tests

`pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs
--self-test`): **4042 cases pass** before, **4075 cases pass** after,
exit 0 —
33 new cases.

The pin and the control both land, per the card's own remedies:

- pin: the `Part of`-only tracker still produces a row (⛔ not a silent
skip),
the row says `DECLINES to judge`, names the binding it read, explains
why a
ruling anchor can never clear a gate, and prescribes NOTHING; it is
asserted
  to be neither the old action-shaped sentence nor a LOUD row nor an
  UNJUDGED-ranked one.
- control: a closing-keyword binding whose card lacks the gate — the row
STANDS,
  with the unchanged action-shaped sentence.
- plus: agreement on a weak binding stays clean both ways; a weak-bound
card
without `tracking` still reports the split but without the ruling-anchor
  clause; an adjudicable split outranks a decline and names only the
  closing-bound PR.

**Ablation** (one-off, restored; run from the committed fix). Mutating
`bindingClosesCard` back to the pre-fix wide population
(`deliveryEvidence(pr, n) !== null`) turns the self-test RED:

```
== anchor counts BEFORE ==   old-text occurrences: 1 / new-text occurrences: 0
mutation written
== anchor counts AFTER ==    old-text occurrences: 0 / new-text occurrences: 1
HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811
MUT  blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75
VERDICT ablation-exit=1
✗ check-half-states self-test: 11 of 4075 case(s) failed.
== restore leg ==
REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811
old-text occurrences after restore: 1 / new-text occurrences after restore: 0
git diff HEAD bytes: 0
```

The mutation is proved on disk by the blob hash moving off the HEAD blob
and by
the two anchor occurrence counts flipping; the restore leg is proved by
the blob
hash returning to the HEAD blob and by an empty `git diff HEAD`, not by
an exit
code. There is no build step and no `dist/` for a repo-root `.mjs`, so
the
rebuild half of the ablation preflight does not apply here.

**Gates** — derived with no paths, at head `e0a4efa`:

```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 41 command(s)
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE
  -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN.
     EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED
     count above is DERIVED from them.
```

All 41 exited 0, each captured by redirect before any pipe. The gate
script's
own wiring is inside that set: `pnpm check:pm-half-states` is the
package.json
step lint.yml runs, and it is the script's `--self-test`.

**Lint** — a declared narrowing, not the farm. `eslint
scripts/pm/check-half-states.mjs
--no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0
errors, 0
warnings**, exit 0. The population is read from eslint's own config, not
guessed
— an `ESLint` instance over `git ls-files` reports `tracked=8674
in-scope=6755
ignored=1919 no-rules=0`. Invariance: this repo runs one
`eslint.config.mjs`
which enables type-aware linting for no file at all (every
`parserOptions` in it
is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero
`project`/`projectService` hits; the config says so itself at its
`QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff —
so no
rule's verdict on a file this PR did not touch can move because of it.
The
repo-wide `pnpm lint` run is CI's.

`skip-changeset`: `scripts/pm/**` is PM tooling and ships in no
package's
`files[]`, so nothing published moves.

## Acceptance notes

- `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this
is
in-seat review. `.github/workflows/half-state-patrol.yml` and anchor
objectstack-ai#9857 are
untouched: the workflow runs the script from `main`, so the fix reaches
the
  anchor at the first scheduled sweep after landing.
- objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier
that
`scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic
tracker
reached through a `Part of` line. It is out of scope here and remains
open.
  `bindingClosesCard` is exported for it: that file already imports
`deliveryEvidence` and `prDeliversCard` from this one, so the fix there
is an
  import rather than a second copy of the predicate.
- noted, not filed: the sibling install of this script in `objectui`
(`scripts/pm/check-half-states.mjs`) has already drifted from this copy
(different md5 at `b3b43b6`), while this file's header describes the
pair as
copied VERBATIM. No gate can hold two repos equal, so the wording is an
  aspiration rather than a claim — carrier: the next dev dispatched on a
  cross-repo patrol card.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ain tip (objectstack-ai#17356 letter A) (objectstack-ai#18485)

`Part of objectstack-ai#17356` — this PR carries **letter A only**: the anchor
advance. The finding itself is fixed by PR objectstack-ai#18131, which stays untouched
this round; out of scope here: objectstack-ai#17356 remains open, and objectstack-ai#18301 (letter
C) is serialized behind this.

## What is in the diff

Exactly one file — `packages/spec/authorable-surface.base.json`,
regenerated on this branch's own merge base with:

```
pnpm --filter @objectstack/spec gen:authorable-surface-base
```

⛔ No root-set widening. ⛔ No other file. `git diff --name-only
origin/main...HEAD` prints one line.

## Why it lands here and not inside PR objectstack-ai#18131

The authorable-key deletion gate (objectstack-ai#4650) compares deletions against the
baseline **at the merge base with `origin/main`** — its own error text
says so, and `build-schemas.ts` `resolveSurfaceBase()` reads the
baseline keys out of git at that rev, never out of the anchor file in
the tree under test:

> Deletions are therefore compared against the baseline at merge base
53ef057 with origin/main, which this commit cannot rewrite.

So advancing the anchor *inside* PR objectstack-ai#18131 was a no-op against that
gate: a commit in a PR cannot move the PR's own merge base. This PR's
merge base **is `main` itself**, so once it lands, every later PR that
merges `main` carries the advanced anchor in its merge base.

## Measurements (this branch, UTC)

| reading | value |
|:---|:---|
| branch base / merge base with `origin/main` |
`85c6d76ec42a879f219436eaa58bda06c68298b2` (2026-09-16T15:19:10Z) |
| anchor `baseRev` before | `53ef05744f37789a3e2e2ee94325a616b706bea0` —
7836 keys |
| anchor `baseRev` after | `85c6d76ec42a879f219436eaa58bda06c68298b2` —
7804 keys |
| generator's own verdict line | `⚓ authorable-surface.base.json
refreshed to 85c6d76 (7804 keys) — commit it, on its own.` |
| `data/Metric:filters` in the new anchor | **0** |
| `integration/DataSyncConfig:schedule` in the new anchor | **0** |
| control — `data/Metric:` keys in the same file | **6** (non-zero, so
the zero readings above discriminate) |
| control — `integration/DataSyncConfig:` keys in the same file | **8**
(the def is still there; only the retired key left) |
| files changed | 1 |

The anchor was **re-generated here, not cherry-picked** from
`dc98ee22a3`: that commit's advance targeted `b9598e9cab9d`, and `main`
has taken further landings since, so copying it would have pinned a
stale rev. The numbers above are this branch's own, measured; they are
not the ones the ruling quotes.

`--update-base` was not run mid-merge: this branch is a fresh branch off
`origin/main` with no merge in it, so the `objectstack-ai#5370` refusal
(`build-schemas.ts` refuses `--update-base` mid-merge because
`merge-base(HEAD, origin/main)` would re-anchor **backwards**) cannot
apply and was not worked around.

## The rulings this executes

Ruling `5682578124` (batch objectstack-ai#135 item 3, maintainer 「135 同意」,
2026-09-15):

> **A** — PR objectstack-ai#18131 gains a **second, separate commit** that advances
the committed anchor with `pnpm --filter @objectstack/spec
gen:authorable-surface-base`, run in a **full (non-shallow) clone**; the
PR body quotes this ruling (「135 同意」 on batch objectstack-ai#135 item 3, presented as
「A 本 PR 内独立提交 + C 另立卡」). Advancing the anchor is the deliberate
maintenance act the build's own output names; it forgives no future
deletion.
>
> **B** is refused: for `integration/DataSyncConfig:schedule` it would
reverse the maintainer's 2026-09-10 retirement ruling recorded in the
source.

Ruling comment `5696910871` (class-1 self-adjudication,
2026-09-16T11:46Z), which re-carries letter A after the seat measured
that the original carrier could not reach the gate:

> 1. Letter A lands **first, in its own PR against `main`**, containing
exactly the anchor-advance commit lifted from `dc98ee22a3`
(`authorable-surface.base.json` only: `53ef05744f37` 7836 keys →
`b9598e9cab9d` 7772 keys, re-generated on the PR's own merge base if it
has moved). ⛔ No root-set widening, ⛔ no other file. Its PR body quotes
ruling 5682578124 and this comment.
> 2. Once it is on `main`, PR objectstack-ai#18131 merges `origin/main` (via
`scripts/pm/os-regen-merge.sh`, never a bare merge) so its merge base
carries the new anchor; the two keys stop reading as deletions;
acceptance 2 of the original dispatch is then measured on objectstack-ai#18131's next
green run.
> 3. Letter B stays refused (the objectstack-ai#16320 2026-09-10 retirement of
`integration/DataSyncConfig:schedule` is not reversed). Letter C stays
objectstack-ai#18301 and is serialized behind this (same file).

Letter B is not reversed by this PR:
`integration/DataSyncConfig:schedule` stays retired. Letter C (objectstack-ai#18301)
is not touched.

## The one premise this PR itself tests

The ruling records it as unmeasured, and this PR's first CI run is the
measurement:

> The seat's inference — «an anchor-only PR that does not widen the root
set is green, because proof 2 still waives the two keys on that PR's
merge base» — is unmeasured. The split PR's first CI run is the
measurement. If it is red for the same reason, that is a genuine fork:
stop, post the first error line, and this card returns to the decision
box — ⛔ not patched around.

Read against the gate's source, the local expectation is narrower than
an inference about proof 2: this branch emits the **same** authorable
surface as its merge base (no schema and no root-set change), so on this
PR the gate has no deletion to adjudicate at all. If CI disagrees, the
first error line from the job log is reported and this is not patched
around.

## Acceptance notes

- `noted, not filed:` the generator prints its anchor verdict as `⚓ … —
commit it, on its own`, which is the advice this PR follows; nothing in
the tooling *enforces* that the anchor advance travels alone, and the
enforcement gap is what made the earlier in-PR carrier look valid. No
card filed — it is an observation, not a reproducible defect, a contract
violation, or an authoring trap.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… not the registered set (objectstack-ai#18581)

Fixes objectstack-ai#18133

Clause-②: no

This card **tightens** governance coverage: the denominator the liveness
ratchet divides by grows, three previously-unreachable types become
nameable, and no accept set, public export or schema moves. The registry
itself is byte-untouched.

## The defect, measured first-hand against `origin/main`

`check-liveness.mts` built `report.ungoverned` from
`listMetadataTypeSchemaTypes()` under a comment stating that function
returns *"exactly the set of authorable metadata types"*. That sentence
is the declared contract, and it is false — the same sentence objectstack-ai#17356
measured false for the reachability gate, one gate over.

| reading (probe run at base `879b51270`) | result |
|---|---|
| `listMetadataTypeSchemaTypes()` | 26 names; `analytics_cube` /
`connector` / `sharing_rule` / `webhook` absent from all four |
| `listUnregisteredKindSchemaTypes()` | exactly those four |
| firing control on the same probe | `view` and `flow` present in the
registered set; `view` absent from the unregistered set |
| `getMetadataTypeSchema(t)` for each of the four | resolves a schema
(third fallback, objectstack-ai#6245) |
| `GOVERNED` / `PENDING_GOVERNANCE` membership | `webhook` governed; the
other three in **neither** map; `PENDING_GOVERNANCE` was `{}` |
| `packages/spec/liveness/` | no `connector.json`, no
`sharing_rule.json`, no `analytics_cube.json` |

⇒ a type in neither map produces no row in **any** of this gate's lists,
so `ungoverned: []` read identically whether the gate had looked and
found nothing or had never looked at all. That indistinguishability is
the finding; the count of what it hid is deliberately not claimed.

**One card correction.** The card attributes `webhook`'s manual patch to
an `EXTRA_SCHEMAS` row. That identifier does not exist anywhere in this
repository — the mechanism is `SPEC_ONLY_SCHEMAS` in
`check-liveness.mts`, plus `liveness/webhook.json`. The claim is right,
the symbol name is not.

## Why the repair is local, on the merits

The dispatch flagged a possible fork into
`packages/spec/src/kernel/metadata-type-schemas.ts`. It is not one, and
the target file says so itself. `listUnregisteredKindSchemaTypes()`
already exists there (objectstack-ai#6931) and its own docblock declares:

> `[objectstack-ai#6931]` This exists so a check can ENUMERATE that map, and for
nothing else.
> ⚠️ Being listed by this function grants NOTHING. It returns names, not
schemas, not descriptors: no `MetadataTypeSchema` enum membership, no
`DEFAULT_METADATA_TYPE_REGISTRY` entry, no create seed, no authorization
verdict, no place in the objectstack-ai#4001 campaign count.

And the precedent is already landed, in the second consumer the dispatch
warned about: `reachabilityRootTypes()` in `scripts/build-schemas.ts`
(objectstack-ai#17356, PR objectstack-ai#18131) is this exact union, computed **inside the consuming
gate**, whose docblock states that `listMetadataTypeSchemaTypes()`
"answers its own question correctly and this file does not touch it."

⇒ objectstack-ai#6245's guarantee survives byte-for-byte: `metadata-type-schemas.ts`
is not in this diff. The gate that was asking the wrong question is
where the question is fixed.

**Not yet one shared spelling.** The card suggests a single helper read
by both gates. `scripts/build-schemas.ts` is held by seat 1 (objectstack-ai#18301 / PR
objectstack-ai#18529), so folding the two together is a follow-up; until then each
docblock cross-references the other by name.

## What the gate now prints that it did not before

Green run, before → after:

```
before:  (no coverage line at all — it printed only when PENDING_GOVERNANCE was non-empty)
after:   governance denominator: 30 authorable type(s) — 26 registered kind(s) + 4
         unregistered-kind stack collection(s) (analytics_cube, connector,
         sharing_rule, webhook); 27 governed, 3 awaiting a ledger (analytics_cube,
         connector, sharing_rule) — a worklist, not a merge gate.
           (+ 9 type(s) governed from OUTSIDE the denominator via SPEC_ONLY_SCHEMAS —
           not metadata types, so the override IS their governance; 36 governed in total.)
```

The success sentence changed too: "every **registered** type is governed
or explicitly pending" is now "every **authorable** type — registered
kind or unregistered-kind stack collection — is governed or explicitly
pending".

**Does it turn any existing check red? No, and here is the arithmetic.**
The three newly visible types are recorded as declared debts in
`PENDING_GOVERNANCE` with a reason and an issue number apiece — the
disposition the gate's own failure text prescribes, and the one that
keeps them *stated* rather than *silent*. `pnpm --filter
@objectstack/spec check:liveness` exits **0** on this branch, and
`state-counts.md` and the README state table are both still current (36
rows, unchanged — the ledger set did not move). Leaving them in
`report.ungoverned` instead would exit 1 on every PR in the repo, which
is a broken `main`, not a delivered finding.

## Reverse verification — two legs, both fired, both restored

Run against the committed implementation; each leg proved its mutation
reached disk by anchor count before the gate ran, and each restored via
`git checkout HEAD -- PATH` under a `trap ... EXIT INT TERM`, verified
by blob hash rather than by an exit code.

**Leg A — the new denominator really reaches an unregistered kind.**
Removed the `connector` row from `PENDING_GOVERNANCE` (anchor count 1 →
0):

```
✗ 1 AUTHORABLE metadata type(s) governed by nothing:
    connector
```
exit **1**. Under the old denominator no edit to that map could have
produced this row for `connector` — it would have been reported *stale*
instead, because the type was not in the set at all.

**Leg C — the union line is load-bearing, not decorative.** Reverted
`authorableTypes()` to the registered set alone, keeping the three rows
(anchor count 1 → 0, injected marker count 1):

```
✗ 3 stale PENDING_GOVERNANCE row(s) — the debt is already paid:
    analytics_cube
    connector
    sharing_rule
```
exit **1**. That output is precisely the old code's whole vocabulary for
these three: *"not a type I know"*, never *"a type nobody governs"*.

Restore proof for both legs: on-disk blob
`b3c8aded6e38ce73bd8097dba62554da19ec97ff` equals
`HEAD:packages/spec/scripts/liveness/check-liveness.mts`, and `git diff
HEAD --stat` for that path is empty. No permanent test file was left
behind by either leg.

## Tests

`scripts/liveness/check-liveness.test.ts` gains a `objectstack-ai#18133` block that
holds the **gate's output answerable to the live registry** rather than
to a copied list — a hard-coded expectation would pass unchanged if the
gate stopped reading the registry at all, which is the regression class
this is for.

- a control that both sides of the union are non-empty and disjoint
(without it, "the denominator omits nothing" is satisfied by a registry
that enumerates nothing);
- the denominator contains every `listUnregisteredKindSchemaTypes()`
name, is **strictly larger** than the registered set, and equals the
union exactly — this is the assertion that goes red the moment somebody
simplifies the union away;
- every member is governed or explicitly pending, `ungoverned` and
`stalePending` both empty;
- the composition line is printed on a **green** run;
- objectstack-ai#6245's guarantee asserted from the gate that had the motive to break
it: the unregistered kinds are still absent from
`listMetadataTypeSchemaTypes()`.

## Verification

Final commit `7c98551bac`; every reading below is from that tree.

- `pnpm --filter @objectstack/spec exec vitest run
scripts/liveness/check-liveness.test.ts` — 58 passed (was 53).
- `pnpm --filter @objectstack/spec test` — **482 files passed, 1
skipped; 13776 tests passed, 1 skipped**.
- `pnpm --filter @objectstack/spec typecheck` — exit 0. Both edited
files are proven in a tsc program: `tsc -p tsconfig.scripts.json
--listFiles` names `scripts/liveness/check-liveness.mts` and
`scripts/liveness/check-liveness.test.ts`.
- `pnpm --filter @objectstack/spec check:liveness` — exit 0.
`check:empty-state` — exit 0.
- **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` off the merge base (never
a hand-fed path list), reconciled with `--ran`: **68 derived, 63 run
green, 5 NOT MEASURED, 0 UNRUN**. Every exit code was landed to disk
before being read.
- The 5 NOT MEASURED all exit **3** — `PREREQUISITE NOT MET`, each
refusing because no package has a `dist/`: `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`, `check:type-check-debt`. They need
a repo-wide build; this diff contains no built-output source, and CI
covers them. ⛔ Neither pass nor finding.
- `pnpm check:cross-package-test-inputs` exits **0** here — consistent
with the known `packages/spec`-is-built behaviour already filed as
objectstack-ai#18353 / objectstack-ai#18440; nothing new is filed for it.
- **Lint, narrowed and the narrowing proven.** `eslint
--no-inline-config --format json` over the two changed TS files: **2
files linted, 0 errors, 0 warnings** (count read from the JSON, not from
prose). Population read from `eslint.config.mjs` itself: the global
block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus
`NEVER_LINTED`, so the two `.md` paths in this diff are outside eslint's
population entirely. Invariance: that config declares in its own words
that this repo "runs one `eslint.config.mjs`, which never enables
type-aware linting (no `parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file" — so nothing in this diff can
move the verdict on a file it does not touch. The repo-wide sweep is
CI's.
- Control-character scan beyond `check:nul-bytes`: `grep -naP` over all
four changed files — no matches.

## Declared deviation — one file outside the dispatched surface

The dispatched surface was `check-liveness.mts` · its test · any
`liveness/*.json` made owed · `.changeset/*.md`. This diff also edits
**`packages/spec/liveness/README.md`**, and that is a deliberate,
declared addition rather than an oversight:

1. that README carries a **second copy of the same false sentence** —
"i.e. exactly the set of *authorable* metadata types" — so repairing
only the script would have left the card's own warning realised: the
sentence reappearing at a third door;
2. more decisively, this change makes an existing published sentence
there **factually false**: "`PENDING_GOVERNANCE` in `check-liveness.mts`
is empty". Shipping a diff that falsifies shipped documentation is the
shape this ledger's own history (objectstack-ai#7257) exists to stop — a completeness
sentence no build can fail.

`packages/spec`'s `files[]` ships `liveness/`, so that README is
published — which is also why this PR carries a `patch` changeset rather
than `skip-changeset`. Nothing under `scripts/` ships; had the README
not been owed, no published byte would have moved. Both edits are prose
only: the state table, its heading count (36) and `state-counts.md` are
untouched, and `check:liveness` reconciles all three green.

## Acceptance notes

Observed while measuring, **not** filed and **not** fixed here:

- The `SPEC_ONLY_SCHEMAS` row `webhook: WebhookSchema` is now redundant
with `getMetadataTypeSchema()`'s objectstack-ai#6245 fallback, which resolves the
identical schema instance. Harmless and load-bearing as documentation;
folding it away is the objectstack-ai#3490 reassessment's business, not this card's.
- `listUnregisteredKindSchemaTypes()`'s docblock still lists `theme`
among "today's" entries; `theme` was retired at objectstack-ai#10485 and the map now
holds four. Stale prose in a read-only file — a doc nit, not one of the
three filable classes.
- Under Leg C's ablation the new coverage line reads "26 authorable
type(s) — 26 registered kind(s) + 4 unregistered-kind stack
collection(s)", i.e. visibly self-contradictory, because the count and
the composition are two independent reads. That is a tell, not a defect:
a reverted union announces itself in the line's own arithmetic.
- `pnpm --filter @objectstack/spec test` was invoked with a trailing `--
--maxWorkers=2`; vitest discards everything after a bare `--`, so the
suite ran at default concurrency. Recorded because the reading is the
suite's, not the flag's.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

---------

Co-authored-by: objectstack-agent <agent@objectstack.ai>
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…le def (objectstack-ai#18301) (objectstack-ai#18529)

Fixes objectstack-ai#18301
Clause-②: no

Executes the **C** half of the objectstack-ai#17356 ruling (batch objectstack-ai#135 item 3,
maintainer 「135 同意」). A is already landed (PR objectstack-ai#18485 advanced the
deletion-gate anchor); B and D were refused. This card adds a proof — it
retires nothing, and it reverses nothing.

> **Patch round.** The contract review of record (comment `5706880661`,
served `CONTRACT_REVIEW_TIER`) returned **FAIL** on ① item 3: the
proof's stated "the door is closed" condition described something the
gate did not compute. This round replaces that condition, corrects every
place the claim was made, and pins the case that was unpinned.
Re-measuring the review's own sweep **falsified its latency finding** —
see "The assumption that did not hold" below.

## What was wrong

Check (c) of the `authorable-surface/` deletion gate
(`packages/spec/scripts/build-schemas.ts`) admitted a deleted baseline
line on three proofs: an aged-out `[RETIRED]` tombstone, an unreachable
def, or a def the build no longer emits.

A key retired the **strict-schema / guidance way** — deleted from the
shape outright, its prescription moved into the closed shape's
`guidance` table — never carries the `[RETIRED]` mark, because there is
nothing left in the shape to mark. Proof 1 therefore could not apply to
it **at any major**: not "has not aged yet" but "has no clock". On a
reachable def that left the whole class with no proof shape at all.

The class was invisible until now because proof 2 was answering for
these defs — the BFS root set omitted the four unregistered kinds, so
whole families read as unreachable and every deletion under them was
waived as over-collection. objectstack-ai#18131 repaired the root set, and the repair
is what exposes the gap.

## The assumption that did not hold

The review swept for a def that could satisfy proof 4's conditions while
silently STRIPPING the author's write, found none, and recorded the hole
as latent. The dispatch asked for that to be re-measured. It was, **with
the gate's own instrument** rather than by grep — a census pass over all
1525 emitted defs, running proof 4's declaration match and then asking
each def what it does with the key. It is not latent:

| def | artifact `additionalProperties` | matches one declaration by
shape identity | reachable | writing `keyBy` |
|---|---|---|---|---|
| `shared/RateLimitConfig` | `false` | yes | `root-graph` | **parse
SUCCEEDS, key dropped** |
| `system/ServerRateLimitConfig` | `false` | yes (the SAME declaration)
| `derived-clone` | refused, with the prescription |

`ServerRateLimitConfigSchema` is declared `strictObject({… guidance: {
keyBy, store } }, RateLimitConfigSchema.shape)` — built FROM the open
schema's own shape object
(`packages/spec/src/system/stack-server.zod.ts`,
`packages/spec/src/shared/http.zod.ts`). So one declaration answers for
two emitted defs, and **every fact the first cut of proof 4 read says
they are the same def**. Two keys (`keyBy`, `store`) on a root-reachable
def: had either baseline line been deleted, the shipped implementation
would have waived it while an author who keeps writing the key has it
silently dropped.

That is the review's "strip-mode clone shares a strict shape" case in
the spelling the tree actually holds — shape sharing in the other
direction, which is why a sweep for `.strip()`, `z.object(X.shape)` and
`strictObjectError()` found nothing. **No wrong verdict has shipped**:
proof 4 is not on `main`, and neither key is a pending deletion. What
changes is that the fix is now mandatory rather than prophylactic, and
the fixture below is a real specimen rather than a synthetic one.

## What this adds

**Proof 4.** A deleted baseline line is legitimate when, on a def that
is emitted and reachable, **all three** of these hold in this build's
own tree:

1. **the baseline entry was not `[RETIRED]`** — a guidance-route
retirement deletes the key from the shape instead of leaving a
`retiredKey()` in it, so it never earned the mark. This is a property of
the class, not a guard bolted on, and it is what keeps proofs 1 and 4
disjoint.
2. **a `strictObject` declaration promises a prescription for the key**
— the def resolves to exactly one `StrictObjectDeclaration` by shape
identity, and that declaration's `guidance` names the exact key, or one
of its `guidanceSets` **enumerates** it. This half says which text is
owed.
3. **the def keeps that promise** — `safeParse` of that key against the
schema `zodByDefKey` holds raises an `unrecognized_keys` issue naming
it, and that issue's message carries the declared text **verbatim**.
This half is the door.

Condition 3 replaces the condition the review failed. Nothing else in
the gate moves.

### Why the artifact read is gone rather than restated

The failed version proved "the door is closed" by reading
`additionalProperties === false` off the emitted JSON Schema. **This
repo had already measured that this does not distinguish a closed door
from a silent strip** and written it down: `build-schemas.ts` converts
with the default `io: 'output'`, and in output mode zod emits
`additionalProperties: false` for a `.strip()` object too — verified in
`docs/audits/2026-07-unknown-key-strictness-ledger.md` by regenerating
both ways to a byte-identical artifact. A condition that answers the
same for both cases cannot be the one that excludes one of them, so it
is removed, and the docblock and the author-facing remedy now say so in
the gate's own words.

The subtler half, which the review named and which the census above
confirms: **shape identity is not a door test either.**
`strictObjectError()` registers a declaration without closing the shape,
`.strip()` and `z.object(Strict.shape)` clone a shape without its door,
and `strictObject(opts, Open.shape)` — the live case — puts a closed
declaration and an open def on the same shape entries. The identity
match stays, because it is how the owed text is found; it is no longer
asked to prove closure.

### Why the probe reads `unrecognized_keys`, and why it reads the
message

`unrecognized_keys` is the **only** issue code a `guidance` table is
ever consulted from (`strictUnknownKeyError` returns undefined for every
other code), and the prescription is appended to that message verbatim,
one bullet per key. So the issue's presence is exactly "this def refused
the write", and the declared text appearing in its message is exactly
"the error map this def parses through is the one holding that table" —
which shape identity alone cannot tell, since a clone can share a shape
without sharing a map. No message WORDING is pinned by this: the needle
is read out of the tree, from the very declaration the structural half
matched, so a rewritten prescription moves both sides together.

The alternative the review offered — reading `catchall` of type `never`
off the instance — was measured to give identical verdicts on all four
shapes tried (`strict`, `.strip()` clone, plain `z.object`,
`catchall(z.string())`). It was not chosen because it proves a spelling
of the door rather than the delivery of the prescription, and it would
still have admitted a strict clone built without the declaration's error
map. The other alternative — recording `strictObject()` and
`strictObjectError()` distinctly in the registry — is a
`packages/spec/src/shared/strict-object.ts` edit, outside this card's
two files and across the clause-② path limb, and it would not have
caught the live case above at all (both twins' declaration comes from
the same `strictObject` call).

### A third verdict, and what it deliberately does not say

A key a declaration names but the def does not answer for now gets its
own violation line instead of the generic "was LIVE (never tombstoned)"
— its `guidance` entry already exists, and what is missing is a door to
deliver it through, so the generic verdict would send its reader to
write something already written. That line states only **that** the
prescription did not arrive, never **why**: on the shipped graph 7 of
the 8 defs in that state are unions, where "the door is open" would be a
guess this gate has not measured — the mistake the first cut made about
`additionalProperties`, one layer down.

### Two narrowings, both deliberate, both fail-closed

- **Exactly one matching declaration.** An empty shape is excluded
outright — it matches every other empty shape. Where two declarations
still answer, the lookup returns "no evidence" rather than unioning
them.
- **A `guidanceSets` RegExp does not count.** Only an enumerated `keys`
list NAMES the key; a pattern claims a family whose members were never
written down.

### Measured population — why this is a proof and not a blanket waiver

Census over the shipped graph, run with the gate's own code (tree
`944d773b8`; `packages/spec/src` is byte-identical at the head this PR
now carries, `git diff --name-only` over that path returns 0 lines):

| reading | value |
|---|---|
| emitted defs | 1525 |
| defs whose emitted artifact carries `additionalProperties: false` |
1117 |
| defs resolving to exactly one declaration that names an undeclared key
| 258 |
| keys those declarations promise | 779 |
| keys the def actually delivers — what proof 4 admits | **770** |
| keys promised and NOT delivered — what proof 4 refuses | **9** |

Of the 9: 2 are the live case above; 7 are union defs the probe cannot
drive to a single door, all of which the superseded artifact condition
also excluded, so no verdict moves for them.
`integration/DataSyncConfig` has no route at all (its shape is a plain
`z.object` and nothing prescribes for `schedule`), so this proof cannot
reach the 2026-09-10 ruling that withheld that tombstone.

## Evidence

### The pins (`build-schemas-check-mode.test.ts`)

| fixture | expected | what it would catch |
|---|---|---|
| `data/Metric:filters` | admitted by **proof 4**, explicitly **not**
proof 2 | a proof that never fires |
| `data/Metric:zzNotPrescribed18301` | still refused, and NOT with the
third verdict | a waiver keyed off the DEF instead of the KEY |
| `integration/DataSyncConfig:schedule` | still refused | a silent
reversal of the 2026-09-10 ruling |
| `api/SessionResponse:zzOverCollected4650` | still waived by **proof
2**, in proof 2's words | proof 4 written as a widening of proof 2 |
| `data/Object:compactLayout [RETIRED]` | falls to the tombstone chain,
**not** proof 4 | the disjointness — it satisfies every other condition
proof 4 tests |
| `system/ServerRateLimitConfig:keyBy` | admitted by **proof 4** | — the
lit half of the new pair |
| `shared/RateLimitConfig:keyBy` | **REFUSED**, with the third verdict,
and not waived by proof 2 either | **the review's finding**: one
declaration, two defs, and a gate that reads the registry instead of the
door admits the open one |

The last two are ONE run and ONE declaration, which is what makes them a
discriminator rather than two assertions. The `beforeAll` guard holds
the tree fact they model in four loud halves: the two twins declare the
same key SET, share every shape ENTRY by instance identity, the open
twin ACCEPTS `keyBy` and the parsed output does not contain it, and the
closed twin rejects it with a prescription bullet. If any half rots, the
pin says so instead of going quietly green.

Every negative assertion in the proof-4 cases was also corrected: they
were written as `KEY — TOKEN` where the gate emits `KEY — def REACH;
TOKEN`, so they could not have matched even on an admitted key. They now
carry the `def .*` span and fail when they should.

### Ablations — both directions, on-disk proof, restored

Both legs prove the mutation reached disk before any colour is read, and
both restores are proved by `git hash-object` against the HEAD blob plus
a whole-tree `git status --porcelain`. Each script arms a `trap` on
EXIT, INT and TERM that restores the file from HEAD, against an absolute
path resolved from `git rev-parse --show-toplevel`.

**Ablation C — blind the door probe** (`delivers()` returns `true`
unconditionally, which is the superseded implementation's behaviour for
this def):

- marker occurrences 0 to 1, blob `322938f2` to `682ce658` — the
mutation is on disk.
- run **RED**, and in the sharpest possible direction: `eager.status`
came back **0**. With the door blinded the gate WAIVES
`shared/RateLimitConfig:keyBy` and the whole run exits green — the hole,
executed, not argued. The other two objectstack-ai#18301 cases stayed green,
correctly: neither tests the door.
- restored: blob back to `322938f2`, marker back to 0, `git diff HEAD` 0
bytes, `git status --porcelain` 0 lines.

**Ablation D — deafen the door probe** (`delivers()` returns `false`
unconditionally):

- marker occurrences 0 to 1, blob `322938f2` to `3096b1af`.
- run **RED**, 2 cases: both positive legs fall to the third verdict —
`data/Metric:filters` and `system/ServerRateLimitConfig:keyBy` both
printed `a \`strictObject\` declaration NAMES …, but writing it`. So the
probe is load-bearing for the admissions too; proof 4 is not the
declaration match wearing a new name.
- restored: blob back to `322938f2`, marker back to 0, `git diff HEAD` 0
bytes, `git status --porcelain` 0 lines.

The previous round's ablations A and B were run against the superseded
implementation (their anchor, `prescribed?.has(leaf)`, no longer exists)
and are **not** carried forward as evidence for this head.

`scripts/ablation-dist-preflight.mjs` still reports `no dist/` for this
package and is **NOT MEASURED**, not red, for the same reason as the
previous round: the test spawns `tsx` over `scripts/build-schemas.ts` in
a sandbox that SYMLINKS the real `packages/spec/src`, so nothing here
resolves through `dist/`. The instrument that applies is the on-disk
marker count plus the run's own colour, both recorded above.

## Runs

Long runs went through `scripts/pm/os-verify-lock.sh`; exit codes were
captured by redirect-then-`$?`, never through a pipe.

`origin/main` was merged into this branch (`79a046f8c`) before this body
was written, and every reading below is on the merged head.

| command | verdict |
|---|---|
| `pnpm --filter @objectstack/spec run test:repo` | `VERDICT
command-exit 0` — 31 files, 529 passed |
| `pnpm --filter @objectstack/spec typecheck` | `VERDICT command-exit 0`
(`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`) |
| `pnpm --filter @objectstack/spec run check:authorable-surface` | exit
0 |
| `pnpm lint` (the repo-wide `eslint . --no-inline-config`) | exit 0 —
the FULL run, not a narrowing, at `9e0324f80` |
| `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` | 61 families derived ON THIS HEAD, not inherited |

`pnpm lint` is normally CI's to run; it completed here, so the reading
is the whole population eslint's own config selects rather than a subset
— no narrowing claim is being made and none needs checking.

All 61 derived gates were run and reconciled with `--ran`, each line
carrying its exit code. 55 exit 0. Five exit **3 (PREREQUISITE NOT
MET)** and are **NOT MEASURED** — each needs a built `dist/`, which this
worktree has never had, and none can be moved by a diff confined to
`packages/spec/scripts/**`: `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`, `check:type-check-debt`.

`pnpm check:pm-dispatch-gates` needed 807s and was recorded as `exit
124` on a first pass whose 600s wrapper fired; it was re-run without the
cap and exits **0**. The record carries the real code, not the timeout.

`packages/lint/scripts/check-reference-carrier-shape.mjs` is still
present on this head and exits 0 — PR objectstack-ai#18503, which retires it, had not
landed when this list was derived. The list was re-derived here rather
than inherited from the dispatch, exactly because of that.

## Scope and publishing

`packages/spec/scripts/**` matches none of the package's `files[]`
entries (`dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`,
`README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`,
`spec-changes.json`), and it is not a `tsup` entry — the only `scripts/`
string in `packages/spec/tsup.config.ts` is a repo-root import, against
a lit control of 22 `src/` occurrences. Nothing publishes, so `Clause-②:
no` and `skip-changeset`.

The diff is the two files the card fenced and no others: `git diff
--name-only` against the merge base returns exactly those two. In
particular the fix did **not** need `packages/spec/src/**` — the
dispatch's stop condition on that point does not fire.

## Acceptance notes

Out-of-scope observations, noted and deliberately **not** filed — none
is a reproducible defect, a declared-contract breach, or a trap that
makes an author write metadata the runtime rejects or silently drops:

- **Superseded.** The previous round's note here claimed proof 4 "works
around" the registry's door-blindness by reading `additionalProperties:
false` off the emitted artifact. That was wrong, per the review and per
this repo's own ledger, and the section above is what replaces it.
Nothing about the registry is "worked around" now: closure is decided at
the def, and the registry is asked only for the owed text.
- `strictObject()` and `strictObjectError()` are indistinguishable in
`strictObjectDeclarations()`, so the registry alone still cannot answer
a door question. This proof no longer asks it one. Recording the two
call shapes distinctly would let a future reader ask directly. Carrier:
whoever next reads `strictObjectDeclarations()` for a door question.
(`packages/spec/src/shared/strict-object.ts`)
- 408 of the 1525 emitted defs do not carry `additionalProperties:
false` on the emitted artifact. Per the review, that counts artifacts
whose TOP-LEVEL field is not `false` — unions, loose objects, pipes —
and is **not** the objectstack-ai#4001 ledger's strip-site population, which
`check-strictness-ledger.mts` counts by AST. Carrier: the
strictness-ledger worklist, which already owns that surface.
- `scripts/ablation-dist-preflight.mjs` reports `no dist/` as a refusal,
which is correct for a dist-mediated ablation and reads as an accusation
for one that resolves through source. Carrier: none today — the script's
header already prescribes the property-read alternative by hand.

There is one observation this round declined to file and flags for the
reviewing seat rather than burying: `shared/RateLimitConfig` is an
**open** `z.object` whose shape is reused, closed, by
`ServerRateLimitConfigSchema`, and the `guidance` entries for `keyBy` /
`store` therefore prescribe to nobody on the open twin — an author
writing `keyBy` on an API endpoint's `rateLimit` has it dropped in
silence. That is objectstack-ai#4001's own failure mode on a live authorable surface,
and it sits in `packages/spec/src/**`, outside this card's fence. It is
a candidate class-(c) card for the triage seat, not a finding this PR
may act on.


---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

---

## Landing note (seat, 2026-09-17)

Contract review at `CONTRACT_REVIEW_TIER` on head `9e0324f807`: **PASS**
— record is comment `5707796462`. It supersedes the earlier **FAIL**
(`5706880661`), which bound head `121465ba16` and does not bind this
one.

⭐ **The re-review did not read this code, it ran it.** With no
`node_modules` on the box it materialised `zod@4.4.3` and `esbuild` out
of pnpm's content-addressed store, `git archive`d this head's
`packages/spec/src` (archived tree hash verified equal to `git rev-parse
9e0324f:packages/spec/src`), bundled, and executed this head's own
`computeGuidanceRoutes` — verbatim, `diff`-checked — against 11
synthetic door shapes and a full 1525-def census, with the OLD head's
function alongside as the control.

**The FAIL's one verdict-bearing item is closed, measured rather than
argued:** `Strict` → prescribed, but `Strict.strip()`,
`z.object(Strict.shape)`, `Strict.loose()`, an error-map object without
`.strict()`, and `z.object(Strict.shape).strict()` without the map are
**all refused**. Every one of those stripping forms also emits
`additionalProperties: false` — which is the superseded condition's
blindness demonstrated on the instance instead of quoted from the
ledger. And the live twin executed both ways:
`shared/RateLimitConfig:keyBy` reads `prescribed` through the OLD
function (the hole, run) and `declared-but-silent` through this one.

### ⚠️ Correction to this body

The row 「defs resolving to exactly one declaration that names an
undeclared key: **258**」 is **mislabelled**. That population measures
**147**; 258 counts defs resolving to exactly one declaration *whether
or not it names anything*. Corrected here because this repo squashes and
the body becomes the permanent commit message. A second figure, the
docblock's 「7 of the 8 defs in that state are unions」, is also wrong (9
keys on 4 defs, 3 unions) but lives **in code** — both are carried by
**objectstack-ai#18579** rather than fixed in-branch, because a third push would move
the head and void the review described above. ⚠️ Neither figure moves a
verdict or describes a safeguard, and the rationale they support (unions
dominate the not-delivered set) survives the corrected arithmetic.

### Seat ruling on the process question the review referred here

The review declined to rule on whether a dev may read a stop instruction
by its stated rationale, and named it the seat's. **Ruling: the dev was
right, and the dispatch order was at fault.**

That order said 「if a live member exists, STOP AND REPORT — on the
reading that it would mean a wrong verdict is shipping」. That bundles a
**trigger** with a **rationale**. The dev measured the trigger TRUE,
then measured the rationale FALSE (proof 4 is not on `main`; a
`guidance`-only key is never in the shape, so it was never a baseline
line and no deletion could ever put it to proof 4 — `keyBy`/`store` 0 in
the baseline against a lit control of 1 for `enabled`), and disclosed
both rather than quietly proceeding. Stopping there would have parked a
proven-wrong proof in a draft and delayed a fix that had to land before
this PR anyway.

⛔ This is **not** a general licence to reason past a fence. The
correction belongs on the seat's side: a stop condition must be written
as a **condition**, with its rationale separate and non-operative. The
general rule stands — where a dev cannot measure the rationale false,
the trigger governs and it stops.

**Out of scope, correctly handed over rather than acted on:** the live
trap the round found — `shared/RateLimitConfig` is an open `z.object`
whose shape is reused *closed* by `ServerRateLimitConfig`, so an
authored `keyBy` is dropped in silence — is filed as **objectstack-ai#18578**. It
lives in `packages/spec/src/**`, outside this card's fence, and ⛔ was
not folded in.

**Pre-landing checks:** ① review PASS on record ✅ · ② `--pair 18529`
exit 0; ⛔ no carriers hung (`Clause-②: no`, verified a true declaration
against both limbs) ✅ · ③ re-taken at landing time ✅. Governed-surface
predicate: **0 of 2 paths hit the register** ⇒ ordinary queue landing.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

3 participants