Skip to content

test(packages): gate that a package door's field allowlist cannot silently drop a stamped or declared key - #14830

Merged
os-project-manager merged 6 commits into
mainfrom
claude/issue-14642-package-door-field-allowlist-gate
Sep 3, 2026
Merged

test(packages): gate that a package door's field allowlist cannot silently drop a stamped or declared key#14830
os-project-manager merged 6 commits into
mainfrom
claude/issue-14642-package-door-field-allowlist-gate

Conversation

@os-trump

@os-trump os-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14642

A gate that goes red when a /packages door's field allowlist drops a key the
producer stamps or the record declares. Both sides of the comparison are derived
by running real code; the only hand-kept artifacts are two annotated registers,
both compared loudly.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

File face

Two files, both new, both tests:

  • packages/rest/src/package-door-producer-key-carry.test.ts
  • packages/runtime/src/domains/package-door-producer-key-carry.test.ts

No production file is modified. In particular packages/rest/src/error-response.ts
is not touched — PR #14713 is in flight on it and the gate's honest landing
site was never that file. No CI job added, no CI filter root widened.

Base refreshed by merging origin/main (not rebasing, no force-push) at
47181632f2. That merge matters: PR #14785 landed packages/runtime/tsconfig.test.json
while this branch was open, so the runtime pin is now compiled by a real tsc
program. See Verification.

Is the near-miss still reachable on today's main?

  • For writable itself: no. Both doors carry it now, by two different
    mechanisms, and both are pinned by name (package-list-writable-carry.test.ts,
    packages-serializable-response.test.ts).
  • For the general case: yes, fully reachable. Nothing generalised past that
    one field. The ablation below reintroduces the drop four different ways and
    every one of them shipped a 200 with a field silently absent before this PR.

So the card's premise stands, with the triage comment's sharpening: the gap is
the next stamped or declared key, not writable.

The two doors do not share an invariant, and the gate does not pretend they do

REST door runtime door
producer getMetaItems({type:'package'}) registry.getAllPackages()
where writable comes from stamped upstream, listed in the allowlist computed at the door, deliberately not in the allowlist
invariant the allowlist contains every stamped key the stamp runs after the projection

Asserting "the allowlist contains every stamped key" at the runtime door would
red on a correct door. So the runtime pin measures the stamp set as
served − record — never a list — and compares it for set equality against
an annotated register. A reorder empties that measured set and reds; a new stamp
grows it and reds until the decision is written down. That register is hand-kept
on purpose: swapping a silent list for a loud one is the mechanism this card
asked for.

Both gate readings

Four ablation legs, each mutating one production line, each restored and proven
restored. The mutation is proven on disk by an anchor count before/after, never
by the editor's exit code. Re-run in full after the merge; every number below
is identical before and after
, so the merge moved nothing.

leg mutation gate reading
1 drop 'writable' from REGISTRY_PACKAGE_RESPONSE_FIELDS REST REDlist/detail PKG: writable, 6 drops across 3 packages
2 drop 'installedVersion' from the same allowlist REST REDlist/detail PKG: installedVersion, 6 drops
3 reorder to toPackageResponse(withWritableVerdict(...)) runtime REDstamp set for com.example.showcase: expected [] to deeply equal [ 'writable' ]
4 drop 'installedVersion' from INSTALLED_PACKAGE_RESPONSE_FIELDS runtime REDlist/detail PKG: installedVersion, 6 drops
control tree restored both GREEN — REST 4/4, runtime 5/5

Leg 1 is the exact near-miss the card is about. Leg 3 is the exact regression the
runtime door's ordering comment warns about. Legs 2 and 4 are the generality
claim: a plain declared field, with no field-specific pin anywhere.

No rebuild leg is involved and none is owed. Both mutated files are resolved
by vitest as local source — the REST pin imports ./package-routes.js, the
runtime pin reaches ./domains/packages.js through ../http-dispatcher.js — so
dist/ is not on the resolution path for either subject and there is nothing to
preflight. The restore leg is proven by git diff HEAD being empty for both
doors after every leg, not by the restore command's exit code.

Two findings the ablation produced, both now fixed in this PR

Recording these because in both cases the first version of the gate was green
for a bad reason, and a reviewer should see that the readings above are not the
first ones taken.

  1. Object.keys was the wrong instrument. installPackage seats settings
    as an own property holding undefined. Both doors omit undefined-valued
    fields deliberately, and JSON.stringify drops them anyway, so the first run
    reported six false drops for a key no consumer could ever have observed. The
    detector now measures defined-valued keys.

  2. The fixture could not see two thirds of the record. Deleting
    installedVersion from either allowlist left both gates green on the
    first ablation: installPackage writes only the fields an install can know,
    so six of the twelve declared fields are simply absent from a freshly
    installed record. seatDeclaredFields now seats every field the record
    schema declares, and a control assertion fails if any declared slot becomes
    unobservable again.

On packages/spec

The card rules out deriving the production allowlist from packages/spec,
and this PR does not: both allowlists stay hand-written and are untouched. What
finding 2 derives from the schema is the test's expectation, which is the
card's own wording for the detector — "the projected key set ⊇ the producer's
stamped/declared key set minus an explicit, annotated exclusion list".
Deriving the expectation is precisely what turns a newly declared field into an
explicit decision at each door instead of a silent omission. It adds no import
edge either: @objectstack/spec is already a runtime dependency of both
packages. The rationale is spelled out at DECLARED_RECORD_KEYS in both files so
it cannot be mistaken for the rejected design.

Why two pins rather than one detector

Measured, not assumed. A single file would have to reach the other package's
door:

  • From packages/runtime, @objectstack/rest resolves to its dist/ (no
    vitest alias maps it to source), which would make the gate's verdict a function
    of build state. It would also grow KNOWN_UNALIASED_TEST_IMPORTS in
    scripts/check-test-source-alias.mjs, a shrink-only registry that lists no
    @objectstack/rest entry for that package today.
  • @objectstack/rest cannot import @objectstack/runtime at all — the
    dependency runs the other way.

So: two pins, one shape. Each imports its own door as source, which is also
what lets a reviewer ablate one allowlist and watch exactly one gate go red.

What I did NOT do, and why

  • Did not change either allowlist. Both are correct today; the card asked for
    the detector, not an edit to the lists.
  • Did not touch packages/rest/src/error-response.ts (hot file, PR fix(rest): consult the bespoke structured arms before the declared-status passthrough, so both error doors answer one refusal with one body #14713).
  • Did not add a changeset. The diff is two test files and nothing else. No
    package publishes a test file, so nothing user-visible ships. skip-changeset
    applied.
  • Did not widen a CI filter root or add a CI job.
  • Did not add anything to packages/runtime/test-typecheck-debt.json, and did
    not need to — see Verification. That ledger is byte-identical to origin/main
    in this branch.
  • Did not silence anything to reach green. No any, @ts-expect-error, or
    eslint-disable was added anywhere, and no assertion was weakened. The
    load-bearing comparison — set equality on the stamp set — runs over
    Set(string) and string[], not any.

Verification

All readings from 47181632f2, the merge commit at the head of this branch.
The dependency closure was built first (pnpm --filter '@objectstack/runtime^...' build),
because an unbuilt closure turns every workspace import into TS2307 and makes a
typecheck verdict a statement about a different world.

The runtime test-layer typecheck, which the merge newly applies to this PR

pnpm --filter @objectstack/runtime typecheckEXIT=0. It now chains
check:test-typecheck, whose own verdict line is:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

Three separate confirmations that the new runtime pin is genuinely covered and
carries no debt:

  • tsc --noEmit --project tsconfig.test.json --listFiles shows 1 hit for
    src/domains/package-door-producer-key-carry.test.ts — it is in the program.
    (This replaces the pre-merge reading of 0 hits, which was true on the old base
    and is now false.)
  • Error lines naming that file in the same run: 0. The 191 errors are exactly
    the ledgered ones in the other 27 files.
  • packages/runtime/test-typecheck-debt.json is byte-identical to
    origin/main's copy — nothing added, no number moved.

Other green

  • packages/rest gate — 4/4; packages/runtime gate — 5/5.
  • Sibling door pins re-run beside them after the merge:
    package-registry-item-projection, package-list-writable-carry,
    packages-serializable-response, packages-writable-verdict — 3 files /
    14 tests and 3 files / 23 tests, both EXIT=0.
  • pnpm --filter @objectstack/rest typecheck — EXIT=0, with --listFiles
    confirming 1 hit for the new REST test file.
  • 25 of the 28 gate families derived by scripts/pm/dispatch-gates.mjs --commands
    — exit 0, re-derived and re-run on the merged head (the family list is
    unchanged, and the change set is still exactly these two files).
    check:engine-double-contract was RED early on (my fixture's findOne double)
    and is green after removing that verb, which the producer path never calls.

Not measured, with the reason

  • check:dual-build-cjs-loads and check:type-check-debt --re-measure — exit
    3, "PREREQUISITE NOT MET": both need the whole workspace built
    (turbo run build --filter='./packages/*' --filter='./packages/*/*'), not just
    one closure. Exit 3 is neither a pass nor a finding. The sub-measurement that
    this diff could actually have moved — the runtime test-layer debt — is answered
    directly above by check:test-typecheck at EXIT=0 with the ledger unchanged.
    And check:dual-build-cjs-loads reads built output, which a diff adding only
    *.test.ts files cannot reach.
  • check-test-completeness — exit 3; it needs a saved turbo run test log,
    and its own failure text says to record it NOT MEASURED when run locally.

Narrowed, declared

The two packages' FULL suites were not run. Nine attempts on the shared verify
lock across this branch never got a long enough turn — holders included one run
at 917s and another at 1704s — and @objectstack/runtime's whole suite exceeds
what fits inside the container's foreground cap on a shared box. Narrowed to the
two new files plus the four sibling pins on the same two doors, all green above.
The diff modifies no production file, no config, and no turbo/vitest setting, so
the only way a sibling test's verdict could move is cross-file interference
inside a vitest worker; CI runs the full sharded suite on this PR regardless.

Generated by Claude Code

…drop a stamped key

Both `/packages` doors project the installed-package record through a
hand-written field allowlist. The trade that bought was explicit: drift
shows up as a missing field, never a 500. Within one day of it landing,
an ADR-0070 D2 `writable` verdict started reaching both doors and both
would have dropped it — a 200 with the field simply absent. One door was
saved by someone reading a sibling pin, the other by a merge conflict.

The field-specific pins that followed cover `writable` and nothing else.
These two gates cover the general case, deriving both sides from real
code rather than a hand-kept key list:

  - REST door: `served ⊇ getMetaItems({type:'package'}) keys`, measured
    through the real `ObjectStackProtocolImplementation` over a real
    `SchemaRegistry`.
  - runtime door: `served ⊇ record keys`, plus a set-equality register
    for the keys the door stamps AFTER the projection — measured as
    `served − record`, so a reorder empties it and reds.

The two doors solved the near-miss differently (the REST allowlist
contains `writable`; the runtime one deliberately does not and orders
instead), so the invariants are stated separately rather than assumed
symmetric. Only hand-kept artifacts are the annotated exclusion and
stamp registers, both compared loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…esence

`SchemaRegistry.installPackage` seats the optional record fields as own
properties holding `undefined`, so `'settings' in record` is true for a
package installed without settings. Both doors omit undefined-valued
fields deliberately and JSON.stringify drops them anyway, so counting
them made the gate red on every package for a key no consumer could have
observed — measured on the first run, six false drops.

Also drops the fixture's `findOne` double: `getMetaItems` never reaches
that verb, and `check:engine-double-contract` is right that a fake looser
than ObjectQL.findOne is worth refusing rather than stubbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
… all of them

The ablation caught this and it is the more useful of the two findings:
deleting `installedVersion` from EITHER door's allowlist left both gates
GREEN. `installPackage` leaves 8 of the 12 declared fields as own
properties holding `undefined`, and a key the wire cannot carry is
correctly invisible to the detector — so the coverage assertion was
exercising 4 fields while reading as if it covered the record.

`seatDeclaredFields` fills every own key whose value is `undefined`,
derived from the record's own key set rather than from a list of field
names, so a field added tomorrow is seated without an edit. A control
assertion now fails if any declared slot is unobservable again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…Schema

The first ablation's most useful reading: deleting `installedVersion`
from either allowlist left both gates GREEN. Six of the twelve declared
record fields are ABSENT from a freshly installed record (installPackage
writes only what an install can know), so a gate watching only the
producer's live output cannot see them dropped.

The card asks for `served ⊇ the producer's stamped/DECLARED key set`, so
the declared half is derived from the record schema — in the TEST's
expectation, never in the production allowlist, which stays hand-written
and untouched. That distinction is the one the originating card ruled on
and it is spelled out at DECLARED_RECORD_KEYS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions github-actions Bot added the size/l label Sep 3, 2026
@os-trump os-trump added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/l labels Sep 3, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 3, 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

@github-actions github-actions Bot added the tests label Sep 3, 2026
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33742531401 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test:  FAIL  src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
      ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 102 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-project-manager
os-project-manager added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 9c7237c Sep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14642-package-door-field-allowlist-gate branch September 3, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

3 participants