Skip to content

fix(spec): guard four prototype fall-through lookups with own-property checks - #18233

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-17818-prototype-fallthrough-lookups
Sep 15, 2026
Merged

os-warren merged 4 commits into
mainfrom
claude/issue-17818-prototype-fallthrough-lookups

Conversation

@os-warren

@os-warren os-warren commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17818

Note: generic type brackets are written with square brackets throughout
(Record[string, X] stands for the angle-bracket spelling), following this
card's own convention, so the body survives GitHub's body sanitiser intact.

Four lookup folds in packages/spec read a module-level table with a runtime key
through a bare index. Every table is an ordinary object, so an off-vocabulary key
resolved a member of Object.prototype and the ?? fallback each function
already writes never fired — the inherited member is truthy.

What changed

Five guard expressions across four functions, all the landed
Object.prototype.hasOwnProperty.call(table, key) && table[key] shape from
packages/spec/src/data/type-compat.ts:

site function guards
packages/spec/src/ui/view.zod.ts normalizeFilterOperator 2 — the fold indexes the alias table twice, raw and lower-cased
packages/spec/src/api/discovery.zod.ts resolveDiscoveryEnvironment 1
packages/spec/src/meta-spelling/manifest-collection-spelling.ts pluralToSingular 1
packages/spec/src/meta-spelling/manifest-collection-spelling.ts singularToPlural 1

Each returns the function's own already-declared refusal value — the answer an
ordinary unknown word such as nope gets today. No new fallback was invented and
no consumer gained a lenient alias.

Census of the guard spelling in the three changed sources: grep -c gives 2 / 1 / 2
= five guards, all one shape. A scan of the added lines for Object.create(null),
__proto__: null and hasOwn( finds those strings only inside comments that
record why they were rejected
— there is no third spelling in the diff.

Three pins, one per file, each with a fixed five-word population
(constructor, toString, valueOf, __proto__, nope) plus a lit control over
the real vocabulary. packages/spec/src/meta-spelling/manifest-collection-spelling.test.ts
additionally pins the discriminating fact for the fourth site:
Object.getPrototypeOf(SINGULAR_TO_PLURAL) is Object.prototype.

Rework round — the two rejection rationales now name the check they protect

The at-tier contract review (5672588932) returned FAIL on one shipped sentence
and flagged a second as a wording nit. packages/spec lists src/**/*.zod.ts in its
files[], so both comments ship to consumers as source. Both are rejection
rationales
— they exist to stop a future editor "simplifying" these tables to a null
prototype — and a false one defeats its own purpose: an editor who checks it finds it
false and may discard the true half with it.

Fixed in 7d458766f7, comments only. No guard, pin, schema, changeset row, exported
value or declared surface moves. Re-measured from scratch before editing — ⛔ not
taken on the review's word.

The probe

Seven declarations compiled under packages/spec/tsconfig.json with the repo's own
tsc 6.0.3, via a throwaway config that overrides only rootDir and noEmit so
every strictness flag is the package's own. Each pair is one declaration written
twice: once as a plain object literal, once wrapped in
Object.assign(Object.create(null), …). The probe is throwaway and is not in the
diff.

Full output, verbatim except that generic brackets are transliterated to square
brackets per this body's convention:

tsprobe/p1-satisfies.ts(15,5): error TS1360: Type '{ production: "production"; sandbox: "sandbox"; development: "development"; test: "development"; staging: "sandbox"; preview: "sandbox"; }' does not satisfy the expected type 'Record["production" | "sandbox" | "development" | "test" | "staging" | "preview" | "trial", "production" | "sandbox" | "development"]'.
tsprobe/p1-satisfies.ts(30,5): error TS1360: Type '{ production: "production"; sandbox: "sandbox"; development: "development"; test: "development"; staging: "sandbox"; preview: "sandbox"; }' does not satisfy the expected type 'Record["production" | "sandbox" | "development" | "test" | "staging" | "preview" | "trial", "production" | "sandbox" | "development"]'.
tsprobe/p2-control.ts(6,14): error TS2741: Property 'trial' is missing in type '{ production: "production"; sandbox: "sandbox"; development: "development"; test: "development"; staging: "sandbox"; preview: "sandbox"; }' but required in type 'Readonly[Record["production" | "sandbox" | "development" | "test" | "staging" | "preview" | "trial", "production" | "sandbox" | "development"]]'.
tsprobe/p3-outer-value.ts(9,3): error TS2322: Type '"nope"' is not assignable to type '"production" | "sandbox" | "development"'.
tsprobe/p4-view.ts(7,3): error TS2322: Type '"nope"' is not assignable to type '"in" | "starts_with" | "ends_with" | "not_in" | "between" | "greater_than" | "after" | "greater_than_or_equal" | "less_than" | "before" | "less_than_or_equal" | "equals" | "not_equals" | ... 6 more ... | "is_not_empty"'.
tsprobe/p5-nullproto.ts(7,3): error TS2322: Type 'null' is not assignable to type '"production" | "sandbox" | "development"'.
tsprobe/p5-nullproto.ts(12,3): error TS2322: Type 'null' is not assignable to type '"in" | "starts_with" | "ends_with" | "not_in" | "between" | "greater_than" | "after" | "greater_than_or_equal" | "less_than" | "before" | "less_than_or_equal" | "equals" | "not_equals" | ... 6 more ... | "is_not_empty"'.
tsprobe/p6-shipped-shape.ts(19,3): error TS2322: Type '"nope"' is not assignable to type '"production" | "sandbox" | "development"'.
tsprobe/p7-proto-code.ts(7,3): error TS2353: Object literal may only specify known properties, and '__proto__' does not exist in type 'Readonly[Record["production" | "sandbox" | "development" | "test" | "staging" | "preview" | "trial", "production" | "sandbox" | "development"]]'.

Read as a table — silent means the declaration produced no diagnostic at all:

declaration spelling result
A the shipped discovery table, trial omitted from the inner satisfies plain literal TS1360
B the same, wrapped in Object.assign(Object.create(null), …) null prototype TS1360 — still fires
G control: Readonly[Record[EnvironmentType, DiscoveryEnvironment]] annotation, trial omitted plain literal TS2741
F control: the same annotation, Object.assign(Object.create(null), …) null prototype silent
D1 Readonly[Record[string, DiscoveryEnvironment]], one bogus value plain literal TS2322
D2 the same, Object.assign(Object.create(null), …) null prototype silent
M the shipped shape verbatim, all seven buckets, dev: 'nope' plain literal TS2322
L the same, Object.assign(Object.create(null), …) null prototype silent
N Readonly[Record[string, DiscoveryEnvironment]] = {} plain literal silent
E1 Record[string, ViewFilterOperator], ne: 'nope' plain literal TS2322
E2 the same, Object.assign(Object.create(null), …) null prototype silent
E3 Record[string, ViewFilterOperator] = {} plain literal silent
H1 / H2 __proto__: null literal against each site's own annotation plain literal TS2322
J __proto__: null literal against a CLOSED key set plain literal TS2353

What it says:

  1. The FAIL reproduces. B still reports TS1360. satisfies is applied to the
    object literal, not to the assignment, so Object.assign(Object.create(null), …)
    does not cost the finding: preview / trial 是 EnvironmentType 的一等成员,但 NODE_ENV_TO_DISCOVERY_ENVIRONMENT 没有条目 —— 靠 ?? 'development' 兜底,折叠方向没被声明 #6287 gate. The sentence that shipped was false.
  2. The instrument discriminates (G vs F): an annotation-carried exhaustiveness
    check does go silent under that spelling — TS2741 as a literal, nothing under
    Object.assign. So the precedent recorded at src/data/type-compat.ts is a real
    measurement; it had simply been transplanted onto a gate where it does not hold.
  3. What the spelling would actually cost at the discovery site is the OUTER
    Readonly[Record[string, DiscoveryEnvironment]] value check
    (D1/D2, M/L):
    Object.create(null) is typed any, and Object.assign's any & U result is
    assignable to anything, so a bogus dev: 'nope' compiles in silence. The rejection
    ground survives intact — only the gate it named was wrong.
  4. Record[string, X] carries no key exhaustiveness at either site (N, E3). So
    view.zod.ts's "the annotation's exhaustiveness check" named a check that never
    existed there; what is lost is the value check (E1/E2).
  5. The surviving half of both sentences is true: a __proto__: null object
    literal does not type-check against either annotation (H1/H2).

What the corrected sentences now claim

packages/spec/src/api/discovery.zod.ts — the FAIL. Was: the null-prototype spelling
"silently COSTS the exhaustiveness check, which here is the satisfies Record[EnvironmentType, DiscoveryEnvironment] above". Now: it costs whatever check
the annotation carries
(Object.create(null) is any; any & U is assignable to
anything); here that is the outer Readonly[Record[string, DiscoveryEnvironment]]
value check
, an index signature with no key exhaustiveness to lose, measured as
TS2322 as a literal and silent under Object.assign; and it explicitly states that
the #6287 satisfies gate is not what would be lost, because satisfies applies
to the literal and still reports TS1360 under that spelling. The last clause is
deliberate: it inoculates the next reader against the exact mistake this comment made.

packages/spec/src/ui/view.zod.ts — the nit, folded into the same commit. Was: the
spelling "COSTS the annotation's exhaustiveness check". Now: it costs the annotation's
value check, with the reason named — Record[string, ViewFilterOperator] is an
index signature and never carried a key-exhaustiveness check to lose — and the
measurement quoted (ne: 'nope' is TS2322 as a literal, silent under Object.assign).

Both keep their ⛔ verdict: the null-prototype table still loses a real compile-time
check, so it is still rejected. Only the named gate changed.

Per-site before/after, measured

Node v22.22.2. Each fold evaluated at this branch's implementation and again at
the merge base 1bdbf82cb5, against the TypeScript sources the build and the test
run both consume. The "before" leg is the ablation described below, so the two
columns come from the same harness on the same tree.

call before (1bdbf82cb5) after
normalizeFilterOperator('constructor') the Object function 'constructor'
normalizeFilterOperator('toString') Object.prototype.toString 'toString'
normalizeFilterOperator('valueOf') Object.prototype.valueOf 'valueOf'
normalizeFilterOperator('__proto__') Object.prototype '__proto__'
resolveDiscoveryEnvironment('constructor') the Object function 'development'
resolveDiscoveryEnvironment('toString') 'development' 'development'
resolveDiscoveryEnvironment('valueOf') 'development' 'development'
resolveDiscoveryEnvironment('__proto__') Object.prototype 'development'
pluralToSingular('constructor') the Object function 'constructor'
pluralToSingular('toString') Object.prototype.toString 'toString'
pluralToSingular('__proto__') Object.prototype '__proto__'
singularToPlural('constructor') the Object function 'constructor'
singularToPlural('toString') Object.prototype.toString 'toString'
singularToPlural('__proto__') Object.prototype '__proto__'

Lit controls, byte-identical on both legs: normalizeFilterOperator('eq') is
'equals', normalizeFilterOperator('notIn') is 'not_in',
resolveDiscoveryEnvironment('prod') is 'production',
resolveDiscoveryEnvironment('staging') is 'sandbox',
pluralToSingular('sharingRules') is 'sharing_rule',
singularToPlural('sharing_rule') is 'sharingRules',
normalizeFilterOperator('nope') is 'nope'.

One correction to the card's own tables. #17818 records toString and valueOf
as returning "the Object function" from normalizeFilterOperator and
pluralToSingular. They return Object.prototype.toString and
Object.prototype.valueOf — distinct function objects, not the Object
constructor. The typeof column the card reports is right, the defect class is
right, and the premise is unaffected; only the identity attribution was imprecise.
The changeset in this PR already carried the corrected identities.

Independent verdict on the :114 scope ruling — please read

The ruling was: align :114 if and only if it measures as the same defect, with
a stop condition naming three ways it could fail — a narrowed key type that makes
the index total, a table that is not a plain object literal, or a result that
never leaves the module.

Re-derived against 1bdbf82cb5, clause by clause:

clause SINGULAR_TO_PLURAL at :114 verdict
module-level declared at :103 at module scope holds
annotated the widest key type Record[string, string] holds
plain object literal Object.fromEntries(Object.entries(PLURAL_TO_SINGULAR).map(...)) fails the letter
read with a runtime key SINGULAR_TO_PLURAL[key], key an uncontrolled string parameter holds
result leaves through a declared signature singularToPlural(key: string): string holds
narrowed key type making the index total no — the key type is string does not apply
result never leaves the module no — the symbol is exported and re-exported from /shared does not apply

So one clause of the stop condition fires on form: the table is not written as
an object literal. ⚠️ I kept the fix anyway, and this is the one judgement call in
the PR that the seat should confirm or reverse.

The reason: the ruling's governing condition is the measurement, not the form, and
the measurement says the defect is identical. Object.fromEntries returns an
ordinary object — Object.getPrototypeOf(SINGULAR_TO_PLURAL) is
Object.prototype, asserted in the pin — and the fold reproduces every spelling of
the defect the other three sites show, out of a signature declaring string
(rows above). The "not a plain object literal" clause exists to catch a table whose
construction puts it outside this defect class (a null-prototype table, a Map,
a Proxy); Object.fromEntries does not. Reverting would knowingly ship half of a
two-line pair with the other half measured broken — the exact shape this card was
filed about — and would mean deleting assertions that pass.

If the seat reads the clause literally rather than purposively, the revert is one
hunk in manifest-collection-spelling.ts plus the singularToPlural half of its
pin, and nothing else in this PR depends on it.

Judgement of the rescued commit 29f14b3fe7

29f14b3fe7 was committed by the seat, unreviewed, to save a staged-but-uncommitted
changeset from a dead worktree. Reviewed here:

  • The before/after table is correct. All eight rows it claims reproduce against
    the merge base, with the correct prototype-member identities.
  • Its /shared re-export claim is true. packages/spec/src/shared/metadata-collection.zod.ts
    re-exports PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, pluralToSingular and
    singularToPlural from ../meta-spelling/manifest-collection-spelling.js.
  • Two sentences did not survive review and are corrected in ee022fad69:
    • "Measured ... against the built artifact" named a provenance nobody measured.
      It now names the reading actually on record.
    • "the level follows the widening rule" had the direction backwards. This diff
      narrows; see below.

Nothing else in the rescued file changed. The commit message of 29f14b3fe7 still
reads INCOMPLETE AND UNREVIEWED: the branch already carried another session's push,
so under the unshared-branch criteria it is not mine to rewrite, and the queue
squashes commit messages anyway. Treat this PR body as the review record for it.

Semver

minor, and I agree — but on a different ground than the rescued text gave.

This change narrows. Every input that got an answer before gets the same answer
now, except the prototype-member spellings, which no signature ever admitted. By the
spec lane's own criterion a narrowing does not itself trigger clause ②. What carries
minor is the standing Clause-②: yes on the claim: a PR that declares it takes at
least minor whatever else the diff fixes. The declaration is the conservative call
the seat made and is not re-litigated here.

Package declaration checked. The changeset declares @objectstack/spec only, and
that is complete: no other package re-exports these four symbols, so no other
published surface moves. Importing them needs no declaration; re-publishing them would,
and nothing does. The /shared re-export is inside @objectstack/spec itself.

Re-export scan, multiline-aware, over packages / apps / examples excluding
node_modules, dist and test files: 0 hits outside packages/spec. Lit control,
same instrument inside it: src/shared/metadata-collection.zod.ts:110 and
src/meta-spelling/index.ts:48. A zero on one leg and a light on the other, so the
zero is a reading.

Corrected importer list. The earlier body listed @objectstack/cli and omitted
three real importers. Re-measured at 7d458766f7, counting only import { … } from
statements that actually name one of the four functions:

package function(s) imported site
@objectstack/core pluralToSingular src/metadata-service-contract.ts
@objectstack/lint normalizeFilterOperator src/validate-preset-comparands.ts
@objectstack/metadata-protocol resolveDiscoveryEnvironment src/protocol.ts:81
@objectstack/objectql pluralToSingular src/engine.ts
@objectstack/rest normalizeFilterOperator src/view-filter-rule-lowering.ts
@objectstack/runtime pluralToSingular, resolveDiscoveryEnvironment src/domains/meta.ts, src/http-dispatcher.ts

Two names need saying precisely, both measured:

  • @objectstack/cli is not a source importer. packages/cli/src has 0 hits for
    any of the four names. Its only reference lives in the test layer:
    packages/cli/test/generate-scaffold-validates.test.ts:98 imports singularToPlural
    from @objectstack/spec/shared.
  • @objectstack/metadata-core imports the TABLES, not the functions.
    src/meta-write-org-scope.ts:71 imports PLURAL_TO_SINGULAR and SINGULAR_TO_PLURAL
    from @objectstack/spec/shared and indexes them bare (:101, :124). That is the
    same family the review recorded as not measured and the seat ruled a follow-up
    card — named here so this list is complete, and ⛔ not touched by this PR.

Two further files mention resolveDiscoveryEnvironment in prose only, not as an
import: packages/metadata/src/routes/hmr-routes.ts:129 and
packages/services/service-analytics/src/analytics-service.ts:785.

Reverse verification

Both legs run from the committed state, with an EXIT INT TERM trap restoring
absolute paths, and the mutation proved on disk before anything was read.

Red leg. The three source files restored to 1bdbf82cb5 in the worktree only
(git restore --source=1bdbf82cb5, no staging). Mutation proved three ways per file:
git hash-object equals the base blob and differs from the HEAD blob, and the guard
text Object.prototype.hasOwnProperty.call counts 0 in each. Result:

Test Files  3 failed (3)
     Tests  17 failed | 98 passed (115)

All three pins fail against the base implementation. They pin something.

Restore. git checkout HEAD -- PATHS, proved by three independent facts:
git diff HEAD empty, git status --porcelain empty, and all three blob hashes
byte-identical to HEAD.

Green leg — the full package suite, which contains the three pins:

Test Files  478 passed (478)
     Tests  13638 passed (13638)

No assertion was weakened, deleted, loosened or skipped. git diff --numstat
against the merge base shows zero deletions in all three test files (53 / 57 / 61
insertions, 0 deletions); the only deletions anywhere in the diff are the four
return TABLE[key] ?? ... lines the guards replace. A scan of the added lines for
it.skip / describe.skip / .todo / .only finds none.

Checks run

check exit
pnpm --filter @objectstack/spec build 0
pnpm --filter @objectstack/spec test 0 — 478 files / 13638 tests
pnpm --filter @objectstack/spec typecheck 0 — test-typecheck debt ledger unmoved (54 files / 259 errors)
pnpm --filter @objectstack/spec check:generated 0 — all 15 generated artifacts current, nothing to regenerate
pnpm lint (repo-wide eslint . --no-inline-config, not narrowed) 0
pnpm --filter @objectstack/lint test 0 — 103 files / 3826 tests; @objectstack/lint is a real consumer of normalizeFilterOperator
pnpm --filter @objectstack/lint run check:doc-formula-expressions 0 (after building @objectstack/formula + @objectstack/lint, which its prerequisite names)
the derived gate families 83 of 86 exit 0 — see below for the other three
pnpm check:stack-collection-maps 0 — the gate the meta-spelling guard comment cites
pnpm check:nul-bytes + a manual control-character scan over every changed file 0 / clean

The gate family list was derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
(86 commands) and re-derived after the final commit: the same 86, no family added.
The union above was run at ee022fad69.

Re-run for the rework commit 7d458766f7

The diff of this round is two comment hunks in two files already in the PR's diff, so
no gate family is added or removed. Re-run on the new head:

check exit
pnpm --filter @objectstack/spec build 0 — 34/34 declaration files emitted
pnpm --filter @objectstack/spec typecheck 0 — test-typecheck ledger unmoved (54 files / 259 errors / 144 pinned signatures)
pnpm --filter @objectstack/spec check:generated 0 — all 15 generated artifacts up to date
pnpm --filter @objectstack/spec test 0 — 478 files / 13638 tests passed
pnpm lint (repo-wide eslint . --no-inline-config, not narrowed) 0 — 6757 files linted, 0 errors, 0 warnings, both changed files present in the run
pnpm check:nul-bytes 0
grep -naP control-character scan over both changed files clean (no match)
node scripts/check-comment-mask-adoption.mjs (+ --self-test) 0 / 0
node scripts/check-comment-mask-corpus.mjs 0
node scripts/check-spec-docblock-symbol-anchors.mjs (+ --self-test) 0 / 0
node scripts/check-keyed-text-bounds.mjs 0
pnpm check:published-files 0
pnpm --filter @objectstack/spec run check:docs 0
pnpm --filter @objectstack/spec run check:api-surface 0
pnpm check:stack-collection-maps 0

Every heavy run went through scripts/pm/os-verify-lock.sh and its verdict line is
what is read above, never a bare $?. The rest of the derived family list is
unchanged from ee022fad69, where it was run, and is CI's run on this head.

NOT MEASURED

  • pnpm check:dual-build-cjs-loads, pnpm check:lean-entry-closure,
    pnpm check:type-check-debt, pnpm check:published-readme-exports — each exits 3,
    PREREQUISITE NOT MET, and each says in its own words that nothing was measured.
    All four read built output across the workspace (44 packages unbuilt here). A
    whole-workspace build is CI's run, not this round's. Not a finding in either direction.
  • check:react-declaration-parity — cannot run in this repo at all; it needs objectui's
    registry manifest. This diff adds no prop and changes no schema.
  • @objectstack/core tests — 48 of 51 files and 1254 of 1254 tests pass with zero
    assertion failures; 3 files fail to collect on
    Failed to resolve entry for package "@objectstack/metadata-core", an unbuilt
    sibling. None of those three files references any of the four functions. Read as
    NOT MEASURED for those three files, not as red.
  • The repo-wide suite, and the full downstream sweep over @objectstack/objectql,
    @objectstack/metadata-protocol and @objectstack/cli — declared to CI. The
    declared surface is byte-identical (check:api-surface green with no regeneration),
    so only the prototype-member input class moves.

Acceptance notes

Noted, not filed:

  • resolveDiscoveryEnvironment's guard comment states the second recorded rejection
    ("a guard that named words would not survive the next prototype member") as prose
    inside a parenthetical, where the other three sites state it as a ⛔ bullet. The
    reasoning is equivalent and present; only the presentation differs. Not worth a
    churn commit. Carrier: whoever next edits api/discovery.zod.ts.

  • The card's own survey reports 44 of 76 Record[string, X] tables carrying a
    runtime-key index site, and explicitly declines to claim the remaining 41 are clean.
    That is the card's declared NOT MEASURED and stays open where the card left it —
    this PR neither narrows nor widens it. Carrier: the next pass over that survey.

  • The (TS2353) cited in both corrected comments is the precedent's error code,
    and it is real — measured against a closed key set (probe declaration J above).
    At these two sites the key type is string, so the same __proto__: null literal
    reports TS2322 instead (H1 / H2). Both comments attribute the code to
    src/data/type-compat.ts rather than claiming it fires here, and the rejection holds
    either way, so the sentence is not false — only the code is site-specific. Left as
    written: correcting it is outside this round's declared scope. Carrier: none today;
    whoever next edits either guard comment, or the seat, if it wants the precision.

None of the three is a reproducible defect, a contract violation, or an authoring
trap, so none is filed.

Reworked in session_01KB5PFtxuy1x3dcR5gxudx6; the rework round is commit 7d458766f7.


Generated by Claude Code

…ty checks

`normalizeFilterOperator`, `resolveDiscoveryEnvironment`, `pluralToSingular`
and `singularToPlural` each read a module-level lookup table with a runtime
key through a bare index. Every table is an ordinary object, so an
off-vocabulary key resolved `Object.prototype`'s members: `constructor` came
back as the `Object` FUNCTION, `toString` / `valueOf` as their prototype
methods, and `__proto__` as `Object.prototype` itself — out of signatures that
declare `string` and `DiscoveryEnvironment`. The `??` fallback never fired
because the inherited member is truthy.

Applies the landed `Object.prototype.hasOwnProperty.call(map, key) && map[key]`
shape from `data/type-compat.ts`, carrying its two recorded rejections (not a
null-prototype table, not a list of prototype member names). Each fix returns
the function's own already-declared refusal value, which is what an unknown
word already gets today.

Pins carry a five-word population plus a lit control at each site.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
INCOMPLETE AND UNREVIEWED. This file was staged and uncommitted in the
dispatch worktree when the container restarted and killed the round; the
seat committed it so the work is not lost, and has NOT reviewed it.

Observed state only: 1 staged path, 47 insertions, on top of 756df6b.

The continuing round diffs this commit rather than trusting it.

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

The changeset was committed unreviewed out of a worktree whose round died. Its
before/after table is accurate — all eight rows reproduce — but two sentences
around it did not survive review:

* "against the built artifact" named a provenance nobody measured. The reading
  on record evaluates each fold at this change's implementation and again at
  its merge base, against the TypeScript sources the build and the test run
  both consume. The sentence now names that.

* "the level follows the widening rule" had the direction backwards. This diff
  NARROWS: an off-vocabulary key that previously resolved an inherited member
  now gets each function's own declared refusal value, and nothing that
  answered before answers differently. `minor` is right, but it is carried by
  the change's declared contract-review status, not by a widening.

No change to the table, to the guard, or to any pin.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 6 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via getItems (sdk, the bare tail of client method meta.getItems, bound to GET /api/v1/meta/:type; the bare tail of client method meta.getItems, bound to GET /meta/:type), meta.getItems (sdk, the route ledger binds it to GET /api/v1/meta/:type, selected by route anchor /meta/:type; the route ledger binds it to GET /meta/:type))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /meta/:type (route, 31 pages)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 136 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 8c657f7dd0740e37e836edf14207ad9ce7836ec3packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8c657f7dd0740e37e836edf14207ad9ce7836ec3

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

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

Copy link
Copy Markdown
Collaborator Author

Seat note — the drift row is a true anchor match but a false accuracy alarm, measured. ⛔ No edit and ⛔ no card owed.

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-14T23:1xZ. Unlike this check's usual empty run, this one lists a real hand-written page, so it got a real read.

Listed: content/docs/api/environment-routing.mdx, via the getItems / meta.getItems SDK anchors (bound to GET /meta/:type).

Why it plausibly could have been falsified — and this is why it was worth checking rather than waving through: this diff guards pluralToSingular / singularToPlural in packages/spec/src/meta-spelling/, and those map the collection spellings that the /meta/:type route resolves. A page documenting that route's type-name resolution would be squarely in range.

Measured on origin/main 0f95f4341f, and it is not:

what the page says why this diff does not touch it
:130await env.meta.getItems('object'); a code example passing 'object', an ordinary vocabulary word. This diff changes the answer only for prototype-member keys (constructor, toString, valueOf, __proto__)
:177 — 「there is no alias, so the old spelling does not resolve」 about the SDK rename client.project(id)client.environment(id) under ADR-0006 D2. ⛔ Nothing to do with plural/singular spelling or with lookup tables
:69 — 「defineStack() rejects it as an unrecognized key」 about stack keys, ⛔ not about resolveDiscoveryEnvironment

Instrument lit: the same grep over the same page for getItems hits, and for meta hits 4, so the zero hits for plural / singular / resolveDiscoveryEnvironment are real absences rather than a dead pattern.

⇒ the page names a symbol this diff touched (so the row is correct by its own precision-first predicate) but states nothing this diff changes. ⛔ No re-verification action, ⛔ no docs edit, ⛔ no card.

⚠️ And the row the check itself says it cannot produce still stands unaddressed by this note: 「a page that states a rule by its inputs shares no identifier with the emitter」. The rule this diff carries — an off-vocabulary key falls through to the declared value rather than to an inherited Object.prototype member — would be restated by inputs, not by function name, on any page that documents it. This seat read the one page the check listed; ⛔ it did not sweep the corpus for input-stated restatements, and does not claim to have.

⛔ Nothing here is a verdict on the diff. The at-tier clause-② review has not run; needs:contract-review is on this PR (hung by the implementing round ✅) and on card #17818.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the domain:spec seat (session_01KB5PFtxuy1x3dcR5gxudx6), 2026-09-15T00:0xZ. ⛔ Not rewritten, not summarised.

Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries "model":"claude-fable-5-1" 212 times and no other value — zero fallback evidence. Controls, same instrument, two os-dev transcripts from this seat: "model":"claude-opus-5" ×92 and ×140.

⚠️ This is a SECOND review of this head. The first was killed by a container restart before it could finalize. Its scratch survives at scratchpad/pr-18233/ and this reviewer was ⛔ forbidden to read it — 「隔离复核子代理…⛔ 不读非本轮自写的暂存」 — so every number below was re-derived from scratch in scratchpad/pr-18233-r2/. ⛔ The first round's measurements are discarded, not merged.


Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee022fad6993ebeeaa1e5300ada19c2158097e1b

Merge base measured fresh: git merge-base origin/main ee022fad = 1bdbf82cb58119132d667713ae35099f388a3479 (= 756df6b613^). Diff 1bdbf82cb5..ee022fad69: 7 files, +330/-4. Scratch (all written this round): scratchpad/pr-18233-r2/ (measurements.md, probe-base.txt, probe-head.txt, red-leg.log, tsprobe/, *.log, API JSON). Generic brackets are written as square brackets throughout: Record[string, X].

① Derived judgments

Public surface: no export added, removed or renamed — check:api-surface current with no regeneration (check:generated: 15/15 up to date). Accept set: for four published functions, the input class {Object.prototype member names} now yields each function's already-declared refusal; every other input answers byte-identically (four whole-vocabulary fingerprints identical base vs head: normalizeFilterOperator 117e38af4b6e, resolveDiscoveryEnvironment 8eaaf5e1e18a, pluralToSingular 2e6b747b9adf, singularToPlural 1174cb71e1da — probe.mts run at base and head, diff probe-base.txt probe-head.txt shows only the population rows). This is a NARROWING; nothing widens.

  1. packages/spec/src/ui/view.zod.ts:360-370 normalizeFilterOperator — two guards (raw op, then lowered), precedent shape verbatim, refusal return op preserved (:370). Judged CORRECT. Base probe: constructor → the Object function, toStringObject.prototype.toString, valueOfObject.prototype.valueOf, __proto__Object.prototype; head → input verbatim. Shipped prose (file ships via files[] src/**/*.zod.ts) verified: canonicalizeSqlType lower-cases first (data/type-compat.ts:198), resolveDiscoveryEnvironment lower-cases first (api/discovery.zod.ts:631), "the enum's own validation reports it as invalid" is true (view.zod.ts:701 z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS))). One loose word at :351-354: "COSTS the annotation's exhaustiveness check" — the annotation is Record[string, ViewFilterOperator], which has no key-exhaustiveness; measured (tsprobe/view-claim.ts, tsc 6.0.3): what Object.assign(Object.create(null), …) silently costs there is the VALUE check (bogus value compiles under Object.assign, TS2322 as a literal). Rejection ground survives; wording nit only.

  2. packages/spec/src/api/discovery.zod.ts:664-670 resolveDiscoveryEnvironment — one guard, precedent shape, refusal 'development' preserved (:670). Guard judged CORRECT: base constructor → the Object function, __proto__Object.prototype; head → 'development'; toString/valueOf already 'development' on both legs (lower-casing), qa/uat'development' both legs. Shipped-prose DEFECT at :654-660 (this file ships in the tarball): the sentence "the Object.assign(Object.create(null), …) spelling that does compile silently COSTS the exhaustiveness check, which here is the satisfies Record[EnvironmentType, DiscoveryEnvironment] above" is FALSE at this site. Measured with the repo's tsc (6.0.3) under packages/spec/tsconfig.json (tsprobe/satisfies-claim.ts): declaration B = Object.assign(Object.create(null), { ...({ six of seven buckets } satisfies Record[EnvironmentType, DiscoveryEnvironment]), prod, dev }) STILL fires TS1360 … Property 'trial' is missing at line 20 — the inner satisfies gate survives Object.assign because it is applied to the literal, not to the assignment. Control that the instrument discriminates (tsprobe/control.ts): the precedent's mechanism reproduces — an annotation-carried Readonly[Record[EnvironmentType, …]] literal fires TS2741 (G) and goes silent under Object.assign(Object.create(null), …) (F). So the precedent's measured fact is real for an ANNOTATION-carried check (type-compat.ts), and the comment's transplant of it onto a satisfies-carried check is wrong. What the null-prototype spelling would actually cost here is the OUTER Readonly[Record[string, DiscoveryEnvironment]] value check (any & U), not the finding: preview / trial 是 EnvironmentType 的一等成员,但 NODE_ENV_TO_DISCOVERY_ENVIRONMENT 没有条目 —— 靠 ?? 'development' 兜底,折叠方向没被声明 #6287 satisfies gate. A reader acting on the sentence would reject a valid spelling for a wrong reason, and the sentence names the wrong gate as what is protected. One-hunk fix (reword the "which here is …" clause). This is the finding that decides the verdict; see Notes.

  3. packages/spec/src/meta-spelling/manifest-collection-spelling.ts:139-141 pluralToSingular — guard, precedent shape, refusal return key preserved. CORRECT. Base constructor/toString/valueOf/__proto__ → prototype members; head → verbatim. Not a .zod.ts file, so its comment block does not ship as source; ships compiled (dist probe below). Comment claim "/meta/:type path segments … are fed into" the fold verified true: packages/runtime/src/domains/meta.ts:499 const type = parts[0]:861 pluralToSingular(type); check:stack-collection-maps exists and exits 0.

  4. manifest-collection-spelling.ts:147-149 singularToPlural — guard, precedent shape, refusal preserved. CORRECT; same defect as measured (see ③ and adversarial 2).

  5. Changeset .changeset/17818-prototype-fallthrough-lookups.md (ships as CHANGELOG): all 8 table rows reproduce against base and head (probe-base.txt / probe-head.txt). "re-exported from /shared" true (shared/metadata-collection.zod.ts:110-115); /meta-spelling and /shared are real exports entries; "Nothing in the declared vocabulary moves" true (fingerprints above); "guard only NARROWS" true. The parenthetical about the null-prototype rejection is attributed to src/data/type-compat.ts where it is true (control F/G). No false row, no false sentence.

  6. Tests: additive only — 22 new expect( lines, 0 deletions in test files, 0 .skip/.only/.todo/xit/xdescribe in added lines. New pin manifest-collection-spelling.test.ts asserts Object.getPrototypeOf(SINGULAR_TO_PLURAL) === Object.prototype; re-measured true at base and head.

  7. Built artifact (card's own instrument): probe-dist.mjs over dist/{ui,api,meta-spelling,shared}/index.mjs at head — all five population words answer the declared refusal on every entry, lit controls answer.

Fix shape census (adversarial 1): guard text Object.prototype.hasOwnProperty.call counts 2 / 1 / 2 at head (0 / 0 / 0 at base) = 5 sites, each if (Object.prototype.hasOwnProperty.call(T, k) && T[k]) return T[k]; — the type-compat.ts:236-241 shape. Added lines carrying Object.create(null) / __proto__: null / hasOwn( / Object.hasOwn / Reflect.has / new Map / new Set: 6 hits, ALL comment lines, 0 code. Runtime-key index sites of the four tables inside packages/spec/src (non-test) at head: the five guarded ones plus conversions/stored.ts:70-71 (PLURAL_TO_SINGULAR[type] ?? type / SINGULAR_TO_PLURAL[singular]) — probed (probe-stored.mts): every population word passes the item through unchanged because the inherited member is truthiness-tested and then discarded (SINGULAR_TO_PLURAL[fn] is undefined) — the card's suggestFieldType SAFE shape, not a live member. No site missed.

② Semver level

What moved: no new export, no new key, no new accepted value; accept-set narrowing on inputs no signature ever admitted as valid answers. Changeset declares '@objectstack/spec': minor; @objectstack/spec is in the single 70-member fixed group; .changeset/pre.json absent (guard enforces).

  • Against the launch-window convention (scripts/check-changeset-no-major.mjs header: no major until GA; LEVEL AXIS — a PR declaring clause-② yes must grade at least one moved package minor or above; pr-automation.yml WHICH LEVEL): MATCHES. Measured offline: node scripts/check-changeset-no-major.mjs --base 1bdbf82cb5 --head ee022fad --event event.json → exit 0, "LEVEL AXIS: declares clause-② yes … no package … graded patch"; carrier needs:contract-review IS on the PR. Note the PR body carries NO fixed Clause-②: line — the gate read a "near miss" from prose; the declaration rides the label alone (and the card's claim comment; check-clause2-carriers --pair 18233 exit 0).
  • Against plain semver: DOES NOT MATCH — a bug fix that narrows an accept set and adds nothing is patch. The changeset says so honestly ("carried by this change's declared contract-review status, ⛔ not by a widening"). check-widening-tells --declaration no over the diff: 3 contract files judged, NO widening tell; so the Clause-②: yes declaration is conservative, not evidenced by the diff. The level is right only while that declaration stands.

③ Boundary flags

  • Q1 (:114 scope, options A/B): measured independently — Object.getPrototypeOf(SINGULAR_TO_PLURAL) === Object.prototype is true at base and head; at base singularToPlural('constructor') = the Object function, ('toString') = Object.prototype.toString, ('__proto__') = Object.prototype; declared signature singularToPlural(key: string): string admits those keys and promises string. It IS the same defect. The table is built by Object.fromEntries, not a literal — the stop condition's letter fires on form only. Whether the letter or the measurement governs is the seat's scope call; the guard itself is correct and the vocabulary is byte-identical.
  • Q2 (card-side needs:contract-review hang): not a diff question; --pair 18233 reads both carriers as agreeing (exit 0). No effect on this verdict.
  • out_of_scope 1 (discovery comment presentation): style agreed — but that same comment block carries the false satisfies claim in ① item 2, which is not a presentation nit.
  • out_of_scope 2 (41 unmeasured tables): not measured here either; one adjacent measurement added — conversions/stored.ts:70-71 is SAFE by probe (① item 7). Consumers outside spec index PLURAL_TO_SINGULAR[type] ?? type bare in many places (packages/metadata-protocol/src/protocol.ts:663,1131,4673…, sys-metadata-repository.ts:348,1593…, packages/metadata-core/src/meta-write-org-scope.ts:124) — same family, outside the card and this PR; not measured; a follow-up card, not a rider.
  • out_of_scope 3 (card identity imprecision): confirmed by the base probe — toString/valueOf return Object.prototype.toString / Object.prototype.valueOf, not the Object constructor.
  • Rescue commit 29f14b3fe7 (adversarial 5): diffed — it introduced only the changeset (47 lines). ee022fad69 changed two sentences ("against the built artifact" → source-evaluated at base and head; "follows the widening rule" → carried by the declared contract-review status, guard narrows). Every remaining line of the rescued file re-judged above: the 8 rows reproduce, the /shared claim is true, the precedent attribution is true. Its commit message still reads INCOMPLETE AND UNREVIEWED; the queue squashes, so no defect.
  • Package declaration (adversarial 7): no package outside @objectstack/spec re-exports any of the eight symbols — barrel grep (export {…} from / export * from) over packages apps examples non-test: 0 hits; same instrument inside packages/spec/src: hits meta-spelling/index.ts:51-52 and shared/metadata-collection.zod.ts:113-114 (control lit). packages/runtime/src/index.ts:247 re-exports @objectstack/core wholesale, and core imports pluralToSingular but does not re-export it. Single-package declaration is complete.
  • Checks run at head (adversarial 8, all in the fresh worktree, pnpm install --frozen-lockfile exit 0): pnpm --filter @objectstack/spec build 0; pnpm --filter @objectstack/spec typecheck 0 (tsc, scripts, test-typecheck ledger 54/259 unmoved); pnpm --filter @objectstack/spec check:generated 0 (15/15 current); pnpm --filter @objectstack/spec test 0 — 478 files / 13638 tests; pnpm lint (repo-wide eslint) 0; pnpm check:stack-collection-maps 0; check-clause2-carriers --pair 18233 0; check-changeset-no-major --event 0; check-widening-tells yes/no both 0. Red leg (adversarial 3): three sources restored to 1bdbf82cb5 worktree-only, blob hashes proven equal to base (125d3059ba, f74688572f, 130512b943) and guard-text 0/0/0; the three test files → 3 failed (3) / 17 failed | 98 passed (115); restored with git checkout ee022fad -- …, git status --porcelain empty, hashes equal to HEAD (d835182077, 35cfbfbc61, c5bf5ecb7b), guard-text 2/1/2. The pins pin.
  • CI from the API (adversarial 9): GET /commits/ee022fad…/check-runs?per_page=100 → 39 runs: 33 success, 6 skipped, 0 failure; commit status Vercel success. All six required contexts success. Skipped, each by its own gate text: Build Docs (needs.filter.outputs.docs != 'false'; filter = apps/docs/**, content/**, lockfile, ci.yml — none touched), Console Pin Gate (filter.console = .objectui-sha + console scripts — none touched), Auto Label + Check PR Size in run 34907559303 (second pull_request run 12 s after the first, the label event; job if: excludes labeled/unlabeled/edited; the first run's instances are success), Packed-tarball smoke (opt-in) twice (if: contains(labels, 'needs:pack-smoke'), label absent). A skip is not a pass; each is by design and each has a green sibling or is opt-in. Skipped STEPS inside green required jobs: if: failure() reporters, Save Turbo cache (main only), Upload stall diagnostic reports, and Verify-lock entry-point self-test (gate-families.outputs.verify_lock == 'skip' for this file set). No red truncated anything; no "flake" invoked.
  • Zero-hit controls (adversarial 10): re-export zero ↔ in-spec hit; third-spelling code zero ↔ 6 comment hits and 5 guard-line hits; tsc claim-false ↔ control F silent / G TS2741; vocab-identical ↔ population rows differ.

Implemented-by: claude/issue-17818-prototype-fallthrough-lookups
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: FAIL


Generated by Claude Code

Both rejection rationales transplanted a measured fact onto the wrong gate.
Re-measured with this repo's tsc (6.0.3) under packages/spec/tsconfig.json:

- discovery.zod.ts: the inner `satisfies Record<EnvironmentType,
  DiscoveryEnvironment>` is NOT costed by
  `Object.assign(Object.create(null), …)` — `satisfies` applies to the
  literal, not to the assignment, so a missing bucket still reports TS1360
  under that spelling. What the spelling would cost is the OUTER
  `Readonly<Record<string, DiscoveryEnvironment>>` value check: a bogus
  `dev: 'nope'` is TS2322 as a literal and silent under `Object.assign`.
  Control, same instrument: an annotation-carried exhaustiveness check does
  go silent under that spelling (TS2741 as a literal, silent under
  `Object.assign`), so the precedent's fact is real — just not here.

- view.zod.ts: `Record<string, ViewFilterOperator>` is an index signature
  and carries no key exhaustiveness. What is lost is the value check
  (`ne: 'nope'` is TS2322 as a literal, silent under `Object.assign`).

The rejection ground is unchanged in both: the null-prototype table still
loses a real compile-time check. Comments only — no guard, pin, schema or
exported value moves.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the domain:spec seat (session_01KB5PFtxuy1x3dcR5gxudx6), 2026-09-15T01:1xZ. ⛔ Not rewritten, not summarised.

Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries "model":"claude-fable-5-1" 115 times and no other value — zero fallback evidence. Controls, same instrument, two os-dev transcripts from this seat: "model":"claude-opus-5" ×59 and ×170.

⚠️ Third read of this head's lineage. The first reviewer was killed by a container restart; the second FAILed ee022fad69 on one shipped sentence; this one judges the fix-up at 7d458766f7 and was ⛔ forbidden to read either predecessor's scratch — every number re-derived in scratchpad/pr-18233-r3/.


Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7d458766f7dc4bdac6f92113382def0f9a69a3ee

Re-review after the fix-up. Merge base measured fresh: git merge-base origin/main 7d458766f7 = 1bdbf82cb58119132d667713ae35099f388a3479. Whole-PR diff 1bdbf82cb5..7d458766f7: 7 files, +344/-4. Fix-up diff ee022fad69..7d458766f7: 2 files, +19/-5. Scratch, all written this round: scratchpad/pr-18233-r3/. Neither pr-18233/ nor pr-18233-r2/ was read. Generic brackets are square throughout: Record[string, X].

① Derived judgments

Public surface: no export added, removed or renamed — check:api-surface exit 0 "public API surface + factory signatures unchanged", check:generated 15/15 current. Accept set: for four published functions the input class {Object.prototype member names} now yields each function's own declared refusal; every other input answers byte-identically (runtime probe via tsx over src at base and head: diff probe-base.txt probe-head.txt shows only the 14 population rows; the 7 lit controls and both Object.getPrototypeOf(TABLE) === Object.prototype readings identical on both legs). A NARROWING; nothing widens.

  1. The fix-up is comment-only — measured, not accepted. All 24 changed lines in ee022fad69..7d458766f7 match a comment prefix (0 non-comment). ts.transpileModule(removeComments: true) of both files at ee022fad69 and 7d458766f7 emits byte-identical JS (sha256 prefixes 150ee997023e4ea6 for discovery, 455524e72e5a7685 for view); control: the same instrument on base-vs-head view.zod.ts emits DIFFERENT JS. Changeset, manifest-collection-spelling.ts, all three test files: git diff --stat ee022fad69..7d458766f7 empty. Guards, pins, :114, the eight-row table and the single-package declaration did not move.

  2. The previous FAIL sentence is gone and its replacement is true. Own tsc probe (tsc 6.0.3; probe tsconfig extends packages/spec/tsconfig.json, overriding only rootDir and noEmit; 21 single-declaration files; 13 diagnostics, 0 outside the probe dir; two instrument controls fire TS2322 as expected):

    • A literal with trial omitted inside satisfies Record[EnvironmentType, DiscoveryEnvironment]: TS1360. B, the same wrapped in Object.assign(Object.create(null), …): TS1360 — still fires. So the new sentence "satisfies applies to the literal … a missing bucket still reports TS1360" is TRUE, and the old "COSTS the … satisfies … gate" was false, reproduced. Extra: B2 (non-member key inside the satisfies block, under Object.assign) still fires TS2353 and B3 (bogus value inside the block) still fires TS2322 — the whole finding: preview / trial 是 EnvironmentType 的一等成员,但 NODE_ENV_TO_DISCOVERY_ENVIRONMENT 没有条目 —— 靠 ?? 'development' 兜底,折叠方向没被声明 #6287 gate, both halves and its value check, survives the spelling.
    • G Readonly[Record[EnvironmentType, …]] literal missing trial: TS2741; F same under Object.assign: SILENT. The instrument discriminates: an annotation-carried check does go silent under that spelling — the precedent's fact is real.
    • M shipped shape verbatim (seven buckets + satisfies) with dev: 'nope': TS2322; L same under Object.assign: SILENT. D1/D2 the minimal form: TS2322 / SILENT. N Readonly[Record[string, DiscoveryEnvironment]] = {}: SILENT. So "what the spelling would cost is [the outer annotation's] VALUE check — an index signature carries no key exhaustiveness to lose … dev: 'nope' reports TS2322 as a literal and is silent under Object.assign" is TRUE at this site. (Precision, not a defect: the value check lost is for the keys outside the satisfies block, prod/dev; the seven buckets' values are still checked by satisfies — B3. The comment's example is exactly a key outside the block and it does not overclaim.)
    • K1 Object.create(null) is any (IsAny pin holds; lib.es5.d.ts:186 create(o: object | null): any); K2 Object.assign(Object.create(null), U) is any (IsAny pin holds; lib.es2015.core.d.ts:284 returns T & U); K3 control Object.assign({}, U) assigned to string → TS2322. "Object.create(null) is any, and Object.assign's any & U result is assignable to anything" is TRUE.
    • E1 Record[string, ViewFilterOperator] = { ne: 'nope' }: TS2322; E2 under Object.assign: SILENT; E3 = {}: SILENT. The view sentence "COSTS the annotation's VALUE check — Record[string, ViewFilterOperator] is an index signature, so it never carried a key-exhaustiveness check to lose … ne: 'nope' reports TS2322 as a literal and is silent under Object.assign" is TRUE.
  3. The (TS2353) citation, judged. H1 __proto__: null against Readonly[Record[string, DiscoveryEnvironment]]: TS2322 ("Type 'null' is not assignable"); H2 against Record[string, ViewFilterOperator]: TS2322; J against the closed Readonly[Record[EnvironmentType, …]]: TS2353. Both shipped sentences frame the code as what src/data/type-compat.ts records, and type-compat.ts:233-238 does say "(TS2353)". The action-guiding claim — a __proto__: null literal does not type-check against the annotation — is TRUE at both sites; only the error number is the precedent's. An editor who tests it here gets a compile error that confirms the rationale, which is the opposite of the FAILed case (where the named gate visibly kept firing). Judged honest attribution with an imprecise code: a nit, not a false rejection rationale. Two hops behind it: type-compat.ts's own tables are Record[string, CanonicalSqlType] and Partial[Record[SqlDialect, …]], and its text attributes the measurement onward to src/shared/value-domain.zod.ts (Readonly[Record[ValueDomain, …]], closed — where TS2353/TS2741 are the real codes). One clause fixes it. Seat's call whether now or in a follow-up; not a verdict condition.

  4. Every other sentence in both changed comment blocks, checked against the code beside it (both files ship as source: npm pack --dry-run lists src/ui/view.zod.ts and src/api/discovery.zod.ts; files[] carries src/**/*.zod.ts). discovery: "plain object literal" — yes (:592-611); "constructor handed the Object FUNCTION, and __proto__ Object.prototype itself" — base probe: yes; "@returns … promises verbatim 'a value guaranteed to satisfy {@link DiscoveryEnvironmentSchema}'" — :650 verbatim; "toString / valueOf are quiet here only by the accident that spelling is lower-cased first" — :626 lower-cases; base probe returns 'development' for both; "refusal value … 'development' — the same answer qa, uat or a typo already gets" — probe qa'development' both legs; "every declared bucket and operator shorthand is an own key" — nine literal keys. view: "indexes the table TWICE" — base :332; "case-folding accident … at canonicalizeSqlType, resolveDiscoveryEnvironment" — type-compat.ts:207 and discovery.zod.ts:626 both lower-case first; base probe: all three prototype methods come back at view, none at discovery; "so the enum's own validation reports it as invalid" — view.zod.ts:704 z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS)); "every alias that answered before is an own key" — literal keys. No false sentence found. One wording stretch, view :350-352: "for the reason src/data/type-compat.ts records: … COSTS the annotation's VALUE check" — type-compat.ts records "exhaustiveness check", not "value check"; the value-check half is this site's own measurement and is marked "Measured under this package's tsconfig.json". Loose citation frame, true content. Nit.

  5. Guards and pins, re-verified at head. Guard text Object.prototype.hasOwnProperty.call counts 2/1/2 in the three sources (git objects at 7d458766f7), 0/0/0 at base — five guards, the type-compat.ts:244-247 shape. Third-spelling regex over the PR's added lines: 7 hits, all comment lines, 0 code; classifier control: the 6 added lines carrying hasOwnProperty.call classify as non-comment (5 guard lines + 1 changeset prose line); the regex sees real code elsewhere (packages/cli/src/utils/build-runtime.ts:69). Red leg: three sources restored to 1bdbf82cb5 worktree-only, proven by git hash-object = base blob (f74688572f, 125d3059ba, 130512b943) and guard-text 0/0/0 → three pin files: 3 failed (3) / 17 failed | 98 passed (115). Restore git checkout HEAD -- …: git status --porcelain empty, hashes = HEAD (1f32d5efe1, 1d1cfd0bef, c5bf5ecb7b), guards 1/2/2 → 3 passed / 115 passed. The pins pin.

  6. Assertions across the whole PR (1bdbf82cb5..7d458766f7, not per file): expect( +22 / −0; it(/it.each +11; deletions inside *.test.ts: 0/0/0; skip/only/todo/x-prefixed/it.fails in added lines: 0 (control: the same regex hits 3 files under packages/**/*.test.ts). The only 4 deleted lines in the whole diff are the four bare-index return statements. Deleted, loosened, skipped or narrowed assertions: 0.

  7. Changeset (ships as CHANGELOG.md — in the tarball): untouched by the fix-up; all 8 before/after rows reproduce against my base/head probe; "re-exported from /shared" true; "guard only NARROWS" true; its null-prototype parenthetical is attributed to type-compat.ts's "two recorded rejections", which that file does record verbatim. No false sentence.

  8. Not shipped, for the record. src/meta-spelling/manifest-collection-spelling.ts is absent from the tarball (no file of that name in npm pack --dry-run, 2012 files) and its dist carries none of the comment text (dist/meta-spelling/index.mjs: exhaustiveness 0, TS2353 0, null-prototype 0, guards 2). Its comment still says "COSTS the annotation's exhaustiveness check" for a Record[string, string] table — the same imprecision the fix-up corrected in view.zod.ts, left uncorrected here. Unshipped, outside the rework's declared scope; a consistency nit. The two TS2353 hits in dist/api/index.d.ts trace to src/api/protocol.zod.ts TSDoc present at base (2 hits there too) — not this PR's text.

② Semver level

Changeset declares '@objectstack/spec': minor; @objectstack/spec is in the single 70-member fixed group; .changeset/pre.json absent. Clause-②: yes stands on the claim comment, and needs:contract-review is on both carriers now (PR labels read at review time include it; card labels include it; node scripts/pm/check-clause2-carriers.mjs --pair 18233 → exit 0, "both carriers agree"). AGENTS.md: a PR that declares Clause-②: yes takes at least minor. Level matches the declaration. The diff itself narrows (no new export, key or accepted value; check:api-surface unchanged); the previous review's plain-semver dissent is on record and the level is ruled — not re-litigated here. The fix-up moved nothing on this axis (changeset blob unchanged).

③ Boundary flags

  • Fix-up Q1 (who re-hangs needs:contract-review): resolved by the seat, measured — label present on both carriers, --pair exit 0.
  • Fix-up Q2 ((TS2353) imprecision): answered in ① item 3 with H1/H2/J — honest attribution, nit; option A was a defensible call and does not block. Seat may fold the one-clause fix in whenever the file is next touched.
  • Fix-up Q3 (PR-body footer form): not a diff or contract question; no effect on this verdict.
  • :114 scope (ruled A, stands): re-verified only that the fix-up did not touch it — manifest-collection-spelling.ts blob c5bf5ecb7b identical at ee022fad69 and 7d458766f7; guard present; base probe shows the same defect out of a string signature.
  • Ablation declined this round — reasoning holds: emitted JS is byte-identical across the fix-up (① item 1), so no assertion could change colour on a fix-up-only red leg; the whole-PR red/green was re-run here anyway (① item 5).
  • PR body (does not ship; rule applied: an error there is a record defect, not a published-surface defect — none found). Corrected importer list verified against the tree: core/src/metadata-service-contract.ts:102, lint/src/validate-preset-comparands.ts:10, metadata-protocol/src/protocol.ts:81, objectql/src/engine.ts:189, rest/src/view-filter-rule-lowering.ts:52, runtime/src/domains/meta.ts:16 + runtime/src/http-dispatcher.ts:16 — exactly the six packages listed. packages/cli/src: 0 hits (control: packages/cli/test/generate-scaffold-validates.test.ts:98 imports singularToPlural). metadata-core/src/meta-write-org-scope.ts:71 imports the tables, bare index at :101/:124 — as the body says. Prose-only mentions metadata/src/routes/hmr-routes.ts:129, service-analytics/src/analytics-service.ts:785 — as the body says. Re-export scan (multiline-aware) over packages/apps/examples non-test: 0 outside packages/spec; control inside: shared/metadata-collection.zod.ts, meta-spelling/index.ts. Single-package declaration complete. The body's probe codes match mine at every declaration.
  • Checks run at head, exit status (fresh worktree, pnpm install --frozen-lockfile --offline 0): pnpm --filter @objectstack/spec build 0 (34/34 dts); … typecheck 0 (tsc + scripts + test-typecheck ledger 54/259/144 unmoved); … check:generated 0 (15/15); … check:api-surface 0 (unchanged); … check:docs 0 (222 generated files in sync); … test 0 — 478 files / 13638 tests; pnpm lint (repo-wide, not narrowed) 0; check-clause2-carriers --pair 18233 0. Probe dir removed before lint/typecheck; git status --porcelain 0 lines at the end.
  • CI from the API (final poll after Lint & Repo Gates completed 00:54:18Z): 46 runs — 38 success, 8 skipped, 0 failure, 0 in progress; commit status Vercel success. Non-success runs, each by its own gate text at this head: Build Docs (docs path filter — none touched); Console Pin Gate (console path filter — none touched); Auto Label and Check PR Size ×2 in the label/body-edit event runs, whose instances in run 34913556301 are success; Packed-tarball smoke (opt-in) ×2 (label absent). A skip is not a pass; each is by design and each has a green sibling or is opt-in. No failing check exists, so no failing check skipped later steps. Skipped STEPS inside green jobs, quoted: Save Turbo cache (main only); Upload stall diagnostic reports (if: failure()); Settle the skip-changeset window; Require a usable diff base; Verify-lock entry-point self-test (family not selected for this file set). No red truncated anything; no "flake" invoked.
  • Zero-hit controls: emitted-JS identical ↔ base-vs-head DIFFERENT; skip-marker 0 ↔ 3 hits in packages/**/*.test.ts; third-spelling code 0 ↔ 7 comment hits + code hit in cli/src/utils/build-runtime.ts:69; re-export 0 ↔ 2 in-spec hits; cli-src 0 ↔ cli-test 1; tsc probe SILENT readings ↔ 13 diagnostics incl. two instrument controls; dist comment text 0 ↔ src 1/3/1.

Implemented-by: claude/issue-17818-prototype-fallthrough-lookups
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance —— 达档条款②复核 PASS,双载体已清

domain:spec 执行席,session_01KB5PFtxuy1x3dcR5gxudx6,2026-09-15T01:0xZ。

复核记录 5673080212(逐字采纳,⛔ 未改写)
所判 head 7d458766f7dc4bdac6f92113382def0f9a69a3ee
保险丝 转录 "model":"claude-fable-5-1" 115 次且无第二值;对照两个 os-devclaude-opus-5 ×59 / ×170
独立性对 Implemented-by: claude/issue-17818-… · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6 ⇒ 非自审
双载体 已于 01:03Z 两侧清除并回读;--pair 18233 exit 0(「both carriers agree, and a review of record names this head」)

⚠️ 这是这条 head 血脉上的第三次阅读:第一个复核死于容器重启;第二个在 ee022fad69 上 FAIL;这一个判返工后的 7d458766f7,并被明令 ⛔ 不得读前两者的暂存 —— 每个数字都在 pr-18233-r3/ 重新推导。

它没有采信返工的自证。 返工声称「纯注释」,它用 ts.transpileModule(removeComments: true) 量:两个文件在 ee022fad697d458766f7发出字节相同的 JS(sha256 前缀 150ee997… / 455524e7…),对照:同一仪器读 base-vs-head 的 view.zod.ts 发出的 JS 不同。⇒ 「纯注释」是测出来的,不是被接受的。

⭐ 它还跑了返工没跑的两个对照(B2/B3):非成员键与错值在 Object.assign(Object.create(null), …) 之下照样报 TS2353 / TS2322 ⇒ 整个 #6287 闸门的三个半边全部幸存。上一轮 FAIL 的那句之所以是假的,这下有了完整的形状。


席位裁定 —— (TS2353) 那处不精确:留着,不在本 PR 改

复核把这件事明确交给本席,并点出代价:改它要动 head,而 head 一动这份 PASS 就作废,需要第三次达档复核

裁定:不改。 判据三条:

  1. 行动指向的主张在两处都为真 —— __proto__: null 字面量确实不通过注解的类型检查(此处 TS2322,precedent 的封闭键集 TS2353)。去实测它的编辑者会被证实,不会被反驳 —— 这与上一轮 FAIL 的情形正好相反(那一句里被点名的闸门在实测中照样发火)。
  2. 归属是诚实的:两句都写成「type-compat.ts 记录的理由」,而 type-compat.ts:233-238 确实那么写。错的是那个错误码的传递,不是这两处的断言。
  3. 代价不成比例:为一个已被证实无害的错误码,花第三轮达档复核(约 25 万 token)并把一个已经 PASS 的 head 推翻。

承接者:下一个碰 view.zod.tsdiscovery.zod.ts 的 PR,顺手一条子句收掉:「TS2353 against the precedent's closed key set; TS2322 here, where the key type is string」。同批还有两处同源小瑕:view.zod.ts:350-352 的引用框(把「VALUE check」归给了 type-compat.ts,而该文件写的是 exhaustiveness),以及 meta-spelling/manifest-collection-spelling.ts 里同一处措辞未被一并更正 —— ⚠️ 后者实测不进 tarball(npm pack --dry-run 2012 个文件中无此名,dist 里注释文本零命中),故非发布面缺陷。

⛔ 本裁定不是说散文不重要 —— 同一批里,一句真的假话让这个 PR FAIL 了一轮。分界是:那一句会让读者做错事,这一句不会。


落地前检三条

结果
① 席内达档条款②复核 PASS 在案 5673080212
② 双载体已清,--pair 机读 ✅ exit 0
③ 全部 check 全绿(⛔ 非 required 子集) 本席清标那一笔又触发了一次 Check Changeset,正在跑

⚠️ ③ 未满足之前 ⛔ 不转 ready、⛔ 不入队。一个在跑的检查不是绿的 —— 本席今晚已因把中途快照当读数更正过一次,不再犯。


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:ui size/m tests tooling

Projects

None yet

2 participants