Skip to content

fix(runtime): name the ADR-0025 reading of manifest.permissions when AppPlugin's security registrar cannot use it - #18039

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-18034-appplugin-silent-permissions-drop
Sep 13, 2026
Merged

fix(runtime): name the ADR-0025 reading of manifest.permissions when AppPlugin's security registrar cannot use it#18039
hotlong merged 2 commits into
mainfrom
claude/issue-18034-appplugin-silent-permissions-drop

Conversation

@hotlong

@hotlong hotlong commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #18034

Clause-②: no

AppPlugin's ADR-0057 security-metadata registrar flattens the manifest under the stack's own collections — { ...manifest, ...collections } — so manifest.permissions is read whenever the stack declares no permissions collection of its own. At the authoring stage that key is the ADR-0025 §3.2 capability grant a package requests (a flat list of permission strings, or { services, hooks, network, fs }); the registrar wants ADR-0090 PermissionSet[]. Both arms were skipped with nothing logged — the structured arm is an object, so Array.isArray was false and the value never entered the loop; every member of the flat list carries no name, so !item?.name dropped all of them.

What changed

One file of runtime behaviour: packages/runtime/src/app-plugin.ts.

The loop now measures what it lost and says so, once per boot, naming the field, how many entries were dropped, both readings of the key, and where permission sets belong. Example, on the legacy arm:

[AppPlugin] `manifest.permissions` reached the stack-declared `permission` registrar, which cannot read it: it dropped 2 of 2 entries, because a `permission` is identified by its `name` and those entries carry none. Nothing is lost if an ADR-0025 §3.2 capability GRANT was meant — `manifest.permissions` is the manifest-stage grant a package requests (a flat list of permission strings, or `{ services, hooks, network, fs }`), and this registrar only reads ADR-0090 permission sets. But if permission sets were meant, none is registered, no audience-binding suggestion is offered, and the boot goes on looking healthy. Declare the collection at the stack's own top level — `defineStack({ permissions: [ … ] })` — not on the manifest.

  • The loop is NOT made tolerant of the grant reading. Which items register is byte-for-byte unchanged; nothing accepts a second spelling of the key. Widening it was rejected by name (ArtifactPackageEntrySchema's body half cannot describe the payload the load path actually registers #14242 road C, maintainer 2026-09-02).
  • warn, not error — nothing here claimed to persist anything, so this is a functional degradation: a permission set is simply not registered and the next person to look for it finds out (AGENTS.md → Degradation log levels).
  • Once per boot, with no dedup machinery: start() runs once per app per kernel, so the report sits in the loop that already runs once. (The sibling repair needed a WeakMap because its pass also runs on every list call; this one does not.)
  • Which SOURCE the value came from is decided on the RAW inputs, not on the flattened copy: the manifest contributed the key only when the stack's own collections do not declare it. Deliberately not read off the converted bundle, because the ADR-0087 pass can rewrite a COLLECTION KEY (rolespositions) and a key the conversion produced came from the stack.

The card asked for one measurement first — here it is, and it changes the scope

The card closed with "whether [the other four fields] can be reached by a colliding manifest key is unmeasured here and is the first thing to check." Measured against ManifestSchema (packages/spec/src/kernel/manifest.zod.ts), on origin/main at a0dd872c1, by parsing a probe manifest per field:

SECURITY_FIELDS entry on ManifestSchema probe verdict can collide
permissions declared, ManifestPermissionsSchema string[] accepted · { services, … } accepted YES
capabilities declared, but a retiredKey() tombstone invalid_type@capabilities (expected never) no
positions not declared unrecognized_keys no
sharingRules not declared unrecognized_keys no

ManifestSchema is a strictObject, so the two undeclared keys are not merely absent — they are refused, which is a stronger reading than "absent" and is why no repair is owed on that side. capabilities is the opposite of this defect: the tombstone types the key never for tsc and raises its own prescription at parse. Only permissions is a real collision.

The report is still written per field rather than for permissions alone — the bundle reaching this block is not necessarily one that passed an authoring parse (AppPlugin's constructor does not parse), and this loop is the last reader before the value is gone. Cost is one branch; the non-permissions text says what it can honestly say.

Two corrections to the card's premise, both measured

Neither kills the finding — the silence is real and the repair stands — but the scenario the card gives as its motivation does not exist, and the one that does is worse.

1. manifest: { permissions: [{ name, isDefault }] } is not a silent drop in either direction. The card describes the trap as "an author who writes permission sets on the manifest … gets no set registered". Measured:

  • through defineStack() at its default strictness, that shape is REFUSED at authoring: defineStack validation failed (1 issue): ✗ manifest.permissions: Invalid input. It never reaches this loop.
  • through a non-strict or hand-built bundle it REGISTERS: the entries carry name, so the loop reads them. Pinned as a discrimination case below — the guard must not turn a working shape into a warning.

2. The reachable trap is the one the card did not name, and it passes the strict parse. manifest: { permissions: ['sales_rep'] } — an author naming a permission set — is schema-VALID, because a flat string[] is the ADR-0025 legacy arm. It reaches the loop, every member is dropped, and nothing is logged. That is the shape this PR makes audible, and it is the one an AI author writing "permissions" on the manifest actually produces.

Verification

⚠️ Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the commands below were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.

Each verdict is read from the line the gate itself prints, never from a bare exit code.

command verdict
pnpm --filter @objectstack/runtime test 263 files / 3640 tests passed
pnpm --filter @objectstack/runtime build exit 0 — check-dts-emitted: 2/2 declared declaration file(s) present
pnpm --filter @objectstack/runtime typecheck exit 0 — includes check:test-typecheck (27 files / 191 errors / 69 pinned signatures held, unchanged)
pnpm --filter '@objectstack/runtime^...' build exit 0 — dependency closure, built before anything was judged

Gate families derived from the real change set rather than guessed: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack reports 60 commands from 3 paths at 7049b4ccf. All 60 were run, plus the 5 artifact-roster families the tool marks ⛔ because their roster sits in a directory one of these paths is in (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census). 63 green.

Two are NOT MEASURED, not failures — both exit 3, PREREQUISITE NOT MET, and both say so in their own words:

  • pnpm check:dual-build-cjs-loads"this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured" (37 packages unbuilt in this worktree).
  • pnpm check:type-check-debt"--re-measure cannot run: 1 workspace dependency … has no built type entry point on disk". CI builds the whole closure and owns both.

Among the green: check:stack-collection-maps (8 enumerations reconciled against 31 declared collections — the gate that pins this very SECURITY_FIELDS list), check:route-ledger-census, check:nul-bytes, check:registry-log-declared, check:cross-package-test-inputs, check:test-source-alias, check:empty-changeset --base origin/main, check:adr-0087-registration --base origin/main, check:changeset-no-major --base origin/main.

Lint, narrowed and proved narrow. Repo-wide pnpm lint is CI's run. The narrowing here is a measurement, with all three pieces: ① the population is read from eslint's own config, which declares no parserOptions.project and no typed @typescript-eslint rules (eslint.config.mjs); ② --format json counts 2 files linted, 0 errors, 0 warnings on the two changed sources; ③ because type-aware linting is not enabled, this diff cannot move the verdict on any file it does not contain. Run at 7049b4ccf.

Ablation — the new guard can fail, and it discriminates

Run from the committed state. Mutation proved on disk by marker count and blob hash, never by an editor's exit code; the mutating shell carried trap '… git checkout HEAD -- <abs path>' EXIT INT TERM.

  1. Baseline. HEAD blob for packages/runtime/src/app-plugin.ts = 37fce89a82c9d776adecc012d4a7b0cb6d942603, equal to the on-disk hash. Marker registrar, which cannot read it present in source.
  2. Mutation. A splice script removed the whole 23-line report block (it refuses loudly if either anchor is missing, or if the block it captured does not carry the marker). Marker count in source 0; disk hash moved to fe3500f7835cecd6d1def60127dfecce176cfdd9; git diff --stat = 23 deletions.
  3. Rebuilt @objectstack/runtime, then node scripts/ablation-dist-preflight.mjs @objectstack/runtime 'registrar, which cannot read it' --absent✓ dist/: marker absent from all 4 built files. (The suite imports the subject by relative path, so it resolves source, not dist — the dist leg was run anyway rather than argued away.)
  4. Predicted direction: red. Observed: red. 3 failed | 4 passed (7) — and the split is the point. The three failures are exactly the cases that assert a line exists. The four that assert silence stayed green: a stack declaring its own permissions collection, a manifest with no such key, a manifest whose entries the registrar really can read, and the securityMetadataRegistrar: 'artifact-door' composition. A guard that fired on every boot would have kept those green too only by never being asserted — they are asserted, so this is discrimination, not noise.
  5. Restore leg. git checkout HEAD -- <abs path> (⛔ never a bare git checkout --), then: marker back to 1, disk hash back to 37fce89a… byte-identical to the HEAD blob, and a whole-tree git status --porcelain printing nothing. dist/ rebuilt from the restored source and the preflight re-run in its positive direction — ✓ dist/: marker present in 2 built files · ✓ tree: working tree clean against HEAD — so nothing of the ablation survives in the artifact. Suite back to 7 passed (7).

An incidental reading from the mutation leg: with the report gone, tsc refuses the DTS build — TS6133: 'fromManifest' is declared but its value is never read and the same for 'members'. The guard is load-bearing for the type check too, so it cannot rot into a dead branch unnoticed.

Clause ②

no. The PATH limb does not hit: the diff is packages/runtime/src/app-plugin.ts, one new test file beside it, and a changeset. Nothing under packages/spec/src/** moved — verified on the actual diff, not on the plan. The CONTENT limb does not hit either: no accept set is relaxed, no published surface widens (the new code is inside a private method body), and no schema, key or arm is touched.

Acceptance notes

  • No governed surface is in the file list (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), so this is an ordinary merge-queue candidate once CI is green.
  • Noted, not filed — the same loop's !item?.name line also drops a nameless entry arriving from the stack's own collection, where PermissionSetSchema requires name and a strict defineStack() already refuses it. That is a different origin with a different remedy (and the producer is already loud), so no card: the guard here is gated on manifest origin precisely to keep the two apart. Carrier: none — the shape is unreachable through an authoring parse.
  • Noted, not filedpackages/runtime/src/app-plugin.ts's flatten exists to support a bundle whose collections sit under manifest:, which is why the narrower repair ("stop reading the manifest for these four fields") was rejected here: it would silently un-register that shape, replacing one silence with another. Recorded because a future reader will consider the same shortcut. Carrier: this PR's diff comment.
  • The sibling half of the collision is permissions 一个键承载两个互不兼容的语义,且共用 registry 同一存储槽 —— ADR-0025 授权 vs ADR-0090 权限集集合 #18031, repaired in a separate PR on plugin-security and spec. Neither PR depends on the other; permissions 一个键承载两个互不兼容的语义,且共用 registry 同一存储槽 —— ADR-0025 授权 vs ADR-0090 权限集集合 #18031 is not addressed here.

CI on this PR — the one red is not this diff

Lint & Repo Gates, Type Check (all four legs), Build Core, Dogfood Regression Gate (3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL) and five of six Test Core shards are green: 30 pass · 2 fail · 3 skipping, the two failures being Test Core (3/6) and the Test Core rollup that reports it.

Test Core (3/6) fails in packages/cli/test/format-zod-union.test.ts, and it is #18032's signature — the standing queue-flake anchor for that exact file, already dispatched to another seat. Excluded from this diff by measurement, not by argument:

  • this branch's diff against its base a0dd872c1 is exactly three paths — packages/runtime/src/app-plugin.ts, one test file beside it, and a changeset. Nothing under packages/spec/** or packages/cli/**.
  • the failing assertion is the file's schema-level control, which calls ObjectStackDefinitionSchema.safeParse directly and spawns no CLI. Reproduced locally against @objectstack/spec built from a0dd872c1 with nothing modified: the specimen yields 2 issues (invalid_value on views.0.list.sort.0.order, unrecognized_keys for direction) where the test asserts toHaveLength(1).

So it is an assertion, deterministic and off-queue — not a timeout and not a queue interaction. The full reproduction, the schema reading behind it (order: z.enum(['asc','desc']) carries neither .optional() nor .default()) and the two open dispositions are recorded on #18032 as evidence rather than as a duplicate card; #18032 is not addressed here.

⛔ Not armed for auto-merge while that shard is red — a red PR does not queue, it hides.


Generated by Claude Code

…7 security registrar cannot read

`AppPlugin`'s security-metadata registrar flattens the manifest under the
stack's own collections, so `manifest.permissions` is read whenever the
stack declares no `permissions` collection of its own. That key is the
ADR-0025 §3.2 capability grant a package requests; the registrar wants
ADR-0090 `PermissionSet[]`. Both arms were skipped with nothing logged —
the structured arm is not an array, and every member of the legacy flat
list carries no `name`.

Skipping stays the outcome: the registrar is NOT made tolerant of the
grant reading, and which items register is byte-for-byte unchanged. What
changes is that the drop is audible — once per boot, naming the field,
how many entries were lost, both readings of the key, and where
permission sets belong. It stays silent on every shape where nothing was
lost.

Claude-Session: https://claude.ai/code/session_680c1c4e-4867-4565-b594-b5d2662e3e65
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 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 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 25 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 60b99552a2f88ee8b06f28f8bec3caa952e60f3cpackageMentionDocs.

@hotlong
hotlong marked this pull request as ready for review September 13, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant