Skip to content

refactor(lint): derive the runtime gate's name-keyed collection set instead of hand-listing it - #13769

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13390-derive-name-keyed-stack-keys
Aug 31, 2026
Merged

refactor(lint): derive the runtime gate's name-keyed collection set instead of hand-listing it#13769
os-project-manager merged 1 commit into
mainfrom
claude/issue-13390-derive-name-keyed-stack-keys

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #13390

Derives the runtime publish gate's name-keyed collection set instead of hand-listing it.

The hole

The set of collections the gate carries in a per-write snapshot was written down in five
places. NAME_KEYED_STACK_KEYS was the one with no guard of any kindCONTEXT_STACK_KEYS
carries a satisfies clause, which is validity rather than completeness, and the compiler held
nothing else.

It carries a real invariant: a collection the CONTEXT fills and that some write type maps
into must be name-keyed, or a finding's path is a positional index into an in-memory snapshot
the caller has never seen and cannot enumerate — the defect #10064 fixed for objects /
permissions / books. Omitting a member did not fail to build, fail a test, or fail a gate; it
emitted correct-LOOKING findings the receiver cannot resolve. Adding the pages collection had
to touch all five spellings and only one announced itself — the one the compiler could see,
and only after that accumulator was retyped as a mapped type.

The change

NAME_KEYED_STACK_KEYS and the TOP_LEVEL_INDEX pattern built from it are now derived from the
two inputs that already state the answer: CONTEXT_STACK_KEYS intersected with the values of
TYPE_TO_STACK_KEY. Both inputs live in the same file, so the derivation is entirely inside
packages/lint.

Files touched, complete: packages/lint/src/runtime-gate.ts,
packages/lint/src/runtime-gate.derived-name-keys.test.ts (new), one changeset.
packages/metadata-protocol/src/runtime-authoring-gate.ts — the fifth spelling,
CLOSURE_CONTEXT_KEY_BY_TYPE — is deliberately not touched; it is another lane's surface and
is filed separately as #13768.

The derivation reproduces the hand list exactly — measured BEFORE anything changed

A read-only TypeScript AST extraction of all four literals on origin/main:

CONTEXT_STACK_KEYS        = ["objects","permissions","books","datasets","pages"]   (5)
values(TYPE_TO_STACK_KEY) = ["actions","agents","books","dashboards","data","flows",
                             "hooks","objects","pages","permissions","views"]      (11)
DERIVED (intersection)    = ["objects","permissions","books","pages"]              (4)
NAME_KEYED_STACK_KEYS     = ["objects","permissions","books","pages"]              (4)
TOP_LEVEL_INDEX           = ["objects","permissions","books","pages"]              (4)
EXCLUDED (context minus derived) = ["datasets"]

derived === NAME_KEYED, same members AND same order : YES
missing=[]  extra=[]                                : the invariant HOLDS today, not already broken

Four members, not five and not three. datasets falls out on its own for exactly the reason the
old comment had to state by hand — it is context-only, no write type maps into it — so no
member needed a hand-written exception and none is kept
. If a future member ever does need one,
the docblock says to state it there with its reason.

Constructive preservation, not a tightening or a loosening

  • The derived pattern's source is byte-identical to the literal it replaces. Members keep
    CONTEXT_STACK_KEYS order, so buildTopLevelIndexPattern([...]).source equals
    /^(objects|permissions|books|pages)\[(\d+)\](.*)$/.source. Pinned as a test.
  • The gate returns the same findings for the same inputs: the eight existing runtime-gate suites
    (156 tests) pass unchanged, and the full package suite is 86 files / 2370 passed.
  • No published entry point changed. The new exports are module-level, for the pin, and are on
    neither @objectstack/lint nor @objectstack/lint/runtime.

A2.3 — the two hazards a derived alternation has, decided rather than left implicit

  • Escaping. Every member today is [a-z]+, so nothing needs escaping and nothing would
    notice if it were skipped. A stack key is a RuntimeStackContext property name and a quoted one
    may hold a .; an unescaped . matches any character, which is the silent-widening direction.
    Members are escaped. Pinned with a synthetic a.c key, which must match a.c[0].x and must NOT
    match abc[0].x.
  • Prefix ordering. Alternation is ordered, so page|pages reads as though the short branch
    shadows the long one. It does not in this pattern, and that was measured rather than assumed:
    the group is anchored by \[, which fails the short branch and forces the engine to backtrack
    into the long one. Pinned with a synthetic page / pages pair in BOTH orders. Because the
    claim is measured, the builder deliberately carries no longest-first sort — a sort here would
    be a defence that never gets exercised, and it would also break the byte-identical preservation
    above.
  • Empty input returns a pattern matching nothing, never ^()\[(\d+)\](.*)$, which would
    name-key every top-level index.

The acceptance criterion is mechanical, and the pin is not tautological

The four real keys agree with the list they replaced, which shows the answer is right today and
cannot show that the derivation is the reason. So the pin asks the invariant per context
collection, against the same table the gate consults (WRITTEN_STACK_KEYS), through the real
nameKeyFindingPath: is a top-level index rewritten exactly when a write type maps into that
collection?
The next widening is exercised on synthetic inputs, since the real one has not
happened.

Reverse-verification

Predicted before running: withholding the derivation must red the behavioural pin only, while
the synthetic-input tests stay green — that is what distinguishes "the pin catches a withheld
derivation" from "any edit reds everything".

Withheld by replacing the derived constant with the hand list that forgot pages — the #13216
near-miss exactly. Mutation proven on disk before the run, not by an exit code: derived-form
occurrences 1 → 0, withheld-form 0 → 1, blob 3f5d0486835fe498. No build step is involved —
the test imports ./runtime-gate.js, a relative same-package specifier vitest resolves to src/,
which the ablation itself confirms empirically by reddening at all.

× pages is name-keyed exactly when a write type maps into it
  AssertionError: expected 'pages[0].sharingModel' to be 'pages.acme_thing.sharingModel'
  Test Files  1 failed (1)
       Tests  1 failed | 14 passed (15)

Exactly the predicted direction and exactly the predicted test. Restore proven by observed state:
git diff HEAD empty, git status clean, working-tree blob 3f5d04860164f069d1d71f98cb77ce408007ec65
equal to HEAD's, derived form back on disk and the withheld form gone.

Gates

Family derived AFTER the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which read the change set from git itself (3 paths vs merge base
adf4bf4d7). 33 commands, run at 485ce7d1c. Exit codes captured before any pipe.

30 GREEN. 0 RED. 3 NOT MEASURED, each declared by the gate itself rather than by me:

gate exit verdict
check-test-completeness 3 NOT MEASURED — needs a saved turbo run test log; the gate prints "It is not a red, and there is nothing here to fix"
check:dual-build-cjs-loads 3 NOT MEASURED — "PREREQUISITE NOT MET ... ⛔ This is NOT a pass: nothing was measured" (needs every package's dist/)
check:type-check-debt 1 NOT MEASURED — refuses without the ledgered packages' 53-package closure built, because measuring from here "would silently measure a DIFFERENT WORLD"

The narrowing on check:type-check-debt is declared with a measurement, not an assertion, because
it is the one gate this diff could genuinely move (a new test file in a TEST_DEBT-ledgered
package). An ad-hoc tsc program over packages/lint's test layer with the package's closure
built reproduces the ledger entry exactly — 16 errors, TS7006 x11 + TS2835 x5, across
validate-semantic-roles x5, validate-dashboard-action-refs x4, validate-filter-tokens x3,
validate-capability-references x3, validate-managed-api-methods x1, matching the recorded
composition file for file — and 0 of them are in either file this PR touches, so the
shrink-only count cannot drift up. check:type-check-coverage, the structural half, ran GREEN
with the new test file present (exit 0).

Related: packages/lint/tsconfig.json excludes **/*.test.ts, so pnpm --filter @objectstack/lint typecheck reads 0 test files (--listFiles: 78 files, 0 matching *.test.ts). That is
pre-existing, ledgered state, not a regression — but it means the package typecheck says nothing
about the new test file, so the ad-hoc program above is where that file was actually type-checked.
It is clean there.

Generated by Claude Code


Generated by Claude Code

The set of collections the runtime publish gate carries in a per-write
snapshot was written down in five places, and `NAME_KEYED_STACK_KEYS` was
the one with no guard of any kind: `CONTEXT_STACK_KEYS` carries a
`satisfies` clause (validity, not completeness) and the compiler held
nothing else.

That list carries a real invariant. A collection the CONTEXT fills AND
that some write type maps into must be name-keyed, or a finding's `path`
is a positional index into an in-memory snapshot the caller has never
seen and cannot enumerate. Omitting a member did not fail to build, fail
a test, or fail a gate; it emitted correct-LOOKING findings the receiver
cannot resolve. Adding the `pages` collection had to touch all five
spellings and only one of them announced itself.

`NAME_KEYED_STACK_KEYS` and the `TOP_LEVEL_INDEX` pattern built from it
are now derived from the two inputs that already state the answer:
`CONTEXT_STACK_KEYS` intersected with the values of `TYPE_TO_STACK_KEY`.
The intersection was measured against the list it replaces before
anything changed: same four members in the same order, `datasets`
excluded on its own because no write type maps into it. No member needed
a hand-written exception and none is kept.

Constructive preservation, not a tightening or a loosening: the derived
pattern's `source` is byte-identical to the literal it replaces. The two
hazards a derived alternation has and a literal did not - member escaping
and prefix ordering - are decided in the builder's docblock and pinned on
synthetic inputs, because the four real keys cannot exercise either.

No published entry point changed: the new exports are module-level, for
the pin, and are on neither `@objectstack/lint` nor
`@objectstack/lint/runtime`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 5 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 c42bc8ee68833297f0273f14b0f6f6e9357ab293packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9a6e3d459d35b74d34755cb334c5c2896d2e24d6 — the merge of head 485ce7d1c2cfd1bc48257b363b2b5348f198e728 into base c42bc8ee68833297f0273f14b0f6f6e9357ab293, 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 9a6e3d459d35b74d34755cb334c5c2896d2e24d6 && git checkout 9a6e3d459d35b74d34755cb334c5c2896d2e24d6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c42bc8ee68833297f0273f14b0f6f6e9357ab293 485ce7d1c2cfd1bc48257b363b2b5348f198e728 && git checkout -B drift-repro c42bc8ee68833297f0273f14b0f6f6e9357ab293 && git merge --no-ff 485ce7d1c2cfd1bc48257b363b2b5348f198e728

node scripts/docs-audit/affected-docs.mjs --json c42bc8ee68833297f0273f14b0f6f6e9357ab293

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

@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 11:07
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 4301f78 Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13390-derive-name-keyed-stack-keys branch August 31, 2026 11:27
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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime publish gate: the snapshot collection set is hand-listed in five places and only one of them can go red

2 participants