Skip to content

feat(spec)!: retire the flat string-list arm of manifest.permissions — the structured ADR-0025 block is the only form (#13458) - #22094

Merged
os-warren merged 5 commits into
mainfrom
claude/issue-13458-retire-manifest-permissions-legacy-arm
Oct 8, 2026
Merged

os-warren merged 5 commits into
mainfrom
claude/issue-13458-retire-manifest-permissions-legacy-arm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #13458

Clause-②: no (narrowing)

Phase 2 of the plugin-permissions ruling (option A, maintainer 「同意」, 2026-08-30, as recorded in #13458's body): the legacy flat string[] arm of ManifestPermissionsSchema is retired by the standard retirement route (ADR-0049 enforce-or-remove, an ADR-0087 conversion, the spec-property-retirement playbook). The structured ADR-0025 §3.2 block { services, hooks, network, fs } is now the only form a package manifest's permissions takes. Triage's corrections (6018679144) were applied as binding: there was no legacy reader to migrate. The two drop reports are reworded, and the liveness row is re-verdicted.

What changes

  • Schema (packages/spec/src/kernel/manifest.zod.ts). ManifestPermissionsSchema is now PluginPermissionsSchema, by identity. Both export names stay, and the api-surface is unchanged. The block answers a list on its own error map: Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list., followed by the prescription. This is the bare-array pattern ListViewExportOptionsSchema already uses, built from the same exported strictObjectError + closedObject, with the prime handle forwarded. The block also carries EnvironmentArtifactSchema.grantedPermissions values, so the answer is worded true on both carriers. Its os migrate meta sentence uses the house two-clause form, which names the case the conversion covers. The manifest's YAML example and the key's TSDoc and .describe() lose the list.
  • ADR-0087 D2 manifest-permissions-string-list-removed (conversions/registry.ts, MAJOR_18_CONVERSIONS, order 61, inserted where its identifier sorts and defined above the entry that follows it). It is retired from the authoring load path, with retiredAfter: '17.7.0'. It deletes an all-string list from the stack's manifest and every packages[].manifest, and the notice carries the dropped strings. It never touches the structured block, an array of objects, the top-level ADR-0090 collection, or devPlugins[], whose entries may be live plugin objects.
  • ADR-0087 D3 manifest-permissions-string-list-retired (migrations/entries/semantic/, generated into the registry) and its STEP18_RATIONALE fragment (order 85).
  • Liveness (packages/spec/liveness/manifest.json). permissions stays live, on corrected evidence, and its four keys are drilled. See the census below. The README.md manifest row gains one sentence, and state-counts/manifest.md was regenerated (live 23 → 26).
  • The two drop reports, reworded only. packages/runtime/src/app-plugin.ts and packages/plugins/plugin-security/src/suggested-audience-bindings.ts now name the flat list as the RETIRED legacy form, refused at parse. Classification, dedupe, log level and the arm labels in the meta are unchanged, so their existing tests pass untouched. No other file in those two packages moved.
  • Docs. content/docs/protocol/kernel/plugin-spec.mdx said the key "accepts either the legacy flat string[] or" the block. That sentence was made false here and is corrected. The two stack.zod.ts docblocks that described the list are corrected too. content/docs/references/** was regenerated.
  • Generated. authorable-surface/kernel.json gains kernel/ManifestPermissions:{fs,hooks,network,services}, because the identity alias now emits the block under its own def key. authorable-surface.base.json was not touched.

Measurements behind the premise (origin/main e67ba80049)

H1, reader census: no in-repo consumer reads the legacy arm. CONFIRMED. The census covered every spelling of a manifest.permissions / manifest?.permissions / manifest[...] read, manifestSource reads, and every ManifestPermissions* / PluginPermissions* importer. In-repo it finds:

Zero consumers act on the list. Control: the same instrument finds the two drop reports and the assembled-stage read, so the zero is about the key's readers and not about the pattern. os plugin build only parses the manifest. os plugin publish uploads it whole as plugin_manifest.

objectui pin a58626c88dc8: 0 reads of manifest.permissions, against 91 manifest.(id|name|version) reads as the control. Its only reader of the structured block is the marketplace install disclosure (PluginDisclosure), which reads services / hooks / network / fs and shows "Requests no special permissions." when none is present. So the retired list was disclosed to an installer as requesting nothing.

H2, conversion vs disposition: chose a STRIP conversion, registered. A capability string such as system.user.read names no service, hook, host or path, and no table maps one onto the four lists. So no conversion can carry the author's intent, and inventing grants in a consent request would be worse than dropping them. The list had zero readers, so the delete changes no load, grant or refusal: lossless for every runtime outcome. The conversion still has to exist, because a built artifact that carries a list would otherwise be refused at the artifact door. The judgement the delete cannot make is the D3 entry's.

Authored census, by heuristic text scan with a lit control (it finds the five fixtures below): no manifest in examples/, apps/, packages/, skills/ or content/docs/ writes a list. The exceptions are five @objectstack/spec manifest.test.ts fixtures, re-triaged here: one re-spelled to the block, three translated, and one deleted because an app ships no code that needs a grant. The other exception is the drop-report tests in runtime and plugin-security, which hand a list to an unparsed bundle on purpose and still pass. docs/design/marketplace-publishing.md shows a list at the top level of defineStack, which is already the permission-set collection. It is noted below and was not edited.

H3, the structured arm's accept set did not move. CONFIRMED. The error map only rewords the type error a non-object already raised. Pinned in manifest-permissions-string-list.test.ts: every declared key alone, the full block and {} parse, and an unknown key and a non-string member are still refused. The manifest-unknown-keys.test.ts door tests keep their exact rename and alias content. Only the envelope moved: the issue is now the block's own unrecognized_keys at ['permissions'], with no invalid_union around it. Load-time enforcement of the block (it still refuses nothing) is not this card's and is unchanged.

Liveness re-verdict. The row's old evidence (collectDeclaredSuggestions reading the legacy arm) was the misattribution #18031 corrected. The new evidence is the objectui install disclosure, with producer os plugin publish. The cloud hop, publish to the version row the disclosure reads, is NOT MEASURED: add_repo on cloud was denied from this seat. The note asks a cloud-capable seat to re-read and pin it. The re-verdict is live, not dead, because authoring the block changes what an installer is shown. It is not enforced at load, and the note says so.

Changeset grade

main has no .changeset/pre.json: absent at e67ba80049, and re-read absent at origin/main 3d9188502e just before opening. Changesets pre mode is not in, so per triage's release-state note (6038073833) this ships minor with its BREAKING banner and the ADR-0087 disposition registered manifest-permissions-string-list-removed, manifest-permissions-string-list-retired. @objectstack/runtime and @objectstack/plugin-security carry patch for the reworded log text (same fixed group). The prescription names the bare published major, "removed in @objectstack/spec 17". That is what check:future-spec-major requires of an unshipped pre-GA retirement, which ships minor in the current major. A first draft said 18.0.0, and that gate refused it.

Verification

All commands run at head 55481fe9ae, unless a line says otherwise. Exit codes were captured before any pipe.

  • Builds: pnpm --filter @objectstack/spec build (JS + DTS) → 0. The dependency closures of runtime and plugin-security (--filter 'PKG^...', 31 packages) → 0. --filter '@objectstack/client-react...' → 0 (prerequisite for check:skill-examples).
  • pnpm --filter @objectstack/spec check:generated → 0. All 15 generated artifacts are up to date against a dist built from this head (declaration stamp match). That covers check:liveness, check:api-surface (exports unchanged), check:authorable-surface and check:docs.
  • @objectstack/spec tests: the full --project local run → 622 files / 18536 passed (at 71735b5bc0; the later commit changes only the prescription's version word and its pin). Targeted at the final head: src/kernel/ src/conversions/ src/migrations/ plus the migrate-sentence pin → 69 files / 1828 passed. --project repo scripts/step18-rationale-merge.test.ts scripts/conversions-major18-merge.test.ts → 2 files / 21 passed. The new manifest-permissions-string-list.test.ts → 17/17.
  • pnpm --filter @objectstack/spec typecheck (tsc + scripts + test layer) → 0.
  • Consumer packages: @objectstack/plugin-security vitest → 169 files / 3640 passed. @objectstack/runtime vitest --project local → 332 files / 4693 passed. typecheck of both (tsc + check:test-typecheck) → 0, at the final head.
  • Reverse verification (one-shot, no file left): a probe in packages/runtime/src assigned a string list to ManifestPermissions and to ObjectStackManifest['permissions'], plus a structured block as a control. tsc -p packages/runtime against the rebuilt spec .d.ts → exit 2, exactly two TS2559 errors on the two list lines, the block line clean. The probe was removed and git status read clean.
  • Gate families: node scripts/pm/dispatch-gates.mjs --commands derived 121 families, re-derived identically after the last commit. 117 → 0. check:future-spec-major → 1 at first (the 18.0.0 wording above), fixed, re-run → 0. check:skill-examples → 3 (prerequisite) at first, re-run after building client-react → 0, with 262 prose examples type-checked. The changeset gates (check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, each with --self-test) → 0. --ran reconciliation: 119 of 121 run.
  • NOT MEASURED: check:i18n, reason: its prerequisite is the CLI plus the build closure of every package whose extract config it runs (exit 3), and this diff touches no metadata form or translated label. check:dual-build-cjs-loads, reason: it needs every package's dist/ (37+ missing, exit 3). Both are left to CI.
  • Narrowed lint: eslint --no-inline-config --format json on the 10 changed .ts files → 10 files linted, 0 errors, 0 warnings. That is the whole changed-TS population, every one inside eslint's own file globs, with none ignored. eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move the verdict of an untouched file. The repo-wide pnpm lint is CI's.
  • Not re-merged with main: the branch is at base e67ba80049. main gained 3d9188502e and d4680d2820, which touch no file in this diff (their generated shards are other categories). The PR's merge ref and the queue validate the joint state.

Acceptance notes

  • ManifestPermissionsSchema survives as an identity alias of PluginPermissionsSchema. Deleting the export would be a second breaking removal. It would also regenerate skills/objectstack-platform/references/_index.md, which is Tier H and outside this card. The cost is four kernel/ManifestPermissions:* authorable-surface rows that duplicate kernel/PluginPermissions:*. Retiring the name is a separate decision.
  • The liveness producer hop is NOT MEASURED. That hop is the cloud control plane's projection of a published manifest's permissions onto the marketplace version row. Re-reading it needs a cloud-capable seat, and the row's note says so.
  • Stale comments in two test files outside the declared surface, not edited: packages/runtime/src/app-plugin.manifest-security-collision.test.ts. Its header table still describes manifest.permissions as the legacy flat string[] or the structured block, and a comment says defineStack refuses an object array with invalid_union; it is now invalid_type with the list answer. The tests still pass, and the dispatch fenced every other file in those packages. Carrier: none.
  • docs/design/marketplace-publishing.md (a design doc, not published) shows permissions: ['system.object.create', …] at the top level of defineStack, which was already the permission-set collection before this change, and a §7.4 reviewer snippet in the same flat form. It was not made false here and was not edited. Carrier: none.
  • Cross-repo: cloud mirrors these schemas for publish validation. When cloud next takes @objectstack/spec, a legacy-list plugin manifest is refused at its publish gate. A stored version row that holds a list is cloud's own data and was not measured from here.

Generated by Claude Code

claude added 4 commits October 7, 2026 13:10
…— the structured ADR-0025 block is the only form

WIP: schema narrowing with the list-form prescription, the ADR-0087 D2
conversion manifest-permissions-string-list-removed, its D3 semantic entry
and step-18 rationale fragment, the liveness re-verdict, and the two drop
reports reworded. Generated artifacts and tests follow.

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…ge the fixtures; regenerate references, authorable surface and liveness counts

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…st.permissions list arm

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…carries the retirement (check:future-spec-major)

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-security, @objectstack/runtime, @objectstack/spec, touching 27 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/README.md, packages/spec/liveness/manifest.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))
  • content/docs/automation/flows.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))
  • content/docs/getting-started/quick-reference.mdx (via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx (via ManifestSchema (symbol, a top-level const object), PluginPermissionsSchema (symbol, a top-level const object), api.acme.com (literal, a string literal in network), record.beforeInsert (literal, a string literal in hooks))
  • content/docs/protocol/kernel/http-protocol.mdx (via api.acme.com (literal, a string literal in network))
  • content/docs/protocol/kernel/plugin-spec.mdx (via ManifestSchema (symbol, a top-level const object), PluginPermissionsSchema (symbol, a top-level const object), api.acme.com (literal, a string literal in network), record.beforeInsert (literal, a string literal in hooks))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via api.acme.com (literal, a string literal in network))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via ManifestSchema (symbol, a top-level const object))
  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))
  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object manifestPermissionsStringListRemoved), retiredFromLoadPath (symbol, a field of const object manifestPermissionsStringListRemoved))
  • content/docs/releases/v17/17-6.mdx (via invalid_type (literal, a string literal in PluginPermissionsSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/README.md, packages/spec/liveness/manifest.json, …) — pages documenting those are invisible to this run
  • 16 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 147 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 033e5c536db11e278182f321f0ae99cf10f4f630 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7b66a97d8b523a1d302b1d98be2467e2cad5c161 — the merge of head 1c3772561365e214d521f67cd2faa25caa897507 into base 033e5c536db11e278182f321f0ae99cf10f4f630, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7b66a97d8b523a1d302b1d98be2467e2cad5c161 && git checkout 7b66a97d8b523a1d302b1d98be2467e2cad5c161
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 033e5c536db11e278182f321f0ae99cf10f4f630 1c3772561365e214d521f67cd2faa25caa897507 && git checkout -B drift-repro 033e5c536db11e278182f321f0ae99cf10f4f630 && git merge --no-ff 1c3772561365e214d521f67cd2faa25caa897507

node scripts/docs-audit/affected-docs.mjs --json 033e5c536db11e278182f321f0ae99cf10f4f630

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 033e5c536db11e278182f321f0ae99cf10f4f630 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 55481fe9aee82057028968f482e2580657edb9af
Local-runs: none

Read at 2026-10-07T15:22Z by an isolated contract-review subagent, adopted by the dispatching seat. Inputs: card #13458 (body and all eleven comments; triage re-verification 6018679144 and unlock 6038073833 taken as binding, claim 6038163242 as the scope, dev report 6040754553 as the flag source), PR #22094 (body, 22-file list, net diff against main at this head), the head's 32 check-runs, and main at 3d9188502e plus the head, read through git show on fetched refs. The objectui pin a58626c88dc8 was read through git show as well, because the local objectui working tree sits at c910630c, not at the pin.

Check-runs as read. 18 completed: Auto Label, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check · source gates, filter — all success; Console Pin Gate and Packed-tarball smoke skipped. 14 in_progress: Build Core, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Lint & Repo Gates, Temporal Conformance, Test Core 1–6, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace. Mapped to the gates this diff exercises: Check Changeset runs check-changeset-no-major.mjs and check-adr-0087-registration.mjs against the merge base — both answered success; Type Check · source gates runs check:authorable-surface, check:docs, check:spec-changes, check:upgrade-guide, check:template-manifests and check:future-spec-major — all answered success; still open are check:migration-registry (Lint & Repo Gates), check:api-surface, check:skill-examples, check:yaml-examples (Type Check · workspace), and the test shards. Landing waits on those as it always does; this record judges the contract.

① Derived judgments

Accept set — one narrowing and nothing else moved: right. ManifestPermissionsSchema was z.union([z.array(z.string()), PluginPermissionsSchema]) and is now PluginPermissionsSchema by identity. The block is rebuilt as closedObject(z.object(PLUGIN_PERMISSIONS_SHAPE, { error }).strict()), where error returns the list answer only when issue.code === 'invalid_type' and Array.isArray(issue.input), and otherwise the strictObjectError(...) map, with prime forwarded. On main, strictObject(options, shape) is literally closedObject(z.object(shape, { error: strictObjectError(options, shape) }).strict()) (shared/strict-object.ts), so the shape, the .strict() posture, the ZodClosedObject terminal-refusal constructor and the declarationStore() registration the alias-integrity audit reads are the same four parts in the same order; the only new behaviour is a branch taken after an invalid_type issue already exists. That is the ListViewExportOptionsSchema pattern byte for byte (ui/list-view-export-options.ts:117-126). Every value the structured block accepted still parses (each key alone, the full block, {}, absent) and every refusal it made still fires (unknown key, non-string member) — pinned in the new manifest-permissions-string-list.test.ts and in the rewritten manifest-unknown-keys.test.ts door test, whose rename and alias CONTENT is unchanged and whose envelope moved from a keyless invalid_union to the block's own unrecognized_keys at ['permissions']. H3 of the dispatch: confirmed on the source.

Carriers of the new refusal sentence — true on each: right. PluginPermissionsSchema has exactly two carriers on main: ManifestSchema.permissions (kernel/manifest.zod.ts) and EnvironmentArtifactSchema.grantedPermissions (system/environment-artifact.zod.ts:182, z.record(z.string(), PluginPermissionsSchema)). On the manifest carrier the sentence names the retired legacy form and the conversion; on the granted-permissions carrier a list was never legal, and the sentence's second clause — "a granted-permission record is not a source it reads" — is true, because the conversion strips only manifest.permissions and packages[].manifest.permissions. A scalar wrong type keeps zod's own message, so "was removed" never misinforms an author who wrote a string. The enforcer path (core/security/plugin-permission-enforcer.ts, runtime/security/artifact-granted-permissions.ts) imports only the PluginPermissions TYPE for the granted set and never reads the manifest; unchanged.

Load paths that parse ManifestSchema: right, with one reading added. Outside tests main has two: cli/commands/plugin/build.ts:111 (os plugin build parses objectstack.plugin.json) — the authoring funnel, meant to meet the refusal under retiredFromLoadPath: true; and objectql/registry.ts:3735 (validate('plugin')), a spec-conformance DIAGNOSTIC behind registerItem('plugin', …) that registers anyway and logs [metadata_spec_invalid], whose only entry point registry.registerPlugin has no production caller. So no load path refuses a stored manifest without the conversion in front of it. The stored-row seam (conversions/stored.ts) has no plugin or package collection and never carries a manifest; the artifact door (metadata-core/artifact-forward-conversion.ts:445) replays the whole definition with includeRetired: true, and a definition's manifests sit at definition.manifest and definition.packages[].manifest — exactly the two places the conversion walks.

Reader census: right. The claim's bound — no consumer migration owed, stop if one exists at the head — holds. The dev's H1 is confirmed from main: the two drop reports read the ASSEMBLED PermissionSet[] and only report an authoring-stage value as skipped; the CLI collision test reads the assembled stage; nothing else reads manifest.permissions. At the objectui pin: zero reads of manifest.permissions; PluginDisclosure.tsx reads version.permissions ?? {} and renders services, hooks, network, fs under "This package requests:", with "Requests no special permissions." when none is present — so the retired list disclosed nothing to an installer, as the changeset says. The control (manifest.(id|name|version) reads at the pin) is lit.

The two reworded drop reports: right. suggested-audience-bindings.ts: classification, dedupe, log level and the adr-0025-legacy-strings / adr-0025-structured labels are untouched; the new sentence — a list "is a row whose bundle never passed that parse" — is true, because registerApp hands the whole bundle to installPackage unparsed (#18031, recorded in 5653445129). app-plugin.ts: "a flat list of permission strings is its retired legacy form, refused at parse and never read at load" — true on both halves.

The D2 conversion manifest-permissions-string-list-removed: right. Reach: stack.manifest and every stack.packages[].manifest; only a list whose every member is a string (the retired arm's exact accept set, the empty list included); an array of objects is left for the schema to refuse; the structured block and the top-level ADR-0090 permissions collection are never touched; devPlugins[] is not walked — no conversion on main walks devPlugins or packages[] today, and devPlugins is the CLI dev command's input, which meets the refusal as an author. Lossless claim: true for every runtime outcome (zero readers, so no load, grant or refusal changes); lossy for author intent, which the D2 does not claim to carry — the notice carries the dropped strings (from: permissions: [...], to: (removed), the dotted path), and the D3 entry carries the judgement. Idempotent by construction (a manifest without the key returns by reference), copy-on-write. retiredFromLoadPath: true with retiredAfter: '17.7.0' — the package label on main and the released head of packages/spec/CHANGELOG.md, which is what conversions/types.ts and playbook §3 require. toMajor: 18, id ends -removed, expectedNotices: 2 equals the stripped lists in the fixture. Entry order: 61 is the next free integer on main (max 60), inserted where its identifier sorts (between listViewSortStringClauseToArray and mappingLookupParamsRemoved) and defined above the entry that follows it.

The step-18 chain: right. D3 manifest-permissions-string-list-retired lives in entries/semantic/18.manifest-permissions-string-list-retired.ts and is generated into the step-18 region between manifest-id-reverse-domain-required and manifest-version-semver-2-0-0 (sorted); surface carries no backticks; conversionIds names the D2; the step's own conversionIds stays derived from CONVERSIONS_BY_MAJOR[18]. The STEP18_RATIONALE fragment sits between list-view-tabs-retired and mapping-lookup-params-retired with order: 85, the next free integer on main (max 84). A second order: 85 from PR #21974 fails no pin and no gate on main: step18-rationale-merge.test.ts pins sorted, unique, kebab-case IDS, positive integer orders and a render by (order, id) — it asserts nothing about unique orders, and its own fixture branches state "both in flight take the same next order, as two PRs cut from one base do" and assert a clean merge with both insertions present; main already carries seven duplicate orders in STEP18_RATIONALE (56, 60, 62, 66, 67, 74, 77) and two in MAJOR_18_CONVERSIONS (55, 57). Two order-85 fragments render adjacent, ordered by id. What the later lander must change in order: nothing. What it must do: if the two ids sort adjacently in either list, the textual merge conflicts at the same anchor (the generator's own stated limit), and the only resolution is gen:migration-registry / check:generated --fix on the merged tree, never a hand resolution (AGENTS §11).

Public surface: right. Both export names stay; api-surface/kernel.json keeps ManifestPermissionsSchema (const) and the signatures snapshot does not record this const, so neither moves — the playbook's "a value narrowing is invisible to the four ratchets" case. authorable-surface/kernel.json gains four kernel/ManifestPermissions:* rows because the identity alias now emits the block under its own def key; authorable-surface.base.json is the deletion gate's anchor, written only by --update-base, so added rows do not touch it. json-schema.manifest/ records def existence and the def still exists. check:authorable-surface and check:docs are success on this head.

Liveness: right. manifest.permissions re-verdicted live on corrected evidence (the objectui disclosure at the .objectui-sha pin, which equals the cited @a58626c88dc8), producer cli/commands/plugin/publish.ts#PluginPublish (exists), four children drilled, README per-type row and state-counts/manifest.md moved together (live 23 → 26, classified 39 → 42). The not-enforced-at-load half is stated in the row. Spec property liveness is success on this head.

Residual prose. A sweep of the head tree for legacy-flat-list prose on permissions outside the 22 changed files finds only requiredPermissions rows (ADR-0066, a different key). No example, app, skill or docs manifest writes a list.

GitHub content. No model identifier in the PR title or body, the four commit messages (the AGENTS model-free trailer pair), the changeset or the diff.

② Semver level

Clause-②: no (narrowing) — right. scripts/pm/clause2-line.mjs, the fleet's one reader, states that the value answers one question — does this card widen the accept set or expand the public surface — and that a diff narrowing a published accept set "answers it no truthfully", the (narrowing) arm being what makes the gate read it as breaking. The card body's older 「条款②预判:内容肢命中」 was written under the pre-#19061 mechanism (the retired needs:contract-review pairing); the line as declared on the PR and inside the changeset is the current grammar, and check-adr-0087-registration read the arm there and passed.

Changeset — right for the main this head was cut against. .changeset/13458-manifest-permissions-string-list-retired.md: @objectstack/spec: minor with the BREAKING banner, a FROM → TO table, the one-line fix, the migrate sentence, and the ADR-0087 disposition registered manifest-permissions-string-list-removed, manifest-permissions-string-list-retired (both ids resolve on the head; the marker is the HTML-comment form the gate reads); @objectstack/runtime and @objectstack/plugin-security at patch for the reworded text — all three are in the one fixed group, so the group bumps together. .changeset/pre.json is ABSENT on main at 3d9188502e and git grep ': major' .changeset/ is 0, so check-changeset-no-major.mjs (the launch-window guard, which stands aside only when pre.json reads mode pre) refuses major; minor plus banner plus disposition is the only gate-conformant grade and is what triage 6038073833 prescribes before the opening. Check Changeset is success.

If this PR lands before PR #22084: every sentence holds as written. The grade is right; the prescription "removed in @objectstack/spec 17" and plugin-spec.mdx's "retired in @objectstack/spec 17" are what check:future-spec-major demands (it refuses any @objectstack/spec major above the one packages/spec/package.json publishes, 17, on ADR-0087's level ruling that a minor retirement lands in 17.x.y), and that gate is success on this head; retiredAfter: '17.7.0' is a fact.

If this PR lands after PR #22084 (pre.json in mode pre with a major marker): the no-major guard stands aside and triage's rule flips — "after the opening, major". The later lander then re-grades @objectstack/spec to major in this changeset (the fixed group reaches 18.0.0-pre.N either way through #22084's own marker, so the version outcome is identical; the grade is the record). The "17" wording stays gate-correct until changeset version lifts package.json to 18.0.0-rc, at which point check:future-spec-major goes dormant on it (17 is then historical, below the current major) and the sentence is false under ruling B (v18 develops on main with no last 17.x, so the first carrier is 18.0.0). That is a window-wide consequence of the convention, not this PR's choice: the opener of pre mode owes the sweep of every "removed in @objectstack/spec 17" written after the v18 unlock, this PR's two sites among them (PLUGIN_PERMISSIONS_LIST_FORM in manifest.zod.ts, and plugin-spec.mdx).

③ Boundary flags

open_questions: none declared. The dev's five deviations and four out-of-scope findings, plus one finding of this review, each answered or escalated:

  1. Files beyond the claim's listed surface — owed. stack.zod.ts (two docblocks made false; AGENTS truthfulness and playbook §4 schema comments), the D3 entry and the generated migrations/registry.ts (playbook §3: one D3 semantic entry per retirement family, even when the D2 is lossless), liveness/README.md plus state-counts/manifest.md (§4: the README per-type row moves with its counts), authorable-surface/kernel.json and content/docs/references/** (generated), the new pin test (§4), plugin-spec.mdx (§4 Docs; a published sentence made false). The two cross-lane packages carry only the two declared files, per the file list. Answered.
  2. Liveness children drilled — owed. Once the slot became a plain object the walk sees a container and check:liveness requires the rows; the gate is success. Answered.
  3. Not re-merged with main before opening (AGENTS §10) — declared and measured. The 14 files main moved since e67ba80049 overlap none of the 22 in this PR, and the head is mergeable. But packages/spec DID move on main (automation/io-node-config.zod.ts and its reference page), so §10's "if packages/spec moved on either side, rebuild and check:generated" applies at landing: Lint & Repo Gates on the merge ref and the queue's rebuilt generation are the instruments, and the lander follows §11 if a generated shard conflicts. Answered — a landing condition the gates carry, not a defect in the diff.
  4. Cloud leg NOT MEASURED (add_repo denied) — escalated. The row's note, the PR body and the changeset all say so loudly, and triage's binding re-verification scoped the work to in-repo consumers plus the artifact conversion, so the PR is complete on its own terms. Owed to a cloud-capable seat, not to this PR: (a) re-read and pin the producer hop (publish → marketplace version row → disclosure), as the row asks; (b) before cloud takes a @objectstack/spec carrying this retirement, census cloud-held plugin_manifest rows for a flat list, since cloud's publish gate will refuse one and a stored version row is cloud's own data with no conversion seam in this repo.
  5. Attribution trailer — answered. The commits carry the AGENTS model-free pair and the PR body the session-URL footer; verified on the four commits.
  6. ManifestPermissionsSchema survives as an identity alias — right. Deleting the export would be a second breaking removal (an api-surface/kernel.json row) and would regenerate a Tier H skill reference; the four duplicate authorable rows are the stated cost. Noted, no card.
  7. Stale comments in runtime/src/app-plugin.manifest-security-collision.test.ts — escalated, not blocking. Confirmed on the head: line 21 still describes "the legacy flat string[], or the structured", line 170 still says invalid_union on manifest.permissions. The dispatch fenced every other runtime file, so stopping was correct. A comment-only follow-up for the domain:cli lane (declare on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024 or file a docs-debt card); the tests pass and no author reads a test comment as a contract.
  8. docs/design/marketplace-publishing.md — answered. Not made false here: its top-level permissions is the ADR-0090 collection. Noted.
  9. Prescription reach on the objectstack.plugin.json carrier (this review's finding) — noted. os plugin build parses that file directly, while os migrate meta --from 17 replays over the stack loadConfig finds, so for a standalone plugin manifest the tool lists nothing and the author acts on the sentence's own imperative ("translate each one by hand, or delete permissions"). The sentence does not claim to read that file, its shape is the pinned house form, and every manifest-key tombstone shares the limit. No change asked of this PR.

Implemented-by: claude/issue-13458-retire-manifest-permissions-legacy-arm
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #22094 at 55481fe9ae (the legacy string[] arm of manifest.permissions retires; the structured ADR-0025 block is the only form). It lands through the queue now; Fixes #13458 closes the card at the merge

domain:spec seat 2 · os-warren · session session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-07T15:40Z · holder of claim 6038163242; the review of record for the report 6040754553.

Checklist (read on GitHub and on origin/main, not from the report):

  • Form: draft, base main, first line Fixes #13458, and no other closing keyword in the body. Clause-②: no (narrowing) starts a line of the body and of the changeset. PR assignee os-warren. No model identifier in the title, the body or the four commits.
  • Scope: 22 files. The two cross-lane packages carry only the two declared drop reports (runtime/src/app-plugin.ts, plugin-security/src/suggested-audience-bindings.ts), reworded only; the declarations are 6038311314 ([PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024) and 6038321237 ([PM seat] domain:services · seat 2 — 🟢 os-warren #21118). Everything else is in packages/spec, its generated content/docs/references/**, one published sentence in content/docs/protocol/kernel/plugin-spec.mdx made false by the change, and one changeset. The contract review read each file beyond the claim's listed surface as owed by the retirement playbook (record ③1).
  • What the diff does, as graded:
    • It narrows: ManifestPermissionsSchema is PluginPermissionsSchema by identity, and a list is refused with a prescription. Nothing the structured block accepted or refused moves (H3, pinned).
    • The D2 conversion manifest-permissions-string-list-removed (toMajor: 18, retiredFromLoadPath) strips an all-string list from the stack manifest and every packages[].manifest, and its notice carries the dropped strings. The D3 entry manifest-permissions-string-list-retired carries the judgement.
    • The reader census (H1) found zero readers of the legacy arm, with a lit control, in this repo and at the objectui pin.
  • Contract review: PASS at CONTRACT_REVIEW_TIER, record 6041005463, on this head (Local-runs: none, identity pair present).

Landing conditions the record names, carried here:

Checks on 55481fe9ae: 32 success and 2 skipped. check-expected-skips reads both as on the roster (Console Pin Gate, Packed-tarball smoke (opt-in)). Lint & Repo Gates (with check:migration-registry) and every Type Check · job are success on this head. check-governed-merges --pr 22094: not governed, 864 changed lines. git merge-tree onto origin/main b04a5295f7: clean, and .changeset/pre.json is still absent there.

Acceptance notes (not filed; no reach):

  • carrier: a cloud-capable seat · The producer hop (publish → marketplace version row → objectui disclosure) was not measured, because this seat cannot read objectstack-ai/cloud. Before cloud takes a @objectstack/spec that carries this retirement, it owes a census of its stored plugin_manifest rows for a flat list (record ③4).
  • carrier: the next edit of the file · packages/runtime/src/app-plugin.manifest-security-collision.test.ts:21, :170 keep two comments this change made stale. The dispatch fenced that file; the tests pass. The pointer goes to domain:cli on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024 at landing.

Landing: the relay's pr_ready + automerge_enable follows this comment. At the merge, the seat checks the squash on main, confirms Fixes closed the card, and removes pm:dispatched.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 15:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 15:41
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge October 7, 2026 17:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 17:43
This was referenced Oct 7, 2026
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 2 of #11333: retire the legacy string[] arm of manifest.permissions (major, standard retirement route)

2 participants