fix(plugin-security): a permission-set name collision now reaches the author - #18022
fix(plugin-security): a permission-set name collision now reaches the author#18022claude[bot] wants to merge 6 commits into
Conversation
…author A package's entire declared permission set was dropped on a set-name collision with no author-visible output at all: the branch reported through `logger?.warn?.(...)`, optionally chained twice, so a caller passing no logger produced nothing and the set vanished with one counter moved. The comment there said "refuse loudly"; nothing about it was loud. The skip itself is unchanged and correct (ADR-0086 D4: a package never writes into a foreign record). What changes is that the refusal is now reported — through the console when no sink was injected, per the #10556 ruling that silent-by-declaration is rejected — and that the diagnostic record travels back on the pass outcome, so a caller reading no log can still ask which sets were dropped and why. The collision predicate, the wording and the formatter live in one module so the compile-time door can consume them rather than re-derive them, which is the shape #14553 established for `navigationContributions`. Only the runtime door is built here; the compile-time door lives in another package. Also corrects the branch comment's premise. It claimed package-namespaced object api names make set-name collisions a packaging bug rather than a merge case; ADR-0130 D1 falsifies that — N packages may co-own one namespace, so a collision is a legal configuration that gets more common. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…add changeset
The two existing pins asserted the old prose ('owned by another package').
Their substance — the refusal is REPORTED, not merely counted — is unchanged;
they now assert the stable `permission_set_name_collision` token an operator
greps and the sibling doors key on, plus the diagnostic record beside it, so
prose drift cannot quietly unpin them.
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
…rmission-set-collision-diagnostic
…UpdateDispatch `check:engine-double-contract` named the new fake's `update()` as a double looser than `ObjectQL.update`. It now opens with `assertEngineUpdateDispatch` and is recorded in the pinned ledger, regenerated with the repo's own `--write` (799 rows, 1 added or grown, 0 lost; byte-identical across two runs). Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d9188ff8a37c148ba5fa0f6020dd75b5454133c4 && git checkout d9188ff8a37c148ba5fa0f6020dd75b5454133c4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 226970bbea94b97e0d74de98dfa189a0d35faa9d 24ed6b97ff660dd31517189dff2945cd1eb0918d && git checkout -B drift-repro 226970bbea94b97e0d74de98dfa189a0d35faa9d && git merge --no-ff 24ed6b97ff660dd31517189dff2945cd1eb0918d
node scripts/docs-audit/affected-docs.mjs --json 226970bbea94b97e0d74de98dfa189a0d35faa9d
|
Contract reviewHead reviewed: Implemented-by: ① Clause-②:
|
🔴
|
… caller's bound
Two repo gates graded the `find` double this branch adds, and both reds are this
branch's own. CI stops at the first non-zero exit, so only the first was visible:
the second sat in the unmeasured tail behind it.
1. `check:where-matcher` -- SILENTLY WRONG (shape (b)). A top-level `$or` / `$and`
carries an ARRAY value, so the operator-object arm never sees it and the key
falls through to `r[k] === v`. `r.$or` is `undefined`, no row matches, and a
suite asserts on an empty result set with nothing erroring.
Measured on the real bytes, lifted out of the file and handed
`{ name: 'crm_sales_rep', $or: [...] }` where the seeded row satisfies the name
AND the first arm:
before RETURNED [] <- the row a real driver returns, silently dropped
after THREW Error: fake driver: unsupported combinator $or
2. `check:objectql-double-limit` -- BLIND to the caller's bound. `defaultLookup`
really does read `{ where: { name }, limit: organizationId ? 5 : 1 }`, and the
bound is load-bearing (#10103: a scoped read returns organization-less rows
beside this organization's own). A limit-blind double cannot tell that read
from an unbounded one, so every change to that bound was green by construction.
Both remedies are the ones the gate headers prescribe, and both spellings are the
sibling double's in this same package (`objects/reserved-identity-names.test.ts`):
one `throw` rather than a combinator implementation -- "the defect class is
*silence*, not incompleteness" -- and the bound applied by PRESENCE
(`typeof q?.limit === 'number'`, so `limit: 0` returns nothing rather than
everything) AFTER the filter.
⛔ Neither baseline grows, and neither gate is weakened, skipped or re-ordered:
where-matcher 408 discovered, 408 conforming (270 refuse, +1), no files added
objectql-double-limit 386 graded, 188 apply the bound or refuse (+1), no files added
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
Contract review — re-issued for the rework headHead reviewed: What actually moved — 1 file, +13 / −1
The rework — it took the gate's own prescription, ⛔ not a shape that merely satisfies the scanner// ⛔ REFUSE what this double does not implement. A `$or` / `$and` read
// as a FIELD NAME is the silently-wrong shape … (`check:where-matcher`, shape (b)).
if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`);That is verbatim the remedy ⭐ It also repaired a SECOND defect in the same double, unprompted// Hold the caller's BOUND, after the filter and by PRESENCE, so `limit: 0`
// returns nothing rather than everything. `defaultLookup` really does read
// `{ where: { name }, limit: organizationId ? 5 : 1 }` (#10103) …
return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits;The double was limit-blind, so it could not tell the bounded read
Gates — the completeness this PR was previously missing
⇒ the unmeasured tail is now measured, and it is green. Clause-② — unchanged, and re-checked rather than inheritedThe rework touched one test file: no exported symbol added, no key added to a published payload. ⇒ the declaration stays
Serial relay on the pinned ledger — measured on BOTH sides before any merge
⇒ this PR adds exactly one row and there is no competing side today, so no merge can silently swallow one. Docs drift — disposed, ⛔ no card14 rows. 13 are name collisions on generic anchors — The one substantive row was checked rather than lumped in:
Still true: the skip is unchanged (no Remaining red — established as NOT this PR's
Verdict: PASS at
|
🔴 Serial-relay hold — this PR and a sibling both append to
|
| side | rows |
|---|---|
origin/main c185d087b |
1491 |
#18022 merge-base 32a321430 |
1491 |
#18046 merge-base 1e20f816e |
1491 |
#18022 head 498f60543 |
1492 |
#18046 head 2be67d2d4 |
1492 |
⇒ the only correct end state once both have landed is 1493. A reading of 1492 means a side was silently swallowed.
⛔ One baton at a time. Whichever of the two lands second must, before enqueueing: merge main, regenerate with the repo's own --write, and prove 0 lost AND byte-identical, with the row count reading 1493. ⛔ The merge exiting 0 is not evidence. ⛔ Path-disjointness is not evidence either — that reasoning was wrong and is recorded as such (correction 170, amended against correction 149: the hazard is the ordinary text merge, and this file carries no merge=os-regen driver, so checking the driver and finding none proves nothing).
Re-measure with:
git show <ref>:scripts/engine-double-contract.pinned.json | python3 -c \
"import json,sys;d=json.load(sys.stdin);print(sum(len(v) for v in d.values()) if isinstance(d,dict) else len(d))"Recorded in the seat registry (#6021 §3). Neither PR is enqueued today in any case — both wait on #18032.
Generated by Claude Code
Rework round — the
|
{ name: 'crm_sales_rep', $or: [...] } |
|
|---|---|
before (6a5958acd blob) |
RETURNED [] — the row a real driver returns, silently dropped |
| after | THREW Error: fake driver: unsupported combinator $or |
A top-level combinator key carries an array value, so Array.isArray(v) turns the operator-object arm off and the key falls through to r[k] === v. r.$or is undefined, nothing matches, and a suite would assert on an empty result set with nothing erroring. That is shape (b) in the gate's own header.
3. The remedy, and why that shape
The gate's header prescribes it in one sentence:
A discovered matcher must answer every combinator probe CORRECTLY, or REFUSE it by throwing. What it must never do is answer silently wrong.
and says which of the two to pick here:
Refusal counts as conforming on purpose -- the defect class is silence, not incompleteness. ... it is why the cheap correct answer for a double that only ever sees scalar equality is one
throw, not a full combinator implementation.
This double only ever sees scalar equality and one $in: defaultLookup reads { where: { name }, limit: … }. So it refuses, in the spelling the sibling double in this same package already uses (src/objects/reserved-identity-names.test.ts): if (k.startsWith('$')) throw new Error(...).
⛔ The gate is not weakened, skipped, re-ordered or quarantined, and ⛔ nothing was added to any baseline: 408 discovered, 408 conforming (270 refuse) — up exactly one — and baseline key set verified against 32a3214: no files added.
4. 🔴 The unmeasured tail was not empty — it held one more red, also this PR's own
Lint & Repo Gates stops at the first non-zero exit, so every gate after step 156 had never run on this branch. Running the whole sequence found step 157, the very next gate:
x ObjectQL double `limit` conformance (1 problem(s)):
- packages/plugins/plugin-security/src/permission-set-name-collision.test.ts: NEW ObjectQL `find` double that does not hold the caller's bound (1 blind, 0 breaking a shape rule, 0 unjudged).
line 53: BLIND
This one is not cosmetic. defaultLookup really does read { where: { name }, limit: organizationId ? 5 : 1 }, and that bound is load-bearing — the code comment cites #10103: "Limit 5, not 1, when scoped: a scoped read returns organization-less rows alongside this organization's own, and one row would be whichever the driver ordered first". A limit-blind double cannot tell that read from an unbounded one, so every change to that bound was green by construction.
Remedy is the gate's prescribed shape, again matching the in-package sibling: the bound applied by PRESENCE (typeof q?.limit === 'number', so limit: 0 returns nothing rather than everything) and after the filter. Result: 386 graded, 188 apply the caller's bound or refuse it loudly — up exactly one — no files added.
5. The whole sequence, in the order lint.yml declares it
All 168 executable steps of the Lint & Repo Gates job (steps 5 through 172; 0-4 are checkout/node/pnpm/cache) re-run on the final tree 498f60543: every one exit 0, including pnpm lint over the whole repo (138s, exit 0).
- The 5 gate-family-conditional steps were run unconditionally — a superset of CI, which selects 4 run / 1 skip (
verify_lock) for this change set. lint.ymland rootpackage.jsonare unchanged between this branch's merge base and currentorigin/main, so the sequence run here is the current sequence.- No gate left the tree dirty:
git status --porcelainis empty at498f60543.
6. Suite and typecheck — the PR body's numbers still hold
At 498f60543, after building the dependency closure (pnpm --filter '@objectstack/plugin-security^...' build, exit 0):
pnpm --filter @objectstack/plugin-security test— 112 files, 2157 tests, 0 failures — identical to the numbers recorded at6a5958acdpnpm --filter @objectstack/plugin-security typecheck— exit 0 (all three legs:tsc --noEmit,tsconfig.scripts.json,check:test-typecheck— 0 files / 0 errors / 0 pinned signatures)- the new file's own suite: 9 passed (9)
⇒ neither remedy changed a single test outcome. The refusal is never triggered by the suite (nothing hands the double a combinator), and applying the bound truncates nothing the assertions depend on. Both are dormant guards that go red the day someone writes the query they model.
7. One process note, reported and not ridden in
node scripts/pm/dispatch-gates.mjs --commands does name both gates for this change set — pnpm check:where-matcher and pnpm check:objectql-double-limit are both in its derived list at this head. So both were derivable before the first push and were simply not run; neither appears in the earlier round's recorded gate list. No tool defect found, so ⛔ no card filed.
Generated by Claude Code
|
🟢 Baton taken — #18022 is the FIRST of the two to go; #18046 holdsThe blocker cleared: #18043 merged at Re-measured on
⇒ the arithmetic is unchanged: the first lander takes #18022 is armed (ready-flipped via MCP with a Generated by Claude Code |
Contract review — re-issued for the base-merge head, with the serial relay dischargedHead reviewed: Why the head moved — and it was NOT a CI kickArmed at
⇒ the real cause is the two stale reds still on the frozen head — So the base branch was merged in — 🔴 Serial relay — DISCHARGED for this merge, by direct measurement
⇒ the merge added nothing and dropped nothing; the file was not a merge participant at all, because
The review carries — everything else is unchangedThe PR's own file set is identical across the merge, so ①–⑤ of
Verdict: PASS at
|
Carrier stripped from both carriers, citing the record — and the reason it was still hungThe record cited: in-seat contract review PASS at Carrier cleared in one stroke on each carrier, read back:
|
Parked pending an AT-TIER contract review — this seat cannot clear this carrier, at any tierCorrecting my own comment above ( Why, read from the gate's own implementation rather than inferred
C7's docblock states the boundary plainly:
and gives the defect it was built from: "a round served below tier cleared a carrier indistinguishably from one served at it" — 11 measured rounds. ⇒ clearing this carrier requires a verdict served at What this seat measured, and what it did wrong
State, and what is actually needed
⛔ The code is not in question — CI is fully green and the in-seat review found no defect. What is missing is a verdict with provenance this seat is not permitted to produce.
Generated by Claude Code |
Contract review (independent, clause-② at tier)Served-tier: claude-fable-5-1 Head reviewed: Verdict: PASS for clause-②. The declaration Provenance of this record
① Derived judgments: what is newly reachable on this head versus
|
| symbol | kind | in src on origin/main |
dist/index.d.ts head / main |
dist/index.mjs head / main |
in export {} head (d.ts / mjs) |
newly reachable |
|---|---|---|---|---|---|---|
PERMISSION_SET_NAME_COLLISION |
value (const) | 0 files | 5 / 0 | 5 / 0 | yes / yes | yes |
permissionSetNameIsForeign |
value (function) | 0 files | 3 / 0 | 3 / 0 | yes / yes | yes |
permissionSetNameCollisionDiagnostic |
value (function) | 0 files | 3 / 0 | 3 / 0 | yes / yes | yes |
formatPermissionSetNameCollisionDiagnostic |
value (function) | 0 files | 2 / 0 | 2 / 0 | yes / yes | yes |
reportPermissionSetNameCollisions |
value (function) | 0 files | 3 / 0 | 4 / 0 | yes / yes | yes |
PermissionSetNameCollisionDiagnostic |
type (interface) | 0 files | 6 / 0 | n/a (type) | yes / n/a | yes |
CollisionReportSink |
type (interface) | 0 files | 3 / 0 | n/a (type) | yes / n/a | yes |
PermissionSeedOutcome.collisions? |
new key on published payload | absent | key PRESENT / ABSENT (parsed from the interface PermissionSeedOutcome block) |
n/a | payload exported on both trees | yes |
POSITIVE CONTROL bootstrapDeclaredPermissions |
value, published on both trees | 1 file (index.ts:57) |
7 / 6 | 7 / 7 | yes / yes | control hits on both trees |
NEGATIVE CONTROL SHIPPED_PLATFORM_BUCKET_NAMES |
module-private const | defined, not exported | 0 / 0 | body 2 / 2, export {} no | no / no | control stays dark on both trees |
NEGATIVE CONTROL defaultLookup |
module-private function | defined, not exported | 0 / 0 | body 2 / 2, export {} no | no / no | control stays dark on both trees |
Key set of PermissionSeedOutcome in dist/index.d.ts: head = seeded, updated, unchanged, unreadable, skippedEnvAuthored, skippedForeign, deleted, collisions; main = the same list without collisions. So limb (2) fires exactly once.
Accept/reject behaviour, read from the diff rather than the PR text:
- The skip is unchanged. The branch condition moved from
existing.package_id === packageIdto!permissionSetNameIsForeign(existing.package_id, packageId), whose body is(owner ?? null) !== (declaring ?? null). At that call sitepackageIdis always a non-empty string (theif (!packageId) return out;guard precedes it), so the two spellings are truth-table identical for everyexisting.package_idvalue (undefined,null, equal string, different string).skippedForeignhas no added or removed line. - What changed is reporting only: a console-backed default sink when no logger is injected, and the diagnostic records on the outcome. Neither alters what is accepted, refused, written, or returned to a wire caller. The ADR-0086 P2 publish materializer's existing failure text is untouched.
- The new token is stamped as
event, notcode, so no ADR-0112 error-code vocabulary is widened.
② Semver grade
.changeset/17516-permission-set-collision-diagnostic.md declares "@objectstack/plugin-security": minor. Seven new reachable exports plus one new optional payload key is an additive widening; minor is the floor AGENTS.md sets for a Clause-②: yes PR and is the correct grade here. Not patch, not major (nothing removed or narrowed).
③ Boundary flags
- Lane fence (compile-time door,
os build/os validate,packages/cli): stopped, nothing pushed, routed to cli:os build/os validateshould report a permission-set name collision — the runtime door landed in #17516, the compile door is the other half of #14553’s shape #18024 (open,domain:cli,priority:p3). Correct disposition; the exports above are what cli:os build/os validateshould report a permission-set name collision — the runtime door landed in #17516, the compile door is the other half of #14553’s shape #18024 is told to consume. - Out-of-scope sibling (
bootstrap-declared-capabilities.ts, same doubly-optional logger): filed as [finding] A package's whole declared capability is dropped on a name collision with no diagnostic — the same doubly-optional logger as #17516, one axis over #18023 (open,domain:services,pm:blockedon this card). Correct: separate record type and outcome key. open_questions:[]on bothos-dev-reportcomments. Nothing outstanding.packages/spec: zero files in the 9-file diff. Verified withgit diff --stat 226970bbea94 24ed6b97ff66.
What this record does NOT decide
- Clause-② only. Wire compatibility, code quality, and the earlier in-seat reviews' other findings are not re-adjudicated here.
- CI on this head was READ from the check-runs API at review time, not re-run:
Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL)allcompleted/success. The fullLint & Repo Gatesstep sequence was NOT MEASURED locally. - Labels, draft state, auto-merge, and landing are untouched by this session; they are the seat's.
Commands run
get_session (session_id omitted) -> session_context.model / external_metadata.last_served_model / configured_model, all claude-fable-5-1
grep -n CONTRACT_REVIEW_TIER scripts/pm/dispatch-gates.mjs -> 'claude-fable-5-1'
git fetch origin main 24ed6b97ff660dd31517189dff2945cd1eb0918d
git worktree add --detach /home/user/wt/pr18022 24ed6b97ff660dd31517189dff2945cd1eb0918d
git worktree add --detach /home/user/wt/main origin/main
git merge-base origin/main 24ed6b97ff660dd31517189dff2945cd1eb0918d -> 226970bbea94b97e0d74de98dfa189a0d35faa9d
git diff --stat 226970bbea94 24ed6b97ff66 -> 9 files, +694 / -9, none under packages/spec
git diff origin/main 24ed6b97ff66 -- packages/plugins/plugin-security/package.json -> empty
git diff origin/main 24ed6b97ff66 -- packages/plugins/plugin-security/src/index.ts -> the 17 added re-export lines only
git grep -c -w SYMBOL origin/main -- packages/plugins/plugin-security/src -> 0 files for each of the 7 (SYMBOL = each name in the table)
git show 24ed6b97ff66:packages/plugins/plugin-security/src/index.ts | grep -n 'export \*' -> no output
(in each worktree) pnpm install --frozen-lockfile && pnpm --filter '@objectstack/plugin-security...' build -> exit 0
(in each worktree) probe: grep -c -w over dist/index.d.ts, dist/index.mjs, dist/index.js; export-list and PermissionSeedOutcome key parse via node
(in /home/user/wt/main) node scripts/pm/check-clause2-carriers.mjs --pair 18022 -> exit 0, both carriers agree; note: claim comment 5652942907 carries no Session: line (ATTRIBUTION NOT VERIFIED, pre-existing)
(in /home/user/wt/pr18022) pnpm --filter @objectstack/plugin-security test ; pnpm --filter @objectstack/plugin-security typecheck -> see line below
At the head, in the fresh worktree: pnpm --filter @objectstack/plugin-security test = 112 files passed, 2157 tests passed, 0 failures, exit 0. pnpm --filter @objectstack/plugin-security typecheck = exit 0 (all three legs).
Generated by Claude Code
Carrier cleared on both carriers — provenanceRecord cited: Commissioned under the maintainer's ruling of 2026-09-13T22:2xZ, 「你可以直接派fable复核」, which authorises this seat to commission a contract-review-tier verdict directly.
The gate, run on BOTH sides of the strip
⭐ Running it twice is the point. On the earlier attempt this same command read 0 before and 4 after, because rows C4 and C7 judge the verdict the clear stands on — a pre-check alone cannot surface them. Both rows are now satisfied by the record above: the authorship pair is complete, and Landing state
Generated by Claude Code |
Fixes #17516
Clause-②: yes
A package's entire declared permission set was dropped on a set-name collision and the drop reached nobody. The branch reported through
logger?.warn?.(…)— optionally chained twice — so a caller that passed no logger produced no output at all, and the set vanished with one internal counter incremented. The comment there said "refuse loudly"; nothing about it was loud.⛔ The skip is unchanged. Refusing to write into a row another package owns is correct under ADR-0086 D4 and every test asserts it still happens, still counts, and still leaves the foreign row untouched. What changed is that the refusal is now legible to the author who caused it.
Reproduced first, then measured again
Both runs drive a real collision through
bootstrapDeclaredPermissionswith no logger passed, spying on all five console channels.c45ea8c39)skippedForeignundefinedThe after line, verbatim:
The control is a pass that really seeds and really re-seeds its own row, so the silence is over a pass doing work — a pin that only checked "something printed on a collision" would pass equally against a seeder that printed on every set.
Ablation (from the committed state): reverting the wiring took the new suite to
4 failed | 5 passed; the mutation was proven on disk first (the wiring marker went to 0, the old doubly-optional spelling back to 1), and the restore is byte-identical toHEAD(c11b5d82fb…) withgit diff HEADempty.What landed
reportPermissionSetNameCollisionsfalls back toconsole.warn, per the Pay down the optional-error sink ledger — 13 paid, 2 remain and both are DESIGN CALLS (was: "15 sink types") #10556 ruling that silent-by-declaration is rejected. An injected host sink replaces it rather than printing beside it, and the call is a property access, so a class-based sink does not lose its receiver.PermissionSeedOutcomegains an optionalcollisionsarray, one diagnostic per dropped set. Absent, never[], when the pass hit none.permission-set-name-collision.tsand are exported from the package entry, which is the shape the decision(objectql): anavigationContributions[].groupthat names no group in the target app is silently RELOCATED to the top level — refuse, warn, or leave to the consumer? #14553navigationContributionsprecedent established.fixnames both legal resolutions.event, notcode: it is a snake_case data value, never routed toerror.code, the same discrimination the siblingposition_name_fold_grantalready makes in this package. Measured consequence —check:dispatcher-error-vocabularypasses with no ledger row owed in another package.🔴 Stopped at a lane fence — the compile-time door
Only the runtime door ships here. The card's precedent raises its diagnostic at both doors, and the compile-time half —
os build/os validate— lives inpackages/cli(domain:cli), another lane. Nothing was pushed for it.That is a real delivery rather than a half-measure: the card's complaint is that the author never learns, and an author-visible runtime diagnostic answers it. The exports above exist so the cli-lane card consumes this predicate and this wording instead of re-deriving either.
⛔
packages/specwas never touched — verified, not assumed: the diff is 9 files, none underpackages/spec.Clause-② — re-derived from the delivered diff
The claim comment predicted
no. Re-measured against the built entry (exports["."].types=./dist/index.d.ts), it is yes: seven new exported symbols are reachable, and an already-published payload gained a key.dist/index.d.tsPERMISSION_SET_NAME_COLLISIONpermissionSetNameIsForeignpermissionSetNameCollisionDiagnosticformatPermissionSetNameCollisionDiagnosticreportPermissionSetNameCollisionsPermissionSetNameCollisionDiagnostic(type)CollisionReportSink(type)bootstrapDeclaredPermissionsSHIPPED_PLATFORM_BUCKET_NAMESdefaultLookupThe negative controls are module-private, so a zero discriminates. Plus
PermissionSeedOutcome.collisions?— a new key on an already-published payload. ⇒ changeset graded minor, andneeds:contract-reviewis hung on both carriers.Checks
All at
6a5958acd, after merging currentmainand rebuilding.pnpm --filter @objectstack/plugin-security test— 112 files, 2157 tests, 0 failurespnpm --filter @objectstack/plugin-security typecheck— clean (both legs joined with&&)scripts/pm/dispatch-gates.mjs— all exit 0check:dispatcher-error-vocabulary,check:error-code-casing,check:optional-error-sink,check:durability-log-level,check:logger-receiver-detach,check:startup-registry-verdict,check:nul-bytes— all exit 0check:type-check-coverageexit 0;check:type-check-debtfirst answered exit 3 (PREREQUISITE NOT MET) — the full workspace closure was built as the gate names and it re-ran to a real exit 0 ("5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number")check:engine-double-contractnamed the new test fake'supdate()as a loose double; it now opens withassertEngineUpdateDispatch, and the pinned ledger was regenerated with the repo's own--write— 799 rows, 1 added or grown, 0 lost, byte-identical across two consecutive writes, gate then exit 0Out of scope, noted not filed here
bootstrap-declared-capabilities.tscarries the identical doubly-optional shape at its ownskippedForeignbranch — a declared capability is dropped just as silently. It is a separate defect on a different axis with its own record type and outcome key, so it is not ridden in on this card; it is reported to the PM with its evidence.Generated by Claude Code