You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(plugin-security, spec): name the ADR-0025 reading of manifest.permissions when the audience-binding reconciler cannot use it, and make both halves of the key point at each other (#18035)
Fixes#18031
Clause-②: yes
`permissions` carries two incompatible readings and
`SchemaRegistry.installPackage` records both in one slot. At the
AUTHORING stage `ManifestSchema.permissions` is the ADR-0025 §3.2
capability grant a plugin requests — the legacy flat `string[]`, or the
structured `{ services, hooks, network, fs }` block. At the ASSEMBLED
stage the collection wins and the same key is the ADR-0090
`PermissionSet[]` (`AssembledPackageBodySchema`, ADR-0130 D4). A package
declares one of them and the other has nowhere to go.
## What changed
**1. The drop is loud (`@objectstack/plugin-security`).**
`collectDeclaredSuggestions` source 2 wants the assembled reading.
Handed the authoring one it returned an empty list and logged nothing:
the structured arm is an object, so
`Array.isArray(manifest.permissions)` was false and the value never
entered the loop; every member of the legacy arm is a bare string, so
`consider`'s `typeof ps !== 'object'` line dropped all of them. No
`sys_audience_binding_suggestion` row, no admin prompt, no log — the
"absence must be loud" rule in AGENTS.md → Route & surface ownership §3.
It now warns, naming which arm it found, how many members were dropped,
what is lost if the author meant permission sets (nobody is ever
prompted to bind the set, and the deployment goes on looking healthy),
and where the sets belong: the package's own `defineStack({ permissions:
[ … ] })`.
- `warn`, not `error`, deliberately: nothing here claims to have
persisted anything, so this is a functional degradation — a prompt that
is not offered. Same reading, one step weaker, as the write-refusal
report beside it, and the same sink (`SuggestionDeps['logger']`, which
declares no `error`; adding one would enrol the published type into
`check:optional-error-sink`'s population, a contract call above this
repair).
- Once per engine per package+arm, through a `WeakMap` keyed on the
engine. The pass runs at boot, after every package-door `permission`
publish **and on every list call**, while a manifest's shape is fixed
for as long as that package is installed — an undeduplicated line would
repeat on every console page load and be skimmed past, which is the same
silence with extra steps. Keyed on the engine rather than a module `Set`
so a process hosting several tenant kernels reports once per kernel.
- A partially readable array still registers its readable sets and still
names the dropped members.
**2. The two halves of the key name each other (`@objectstack/spec`,
declaration text only).** `ManifestSchema.permissions` says it describes
the AUTHORING stage and names the assembled counterpart; the stack
`permissions` collection names the manifest-stage grant;
`InstalledPackageSchema.manifest` says it is a STAGE rather than
"whatever was stored", pointing at `AssembledInstalledPackageSchema` /
`InstalledPackageAtEitherStageSchema` for the stage a `defineStack()`
host installs. No key, arm, export or accept set moved.
## Scope item 1 of the card was NOT done, and the evidence says it
should not be
The card suggested making `InstalledPackageSchema.manifest` "tell the
truth (or declare itself a superset)", and flagged that `ManifestSchema`
"has no `objects` / `datasources` keys". Two corrections, both measured
on `origin/main` at `1e20f816e`:
1. **`ManifestSchema` declares both keys.** `objects` and `datasources`
are `z.array(z.string()).optional()` — glob patterns. So the mismatch is
of TYPE, not of presence, and it is bigger than the card says:
`ManifestSchema` is `strictObject`, so a real assembled record is
refused twice over — `invalid_type` on those two keys and
`unrecognized_keys` on `apps` / `objectExtensions` / the collections.
2. **The work is already landed, under a maintainer ruling that rejects
the suggested road by name.** #14242 recorded three roads and the
maintainer took **B** on 2026-09-02 — «declare the assembled stage
rather than widen the authoring one» — quoted at
`AssembledPackageBodySchema` in `packages/spec/src/stack.zod.ts`. Road
**C** (widen a manifest key into a union of both spellings) was REJECTED
by name: a union at the key makes neither stage checkable, the
tolerate-at-the-consumer shape Prime Directive #12 refuses. The
assembled stage therefore already has `AssembledInstalledPackageSchema`,
and the read doors already serve `InstalledPackageAtEitherStageSchema`,
a union of two whole closed stages
(`packages/spec/src/api/package-api.zod.ts`).
⇒ Widening `InstalledPackageSchema.manifest` would reverse a recorded
decision — Prime Directive #13 territory, needing an ADR, not a
changeset. What was genuinely missing is the one thing this PR adds
instead: a reader standing at `package-registry.zod.ts` had no pointer
to any of it. The same goes for scope item 3: the assembled side already
carries a stage table naming `permissions` beside `objects` and
`datasources`, so the missing half was the BACK-pointers from
`ManifestSchema.permissions` and from the stack collection, which is
what landed.
## 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 every command below was 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.
Named gates, each read from its own verdict line, not from a bare exit
code:
| command | verdict |
|---|---|
| `pnpm --filter @objectstack/spec build` | exit 0 |
| `pnpm --filter @objectstack/spec test` | 476 files / 13563 tests
passed |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 — includes
`check:test-typecheck` (54 files / 259 errors / 144 pinned signatures
held, unchanged) |
| `pnpm --filter @objectstack/spec check:generated` | 15 artifacts;
`check:docs` was the only stale one, regenerated by `--fix`
(`gen:docs`), re-run clean. `check:migration-registry`,
`check:upgrade-guide`, `check:spec-changes`, `check:authorable-surface`,
`check:api-surface`, `check:liveness` all green with no regeneration
owed |
| `pnpm --filter @objectstack/plugin-security build` | exit 0 |
| `pnpm --filter @objectstack/plugin-security test` | 111 files / 2154
tests passed |
| `pnpm --filter @objectstack/plugin-security typecheck` | exit 0 —
includes `check:test-typecheck` (0 files / 0 errors) |
| `pnpm check:nul-bytes` | OK — 8604 text files, no raw control bytes |
| `pnpm check:pm-widening-tells` | self-test 269 cases pass |
| `pnpm check:durability-log-level` | 35 durability-critical seams all
loud; 68 read seams, none inventing an answer |
| `pnpm check:optional-error-sink` | every sink declaring an optional
`error` guarantees a `warn` |
| `pnpm check:stack-collection-maps` | 8 enumerations reconciled against
31 declared collections |
| `pnpm check:startup-registry-verdict` | 43 seams, none recording a
verdict the boot can contradict |
| `node scripts/check-empty-changeset.mjs --base origin/main` | exit 0 |
| `node scripts/check-adr-0087-registration.mjs --base origin/main` |
exit 0 — no declared-breaking changeset |
| `node scripts/check-changeset-no-major.mjs --base origin/main` | exit
0 — no `major`; the level axis is PR-scoped and has no input on a local
run |
Repo-wide sweeps (`pnpm lint` first among them) are CI's run, not this
seat's; `node scripts/pm/dispatch-gates.mjs --commands` derives 112
commands from this change set and the farm owns the rest.
### Ablation — the new guard can fail
Run from the committed state, mutation proved on disk, restore proved by
hash.
1. `HEAD` blob for `suggested-audience-bindings.ts` =
`2e6a770cd56f678cabd3fee3876d370631c32610`; marker
`reportDroppedManifestPermissions(ql, logger, dropped);` present **1×**
in source.
2. Mutation deleted that one call. Marker count in source **0** — the
mutation landed; `perl -0pi` exit status was not read as evidence.
3. Rebuilt `@objectstack/plugin-security`, then `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security '…'
--absent` → `✓ dist/: marker absent from all 4 built files`. (The suite
imports the subject by relative path, so its resolution is source, not
`dist` — the dist leg is run anyway rather than argued away.)
4. **Predicted direction: red. Observed: red.** `4 failed | 29 passed
(33)` — the four warn-asserting cases. The three that assert *no* noise
(readable shape, no logger, steady state) stayed green, which is what
distinguishes a discriminating guard from one that fires on everything.
5. Restore leg `git checkout HEAD -- …` (never a bare `git checkout
--`), then: marker back to 1, `git status --porcelain` empty across the
WHOLE tree, `git hash-object` = the HEAD blob above, byte-identical.
`dist/` rebuilt from the restored source and the preflight re-run in its
positive direction — `✓ marker present in 2 built files`, so nothing of
the ablation survives in the artifact.
An incidental reading from the mutation leg: with the call gone, `tsc`
refuses the DTS build with `TS6133: 'logger' is declared but its value
is never read` — the guard is load-bearing for the type check too.
## Clause ②, both limbs stated separately
- **PATH limb HITS** — the diff moves `packages/spec/src/**`.
`needs:contract-review` is on both carriers (this PR and the card) and
the card waits outside the queue until the in-seat review clears it.
- **CONTENT limb does not hit**, and the `yes` above is the conservative
direction rather than a claim that it does. Nothing relaxes an accept
set: `ManifestSchema` stays `strictObject`, both arms of
`ManifestPermissionsSchema` are untouched,
`InstalledPackageSchema.manifest` stays `ManifestSchema`. Nothing widens
a published surface: `collectDeclaredSuggestions` gains an optional
third parameter but is module-exported only — it is not re-exported from
`plugin-security/src/index.ts`, so it is not on the package's published
API. The spec half is `describe` / TSDoc text.
- The level rule is honoured on the package that actually gained
something: `@objectstack/plugin-security` is graded `minor` for a new
operator-visible diagnostic that did not exist; `@objectstack/spec` is
`patch` for declaration text.
## Acceptance notes
- **Filed as #18034** — `AppPlugin`'s `SECURITY_FIELDS` loop
(`packages/runtime/src/app-plugin.ts`) has the identical silent drop at
both arms, on the same key, and is the half that runs at every boot for
every stack. Out of scope here: a different package, a different
verification surface, and its remedy needs the other four fields in that
loop measured first.
- **Noted, not filed** — `packages/spec/liveness/manifest.json` grades
`permissions` `live` on the strength of this very reader, with the note
"it reads the LEGACY arm". It *iterates* that arm and reads no value out
of it, which the tests added here now pin. #11333 and #13458 already own
that question and its retirement route, so the measurement went on
#11333 as evidence rather than into a duplicate card;
`liveness/manifest.json` is deliberately untouched by this PR, because
moving a liveness `status` is the spec seat's verdict, not a rider on a
reader-side repair. Carrier: #11333.
- No governed surface is touched (`docs/adr/**`, `.claude/**`,
`skills/**`, `AGENTS.md`, `CLAUDE.md` — none in the file list), so this
is an ordinary queue candidate once the contract review clears.
---
_Generated by [Claude
Code](https://claude.ai/code/session_680c1c4e-4867-4565-b594-b5d2662e3e65)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031).
7
+
8
+
`permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it.
9
+
10
+
-**`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries.
11
+
-**`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`).
12
+
-**Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past.
13
+
-**The spec half is declaration text only — no key, export, arm or accept-set moved.**`ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs.
14
+
- ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change.
|**permissions**|`{ name: string; label?: string; description?: string; packageId?: string; … }[]`| optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) |
76
76
|**objects**|`{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]`| optional | Business Objects definition (owned by this package) |
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**permissions**|`string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }`| optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) |
257
257
|**objects**|`string[]`| optional | Glob patterns for ObjectQL schemas files |
258
258
|**datasources**|`string[]`| optional | Glob patterns for Datasource definitions |
|**permissions**|`{ name: string; label?: string; description?: string; packageId?: string; … }[]`| optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) |
320
320
|**objects**|`{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]`| optional | Business Objects definition (owned by this package) |
|**permissions**|`string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }`| optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) |
485
485
|**objects**|`string[]`| optional | Glob patterns for ObjectQL schemas files |
486
486
|**datasources**|`string[]`| optional | Glob patterns for Datasource definitions |
|**permissions**|`string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }`| optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) |
609
609
|**objects**|`string[]`| optional | Glob patterns for ObjectQL schemas files |
610
610
|**datasources**|`string[]`| optional | Glob patterns for Datasource definitions |
|**permissions**|`string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }`| optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) |
695
695
|**objects**|`string[]`| optional | Glob patterns for ObjectQL schemas files |
696
696
|**datasources**|`string[]`| optional | Glob patterns for Datasource definitions |
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**permissions**|`string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }`| optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) |
1895
1895
|**objects**|`string[]`| optional | Glob patterns for ObjectQL schemas files |
1896
1896
|**datasources**|`string[]`| optional | Glob patterns for Datasource definitions |
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**manifest**|`{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }`| ✅ |Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`|
|**permissions**|`string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }`| optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) |
39
39
|**objects**|`string[]`| optional | Glob patterns for ObjectQL schemas files |
40
40
|**datasources**|`string[]`| optional | Glob patterns for Datasource definitions |
0 commit comments