feat(runtime): bind an artifact's install-time granted permissions to its plugins at load - #17137
Conversation
…er plugin at load
Wire `EnvironmentArtifactSchema.grantedPermissions` into
`PluginPermissionEnforcer.registerGrantedPermissions` at materialize time —
the consumer half the artifact contract names, and the key-to-plugin binding
that did not exist before: one `AppPlugin` covers a whole artifact, so nothing
in the load path could say which package a grant entry belonged to.
Absent, `{}` and a consented entry stay three distinct states, both directions.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
…ugin-permission-load-gate
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
… unwrap
The `{ schemaVersion, metadata }` unwrap hands the kernel `metadata` alone and
drops every key beside it, so the install-time consented set — which the
artifact contract puts BESIDE `metadata` — never reached the loader that the
contract names as its consumer. Silent, and indistinguishable from the
legitimate "no consent record" reading.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 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 bf8111b87a5a706f51a0f6fa134d9169e5bad1b8 && git checkout bf8111b87a5a706f51a0f6fa134d9169e5bad1b8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 702614108578b56e948bb8cd2a3efa646f4876c2 24e357903dcd840af524be2dbef8fcbee7244839 && git checkout -B drift-repro 702614108578b56e948bb8cd2a3efa646f4876c2 && git merge --no-ff 24e357903dcd840af524be2dbef8fcbee7244839
node scripts/docs-audit/affected-docs.mjs --json 702614108578b56e948bb8cd2a3efa646f4876c2
|
Contract review at
|
…ugin-permission-load-gate
Contract review (clause ②) — PASS WITH FINDINGS, one blocking · head
|
…orbidden `?? {}`
Contract-review rework on #13457. Behaviour is unchanged in every leg; what
moves is naming, one redundant default, and two test assertions that could not
tell the doors apart.
- `ArtifactGrantBinding.gated` / `ungated` -> `registered` / `unregistered`,
and the `[AppPlugin]` log line with them. The old names claimed a gate that
does not exist: this round REGISTERS a consent set and nothing on this tree
queries the registry, which the type's own docblock already said
("registered on the enforcer"). The loader comment "the gate had nothing to
enforce" presupposed the same absent gate.
- Delete `.grantedPermissions ?? {}` in `registerArtifactGrantedPermissions`.
It was a semantic no-op, but it is the exact spelling `app-plugin.ts` forbids
by name on this key. Replaced by the narrowing that states the fact the walk
already established: `registered` is non-empty only on the plain-record
branch, so the loop reads the carrier's own record or iterates nothing.
- Correct the "closed by two doors" claim. `{ id: '', name: 'x' }` passes BOTH
doors, because `artifactPackageId` is `id || name`; the fixture set id and
name to `''` together and hid the fallback. The escaping case is now pinned,
including its fail-OPEN reading through the enforcer's own readback: the
unattributable `''` key binds to nothing, is reported `unbound`, and denies
nothing. No refusal is added -- that fork is #17148.
- Tighten both door tests. Each asserted `/no usable package id|not a package
entry/`, so either passed on either door and neither pinned which fired.
Both doors raise the same ADR-0112 code and status, so each test now pins the
shared envelope plus the message unique to its own door, and asserts the
other door's message is absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ugin-permission-load-gate
Standing down at the gate — contract review cannot run at tier right now · head
|
Contract review at
|
| # | Was | Status on 24e357903 |
|---|---|---|
| F1 | blocking | DISCHARGED. Body line 1 Part of #13457; closing-keyword regex over the whole body: 0 hits; closed_by_pull_requests = {total_count: 0} (MCP issue_read get — the surface that carries the key); check Part-of PR must not also close its card success on head. Card re-cut to Phase 1a (5602329414). |
| F2 | non-blocking | OPEN, untouched. artifact-granted-permissions.ts:152-158 returns declared:true with empty lists; warn only at :210-220 when unbound.length > 0; app-plugin.ts:408-417 still allocates an enforcer and logs info with three empty arrays for []/"x"/42; test :162-166 asserts only declared/registered. Dev report names carrier "review comment + #17147" — #17147 body and its comment do not mention it; the seat's review comment is the only record. |
| F3 | non-blocking | OPEN, untouched. content/docs/protocol/kernel/plugin-spec.mdx:616 and :640-651 byte-identical (main didn't move the page either); no docs file in the PR; not carried on #17147 (5602723353 lists plugin-permission-enforcer.ts:94-97 and manifest.zod.ts only). |
| F4 | non-blocking | OPEN, untouched. Changeset sentence "on every path … without either caller changing a line" unchanged; the same unmeasured cloud claim also sits in the PR body's landing-site paragraph and three shipped docblocks (artifact-granted-permissions.ts:14-17, security/index.ts:48-53, app-plugin.granted-permissions.test.ts:7-11); body says NOT MEASURED only for the no-packages[] residual. Package/level right. |
| F5 | non-blocking | PARTLY. (a) still no malformed-entry-value pin (grep null/services: '*'/malformed across the three test files: 0); fail-closed re-verified on head — inList plugin-permission-enforcer.ts:491-492 is Array.isArray(list) && …, so a non-array value denies. (b) flat-artifact test load-artifact-bundle.granted-permissions.test.ts:63-69 unchanged. Beyond F5: refusal tests now assert code + status (ADR-0112), which they did not at ef3455756. |
| F6 | non-blocking | MOSTLY DISCHARGED. Seam card #17147 exists (13:05:13Z) carrying the bare-id query note (scope §3) and the warn-vs-refuse fork; dev report 5602250159 (13:00:46Z) now on the card with exit codes. Residual: the "enforcer is per-AppPlugin instance, not a kernel service — the seam needs a handle" note (app-plugin.ts:151, :209) is on no card. |
| Seat ⑥ blocking | blocking | DISCHARGED (= F1). |
| Seat non-blocking | — | Taken: ?? {}, rename, ⑤ correction (body + tests), door regex. Recorded on #17147 (5602723353): the three pre-existing load-enforcement sentences. Still undeclared in body/changeset: ① empty-env sorter throw and ordering ahead of assertProtocolCompat (app-plugin.ts:325 vs :371); ② key grafted into a strictObject metadata and carried on via servicePayload → manifest.register (app-plugin.ts:381-385); ② cloud reachability unmarked (= F4). Body probes for strictObject / registerApp / empty-env: all absent. |
Clause-② reading
yes, unchanged. Mechanical floor on this head: 4 root-barrel exports packages/runtime/src/index.ts:143-146 (via security/index.ts:54-59); 2 public AppPlugin accessors app-plugin.ts:209, :219; loader carry load-artifact-bundle.ts:107-110. The delta moves none of them — the rename is on fields of a type that is new in this PR (artifact-granted-permissions.ts is a wholly new file vs merge-base), so no released surface moved and minor stands; changeset names the type and the three functions, never a field.
node scripts/pm/check-clause2-carriers.mjs --pair 17137→ exit 0 — run twice: the working-tree copy (this checkout is on a non-main branch and its copy differs fromorigin/mainby 143 lines) and theorigin/mainblob60b7562c, extracted to the scratchpad; both: "readable in the fixed spelling and both carriers agree".check-widening-tells --declaration yes --diff <merge-base diff>(origin/main version) → exit 0.
Security reading re-checked on head: registration only — registerGrantedPermissions( production caller: only artifact-granted-permissions.ts:206; new SecurePluginContext production sites: 0 (control: 3 in tests); .permissionEnforcer/.grantBinding production readers: only the enforcer's own private field (plugin-permission-enforcer.ts:416/422/438). Fail-closed on {}/malformed: buildPermissionsFromGrants :504-519 + inList :491-492; null/undefined → deny-all bag :102-103; checkPermission denies unregistered (:270). Absent key → no enforcer: app-plugin.ts:405-407 (=== undefined), artifact-granted-permissions.ts:151. No path consults the old source: sys_package_installation has no read in packages/runtime/src or core/src/security (docblock :94 only); manifest.permissions absent from the three PR source files; legacy-arm reader is packages/plugins/plugin-security/src/suggested-audience-bindings.ts:276 on this head (the director's :252 citation is a stale path/line) and untouched. packages/core/** and packages/spec/** untouched by the PR; core enforcer unchanged since ef3455756.
Governed surface / protocol label
None. 9 files = .changeset/artifact-granted-permissions-load-binding.md + 8 under packages/runtime/src/**. No path under docs/adr/**, .claude/**, skills/**, AGENTS.md, content/docs/releases/; no packages/spec/src/** → no protocol:* owed. @objectstack/runtime 17.4.0, private unset → published; changeset minor present; Check Changeset (level axis) green.
CI on head
39 check runs, all head_sha = 24e357903: 34 success / 5 skipped / 0 red (≈33 distinct names; duplicates are re-runs of the small gates, none red). Required floor: Lint & Repo Gates success 14:45:50Z, TypeScript Type Check success 14:34:27Z. Also green: Type Check · workspace, all 6 Test Core shards, Check Changeset, Governed Surface Queue Guard, Part-of PR must not also close its card. Combined status: Vercel success. Landing pre-checks ② and ③ hold; ① (a tier PASS on record for this head) is the director's call — the seat's own tier PASS is on 8d1e62f3e and its 14:49Z comment (5603852418) records it could not re-run at tier (429) and will not self-certify.
New findings
- F7 — non-blocking, body edit. Acceptance notes still read "the ADR-0025 materialize-seam card, which does not exist yet"; Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147 has existed since 13:05Z, and the body never names it (it names Reading needed from the cloud side: can
ArtifactKernelFactoryserve an artifact that declaresgrantedPermissionsbut carries NOpackagesarray? #17148). A reader of the PR alone cannot find Phase 1b. - F8 — non-blocking, routing. No
needs-user-decisioncard exists for warn-vs-refuse (12 openneeds-user-decisioncards updated today, none is it; issues created ≥12:30Z related to this card: only Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147/Reading needed from the cloud side: canArtifactKernelFactoryserve an artifact that declaresgrantedPermissionsbut carries NOpackagesarray? #17148). The seat ruled Q2 in-seat ("A for this round", act-immediately class) and carries the fork as an undecided section of Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147's body. Defensible — warn is the status quo and nothing was decided — but Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147 and Reading needed from the cloud side: canArtifactKernelFactoryserve an artifact that declaresgrantedPermissionsbut carries NOpackagesarray? #17148 both carry zero labels (ungraded, "triage's write"), so the fork sits outside every queue. The director's F1 prescription was not followed literally: carrier exists, routing does not. - F9 — info.
check-widening-tells --declaration noon this diff also exits 0 ("no widening tell on any declared surface") despite 4 new root exports and 2 public accessors —packages/runtime/src/**is outside the gate's declared surfaces. Theyeshere rests on the mechanical floor reading, not on the gate. Not a PR defect; same class as [finding] check-widening-tells' CLI cannot be told the repo, so its declared objectui mirror row is unreachable — every objectui diff reads clean, including one that DOES add a key to packages/types/src/zod/** #17217.
Acceptance notes
- Implemented-by:
domain:engineseatsession_01XTBcV7zZHmokdyQgXjbyEU(os-sam) dev,mode:subagent, branchclaude/issue-13457-plugin-permission-load-gate— newestClaim:is still 5601277704 (11:43:08Z); noClaim:among the four later comments (12:54Z director pointer, 13:00Z dev report, 13:06Z re-cut, 14:26Z rework report). Rework commit0ec3026f7carries the same session trailer. Reviewed-by: this isolated subagent for the director seatsession_017Js5kTpTtxieBjPyScgxJ3— distinct sessions, not a self-review. Part ofon landing: Phase 1 of #11333: wire granted_permissions into PluginPermissionEnforcer (F4) as the load-time gate #13457 will not auto-close;pm:dispatchedmust be removed by hand at ACCEPT, andneeds:contract-reviewcleared on both carriers by the same seat that records the PASS, then draft → ready. Closing Phase 1 of #11333: wire granted_permissions into PluginPermissionEnforcer (F4) as the load-time gate #13457 by hand is gated on Phase 2 of #11333: retire the legacy string[] arm of manifest.permissions (major, standard retirement route) #13458 re-pointingBlocked-by:at Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147 — not yet done: Phase 2 of #11333: retire the legacy string[] arm of manifest.permissions (major, standard retirement route) #13458's body still readsBlocked-by: #13457and mentions Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147 zero times; thedomain:specseat ([PM seat] domain:spec — 🟢 os-bill · session_01MkQhmuuJAVDjmeWNixwDDH · 1 dev in flight, 2 slots free · §1/§3 current 19:36Z #6017) is recorded vacant (5603454007), and Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147 is ungraded. So after merge, Phase 1 of #11333: wire granted_permissions into PluginPermissionEnforcer (F4) as the load-time gate #13457 stays open by design, and the protection is a seat's restraint plus thePart ofline — not a mechanism.- Recommended same-round-or-follow-up (all non-blocking): F7 one-line body fix; F4 one-sentence changeset hedge ("verified on
createStandaloneStack; cloud depends onArtifactKernelFactorypassing the key through"); F2 warn + test; F3 docs correction can ride with Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147; F6 residual (per-instance handle) and the seat's ①/② undeclared items to be recorded on Phase 1b of #11333: make the registered granted-permission set actually REFUSE — the ADR-0025 materialize seam that gives each plugin its own SecurePluginContext #17147.
Generated by Claude Code
|
Landing provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Part of #13457
Clause-②: yes— this round moves a published behaviour: an environment artifact's install-time consented set now reachesPluginPermissionEnforcer, and an artifact whose envelope carried consent records now delivers them where before it delivered none. Not downgradeable here;needs:contract-reviewcomes off on a contract-tier PASS, not by this PR.Draft, deliberately. One design fork below is a decision, not a patch, and is reported rather than chosen. See "What is NOT delivered".
The landing site, named before anything else
packages/runtime/src/security/artifact-granted-permissions.ts(new)AppPlugin.init()—packages/runtime/src/app-plugin.tspackages/runtime/src/load-artifact-bundle.tspackages/runtime/src/{index,security/index}.tspackages/core/src/security/plugin-permission-enforcer.tswas in the declared surface and is untouched — every symbol the wiring needs (createPluginPermissionEnforcer,registerGrantedPermissions,buildPermissionsFromGrants) was already exported.packages/core/src/security/index.tsis likewise untouched, so the round never depended on the #17101 fence lifting.packages/spec/**is untouched.AppPlugin.init()is the site because it is the single point where an environment artifact becomes a kernel plugin on both paths — the self-hostedcreateStandaloneStackand the control plane'sArtifactKernelFactory, which constructs the same object — so the consent records reach the enforcer without either caller changing a line.The #7500 re-measurement — the reading STILL HOLDS
Measured by
git grepon this branch's own head, with a control that fires (the order's warning was correct:permissionEnforcerreturned 0 for the dispatching seat; on a real checkout it returns 4, all inside the enforcer module — a lower-case symbol that does exist).PluginPermissionEnforcercreatePluginPermissionEnforcerpermissionEnforcer(control, fires)plugin-permission-enforcer.ts—SecurePluginContext's own private fieldSecurePluginContextenforce{ServiceAccess,HookTrigger,NetworkRequest,FileRead,FileWrite}⇒ Zero production callers before this PR, exactly as #7500 read it. The re-export and the two
packages/specfiles the dispatch order flagged are prose and a docblock, not calls. This PR creates the first production caller.Constraint 2 — the key-to-plugin binding, which is the substance
The map is keyed by the plugin manifest
id. OneAppPlugincovers a whole artifact and registers itself under a single kernel plugin name, so nothing in the load path could say which of an artifact's packages a grant entry belonged to. The seam resolves an artifact's carried package ids through the platform's one package sorter (resolveArtifactPackageOrder, ADR-0130 D4/D5) and unwraps each body the same wayAppPlugin's own constructor does, so the ids it registers under are the ids the platform names those packages by — on the flattened shape and onpackages[]alike.Pinned: the map key is
com.acme.crm, never the kernel plugin nameplugin.app.com.acme.crm. Those are different strings and the test asserts both.Absent is not
{}, in both directionsThe walk is driven by the map's own keys, never by the package list. That is the whole guard, and it is one keystroke from its opposite:
registerGrantedPermissions(id, undefined)registers a deny-everything bag, so a package-list-driven loop would come up denying every first-party plugin in the artifact — the boot brick.grantedPermissionskeypermissionEnforcer === undefined;getPluginPermissions(id) === undefined{}grantBinding.declared === true,registered: []{}getPluginPermissions(id)is defined and denies every service, hook, host, path{ services: [...] }warngrantBinding.unboundThe absent case and the
{}-entry case both deny; only one of them is a decision the installer made, andgetPluginPermissionstells them apart. The envelope carry uses!== undefinedfor the same reason.Constraint 1 — which unattributable-consent spellings the doors refuse, and the ONE they do not
The contract has no spelling for "a consent record exists but cannot be attributed": when a manifest carries no top-level string
idthe producer emits it under no name, so to a consumer it is indistinguishable from "no consent record". What the doors actually do, re-measured on this head against the built@objectstack/core:Door 1 is the schema:
ManifestSchema.idis a requiredz.string()— with no.min(1)—AssembledPackageBodySchemaextends it, andObjectStackDefinitionSchema.packagesis an array of that, so a package with no top-levelidis refused at the artifact door. Door 2 isartifactPackageId, which maps the empty string door 1 admits toundefined, andresolveArtifactPackageOrderthen refuses the entry (INVALID_ARTIFACT_PACKAGE_ENTRY).⭐ The case that escapes BOTH.
artifactPackageIdisid || name, notid ?? name. So{ id: '', name: 'x' }clears door 1 (a string is a string) and clears door 2 (the fallback yields'x'), and the package is carried asx. The old fixture setidandnameto''together, which is exactly why the fallback never showed itself. A consent record keyed by the unattributable''therefore DOES reach this seam, where it binds to no package the artifact carries.That residual is fail-OPEN, and stays fail-open in this round. The
''key is reported ongrantBinding.unboundand atwarn, and is registered nowhere; the package loads with no consent record at all, exactly as an artifact that never declared one does. Nothing is silently denied. Whether an unbindable consent record should instead REFUSE the artifact is an open decision carried by #17148 — the correction here is to the measurement and to the pins, ⛔ never to the behaviour.Both doors and the escaping case are pinned separately in
artifact-granted-permissions.test.ts. Each door now asserts the ADR-0112 envelope the two share (INVALID_ARTIFACT_PACKAGE_ENTRY/ 422) plus the message unique to itself, and asserts the other door's message is absent. The previous pair both asserted/no usable package id|not a package entry/, so either test passed on either door: they pinned "refused by some door", never which one fired.packages[]at all takes the sorter's single-package branch, which returns the artifact unvalidated, andartifactPackageIdfalls back toname. Whether the control plane can serve such an envelope with agrantedPermissionskey is a fact aboutArtifactKernelFactory, which is not in this session's read scope: NOT MEASURED. In that residual the seam has no key to act on and behaves as it does for any package the map does not name. The fork this leaves open is in the report and in "What is NOT delivered".What is NOT delivered, and why it is a decision
This PR registers the consented set. It does not intercept access. Every enforcement surface
PluginPermissionEnforcerexposes is reached throughSecurePluginContext— per-plugin context construction, i.e. the ADR-0025 materialize seam, which the 2026-09-01 ruling on this card put out of bounds for either half. So an entry registered here is queried by nothing on this tree yet.Two doors were measured and both are closed to this round:
warn) needs a new error code, and under the [Decision] Clause ② on an UNREGISTERED error code carried by a thrown value: #14552 landedno, #15963 landsyes, and they are the same class #16404 ruling every code that ships indistis registered in the spec'sERROR_CODE_LEDGER— apackages/specedit this round is forbidden to make.⇒ the unbindable case is reported at
warnand recorded ongrantBinding.unbound. Whether it should refuse the artifact instead is the maintainer's call, not this PR's.Of the four permission classes, this round makes none enforceable at access time and all four carried and registered. Also measured while looking for a load-time gate that needed no new vocabulary: the grant's
hooksclass spells hooksrecord.beforeInsert, a string that appears nowhere in this repo outside permission examples — the declarative hook registry spells the same thing{ object, events: ['beforeInsert'] }. Bridging them is an invented mapping, so no hook gate was built.Verification — first round on
8d1e62f3e, rework round on24e357903Exit codes captured by redirect-then-
$?, never across a pipe; gate verdicts quoted from the gate's own line.pnpm --filter '@objectstack/runtime^...' buildVERDICT command-exit 0pnpm --filter @objectstack/runtime run testpnpm --filter @objectstack/runtime run typecheckcheck:test-typecheck: OKnode scripts/pm/dispatch-gates.mjs --commands→ 58 families, all 58 run; reconciled with--ran: "58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED"check:nul-bytes+ a hand scan of all 9 changed paths for control bytesTwo families exited 3, which is each gate's own
PREREQUISITE NOT METcode — "⛔ This is NOT a pass and NOT a finding: nothing was measured."check:type-check-debt— named its two missing dists (@objectstack/hono,@objectstack/runtime); those were built and it was re-run: exit 0, "5 ledger entr(ies) re-measured in 368.0s, 55 raw tsc error(s) total, none above its recorded number."check:dual-build-cjs-loads— needs built output for ~38 packages, i.e. a full-farmpnpm build. NOT MEASURED here; CI owns that run. ⛔ Not reported as green.Ablation — 3 legs, each mutated on disk and restored byte-identical
Every leg: inject → prove it reached disk (anchored
grep -cfor the deleted text AND the injected text, plus a blob hash that moved) → run →git checkout HEAD -- <absolute path>→ prove the restore byte-identical (git hash-objectequals the HEAD blob) → finallygit status --porcelainempty. All undertrap ... EXIT INT TERMwith absolute paths. Control leg run first: 14 passed.{}read as absencethis.bindGrantedPermissions(ctx)removed fromAppPlugin.initif (!grants)) moved the blob hash and left every test green — because{}is truthy, so it was a semantic no-op that ablated nothing. The harness treated that green as a failed measurement and refused it; the anchor was changed to a length test (what a?? {}-shaped consumer degrades into) and re-run. Recorded rather than quietly re-rolled.Rework round — readings on
24e357903Behaviour is unchanged in every leg of this round: what moved is naming, one redundant default, one false claim, and two test assertions that could not tell the doors apart. Exit codes captured by redirect-then-
$?, never across a pipe.pnpm --filter '@objectstack/runtime^...' buildVERDICT command-exit 0pnpm --filter @objectstack/runtime run testpnpm --filter @objectstack/runtime run typecheckcheck:test-typecheck: OK, test layer compilednode scripts/pm/dispatch-gates.mjs --commands→ 58 families; reconciled with--ran: "58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED"check:dual-build-cjs-loadsexited 3 — its ownPREREQUISITE NOT METcode, naming ~38 unbuilt package dists ("⛔ This is NOT a pass: nothing was measured."). NOT MEASURED here; CI owns that run. ⛔ Not reported as green.check:type-check-debtalso exited 3 on its first run (tscOOM-killed at a 4 GB heap, the gate refusing to record a0it could not stand behind); re-run at 8 GB with the two dists it named built, it is exit 0 — "5 ledger entr(ies) re-measured in 60.5s, 55 raw tsc error(s) total, none above its recorded number."Reverse verification that the rename is real and enforced — one leg, mutated on disk and restored byte-identical.
binding.registered→binding.gatedinapp-plugin.ts(on-disk proof: the anchored count went 1 → 0 and the injected spelling 0 → 1; blob hash moved off the HEAD blob), thentsc --noEmit:The mutation was run only AFTER the implementation was committed, so the restore leg (
git checkout HEAD -- <absolute path>, never a baregit checkout --) points at a HEAD that already carries it. Undertrap ... EXIT INT TERMwith absolute paths.The changeset did not move, and that is a measurement, not an omission. The rename touches
ArtifactGrantBinding's fields; the changeset names the type and the three functions, never a field (grep -wforgated/ungated/registered/unregistered: zero hits). And the type is new in this PR —git cat-file -e origin/main:packages/runtime/src/security/artifact-granted-permissions.tsreports it absent on main, withapp-plugin.tsas a control that fires — sogated/ungatednever shipped and the rename moves no released surface.minorstands.Acceptance notes
packages/core/src/security/admission-tenancy-posture.ts(landed via refactor(core): one shared admission tenancy-posture classification, six seams folded onto it #17101 while this round ran) was read on this head: it classifies thetenancyservice's rejection at admission doors and names neither the permission enforcer nor granted permissions. No interaction. noted, not filed.AppPluginregisters underplugin.app.<manifest id>while the artifact contract keys the grant map on the bare<manifest id>. Both spellings are correct for their own surface, and the seam registers under the contract's. Whoever builds the materialize seam has to query with the bare id, not withAppPlugin.name. noted, not filed — the carrier of this is the ADR-0025 materialize-seam card, which does not exist yet.Generated by Claude Code