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 — ⏳ vacant #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 — ⏳ vacant #6024) and 6038321237 ([PM seat] domain:services · seat 2 — 🟢 zhuangjianguo · session_01FzQy26KznSa5j2gnf3AzsE #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 — ⏳ vacant #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
@os-warren
os-warren added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit fec87e7 Oct 8, 2026
36 checks passed
@os-warren
os-warren deleted the claude/issue-13458-retire-manifest-permissions-legacy-arm branch October 8, 2026 03:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…safe master-reference shapes at error on a controlled_by_parent object (objectstack-ai#22109)

Fixes objectstack-ai#9139

Clause-②: no

## What this does

`relationship/master-detail-required` (R2 in
`packages/lint/src/data-model-rules.ts`) now has two tiers under one
rule id, as the maintainer ruling of 2026-08-16 (Direction 1, scheduled
for the v18 boundary) orders.

- **On an object with `sharingModel: 'controlled_by_parent'`**, every
`master_detail` field is refused at `error` in each of the three unsafe
shapes: `required` absent (or `false`); `required: true` + `readonly:
true`; `required: true` + `system: true`. One finding per field, located
at the first defect (`.required`, `.readonly` or `.system`); the `fix`
names every edit the field needs.
- **On every other object** the verdict is unchanged: a `warning` for a
`master_detail` without `required: true`, with the same message and fix,
and silence on the two flagged shapes.

Before this change the predicate was `required !== true` at `warning` on
every object. Two of the three shapes drew no finding at any severity,
as the census note on objectstack-ai#9139 measured. A one-line severity flip would
have closed one shape and left two open, so the predicate covers all
three. Each shape is pinned on its own.

Runtime is untouched, as the card's scope requires:
`resolveCbpRelation`'s fallbacks and the security gate stay as they are.

## The mechanism hypotheses, measured

| # | hypothesis | reading |
|---|---|---|
| H1 | R2 at `data-model-rules.ts:742`, `warning`, `required !== true`,
every object | Holds at `b04a5295f`. Before-probe: CBP and non-CBP alike
gave `warning` for absent / `false`, and nothing for `+readonly` /
`+system`. |
| H2 | the `:817` pin filters to `SECURITY_CBP_NO_RELATION`, so
promoting R2 leaves it green untouched | **Holds.** The pin asserts only
that the CBP no-relation rule (a mirror of `resolveCbpRelation`) is
silent. That stays true, because the runtime still resolves step 2. The
pin is **reversed, not removed**: see the pin section below. |
| H3 | R2 bites `os lint` and the eval rubric only | **Holds.**
`lintDataModel` is called from `lintConfig` (`os lint`, exit 1 on any
error) and from `scoreMetadata` (the generation rubric: `valid` requires
zero errors). It is not an `AUTHORING_RULES` entry:
`authoring-rule-wiring.test.ts` pins `lintDataModel` in
`DIRECT_CALL_RATCHET` as lint-only. So `os build`, `os validate` and the
metadata save door do not run it. `check:i18n-coverage` spawns `os lint`
but tolerates a non-zero exit. Registration was **not** widened; see the
open question in the report. |
| H4 | objectstack-ai#9138 (builder force) landed | **Holds.**
`forceCbpMasterDetailRequired` (`object.zod.ts`) forces an omitted
`required` to `true` under CBP and refuses an explicit `false`. It skips
the array field form and never inspects `readonly` / `system`. So
through `ObjectSchema.create` only shapes 2 and 3 reach the lint, and
both pass the builder untouched. Shape 1 reaches the lint from anything
not built through `create`: a plain object literal, the array form, or
raw parse of stored metadata. Pinned with real `ObjectSchema.create`
objects. |
| H5 | 0 in-tree corpora turn red | **Holds.** Census below. |
| H6 | take the next free order in the step-18 chain | **Falsified as
stated.** There is no order to take: entries are one file each under
`entries/semantic/`, and `gen:migration-registry` sorts the registry by
id. The new entry is `cbp-master-detail-required-lint-error`. Whichever
of this PR, objectstack-ai#22094 and objectstack-ai#21974 lands later merges `main` and re-runs the
generator. Per the entries README's measured table, a driver-less merge
conflicts only when two new ids are adjacent in sort order. Whether that
holds against those two PRs' ids is NOT MEASURED. |

## The step-2 pin: reversed, not removed

`validate-security-posture.test.ts` "stays silent on step 2: ANY
master_detail (not marked required)" pinned the step-2 shape as
supported. The ruling retires that reading as a deliberate contract
narrowing. Under H2 the pin's assertion is about the runtime mirror, and
that half stays true, so the pin now asserts **both halves on one
stack**:

1. the CBP no-relation rule stays silent: `resolveCbpRelation` still
resolves a non-required `master_detail`, and metadata at rest keeps
loading;
2. the same declaration draws `relationship/master-detail-required` at
`error` from `lintDataModel`.

The pin's title and comment state the narrowing. The pin is kept rather
than deleted, so a later change that re-tolerates the shape at authoring
time, or drops the runtime tolerance, turns it red.

## Census (H5), at `b04a5295f` plus this change

Every `*.object.ts` outside tests and fixtures (111 files, 0 import
failures), plus the CLI's golden eval corpus and the multi-package
example's two sub-stacks. Each corpus was linted with its own controls
appended to its own array: one positive control per unsafe shape and a
clean negative control.

| corpus | objects | `controlled_by_parent` | R2 error | R2 warning |
controls |
|---|---:|---:|---:|---:|---|
| examples/app-crm | 6 | 1 | 0 | 0 | 3 reached, neg clean |
| examples/app-showcase | 22 | 2 | 0 | 0 | 3 reached, neg clean |
| examples/app-showcase (external sub-stack) | 2 | 0 | 0 | 0 | 3
reached, neg clean |
| examples/app-todo | 1 | 0 | 0 | 0 | 3 reached, neg clean |
| examples/app-multi-package (core, orders) | 2 | 0 | 0 | 0 | 3 reached,
neg clean |
| packages/cli DEFAULT_METADATA_EVAL_CORPUS | 11 | 4 | 0 | 0 | 3
reached, neg clean |
| packages/platform-objects | 46 | 0 | 0 | 0 | 3 reached, neg clean |
| packages/plugins, services, metadata-core, qa, create-objectstack | 39
| 0 | 0 | 0 | 3 reached, neg clean |
| **total** | **129** | **7** | **0** | **0** | |

No stored in-tree metadata turns red.

## v18 upgrade-checklist line (for the v18 release notes)

> On every object with `sharingModel: 'controlled_by_parent'`, give each
`master_detail` reference `required: true` and remove any `readonly:
true` or `system: true` from it. `os lint` now refuses the
missing-`required`, `required` + `readonly` and `required` + `system`
shapes there at `error` (`relationship/master-detail-required`). An
object authored through `ObjectSchema.create` already gets `required:
true` when the key is omitted, so the edit there is dropping the flag.

The changeset's Remedy section carries this line verbatim. The step-18
semantic entry `cbp-master-detail-required-lint-error` carries the same
prescription for `os migrate meta`. This PR does not touch
`content/docs/releases/**`.

## Release grading

`.changeset/pre.json` is absent on `origin/main`: read at `b04a5295f`
(2026-10-07T15:37Z) and again at `bafb58bb0` before this push. So the
changeset grades `@objectstack/lint` and `@objectstack/spec` at `minor`,
with the BREAKING banner, `Clause-②: no (narrowing)` in its body, and
the ADR-0087 disposition `registered
cbp-master-detail-required-lint-error`.

## Files

- `packages/lint/src/data-model-rules.ts`: R2's CBP tier
(`cbpMasterReferenceFinding`), placed above the first exported rule.
`authoring-rule-wiring.test.ts` reads a rule body as the source text up
to the next `export`, so an error-emitting helper placed below
`lintLegacyOrganizationComposites` was read as that advisory rule
emitting `error`. That was measured red once, then fixed by moving the
helper.
- `packages/lint/src/data-model-rules.master-detail-required.test.ts`
(new): 19 cases. Each unsafe shape at `error` with its path; two clean
controls; one finding for several defects; every `master_detail` field;
the array field form; a lookup ignored; non-CBP unchanged across 4
sharing models plus the two flagged shapes; and real
`ObjectSchema.create` objects.
- `packages/lint/src/validate-security-posture.test.ts`: the step-2 pin,
reversed as described above.
-
`packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts`
and the regenerated `registry.ts`. The entry carries no tracker id and
no call spellings. `spec-changes.json` and the upgrade guide do not
project step 18 yet, and `check:generated` reports all 15 artifacts
current.
- `packages/cli/test/score.test.ts`: fixture triage. The "warning"
fixture of "suggestions cost less than warnings cost less than errors"
was a CBP object, so it now measured the error weight. Probe:
`counts.errors` 1, `valid: false`, with R2 at `error`. Its assertions
still passed, but for a different reason. The fixture moves to
`sharingModel: 'private'` and two assertions pin it to the warning tier.
- `content/docs/protocol/kernel/error-handling.mdx`: this change made
one sentence false ("does not report the `readonly`, `system` … shapes
at all"). It now names `os lint`'s error tier and its reach.
- `.changeset/9139-cbp-master-detail-required-error.md`.

## Verification (final head `bee9c1e25`)

All readings below were taken at `bee9c1e25`, after the last commit.
Each exit code was captured before any pipe.

- **Derived gate families:** `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` gave 113 commands. All 113
ran at `bee9c1e25`: **112 exit 0, 1 exit 3**. The exit 3 is `node
scripts/check-plugin-teardown-shape.mjs --self-test` refusing its own
prerequisite, because its positive control is pinned to commit
`621a4876` outside this shallow clone. That is NOT MEASURED, not a
finding; CI runs it on a full checkout. Reconciled with `--ran`: "113
derived famil(ies) accounted for — 112 run, 1 NOT-MEASURED, 0 UNRUN".
Gate lines quoted from the run:
- `check-adr-0087-registration`: "1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing]
registered cbp-master-detail-required-lint-error (new here: …)"
- `check:migration-registry`: "registry.ts is current (382 semantic, 247
retired-key, 218 retired-def)"
  - `check-issue-citations`: "every citation this change adds resolves"
  - `check:nul-bytes`: "OK … no raw ASCII control bytes"
  - `check:doc-authoring`: "17816 customer-facing string(s) … clean"
- **`@objectstack/lint`:** `pnpm --filter @objectstack/lint test` passed
122 files / 5665 tests. `pnpm --filter @objectstack/lint typecheck` was
green; "check:test-typecheck: OK".
- **`@objectstack/spec`:** `vitest run src/migrations
scripts/build-migration-registry-entry.test.ts
scripts/step18-rationale-merge.test.ts` passed 5 files / 191 tests.
`pnpm --filter @objectstack/spec check:generated` was "All 15 generated
artifacts are up to date". That run was on top of `3f6d9ca97` (the
registry regeneration); `packages/spec` has not changed since, and its
per-artifact gates are in the 113 above.
- **`@objectstack/cli` (consumer that reads R2 findings):** `vitest run
--project unit` passed 259 files / 3795 tests. `pnpm --filter
@objectstack/cli typecheck` was green. The `integration` tier is
declared to CI; `migrate-meta-engine-guidance.test.ts`, which holds
every semantic entry's printed prose, lives there.
- **ESLint, a proven narrowing.** `eslint --no-inline-config --format
json` over the 6 touched `.ts` files counted 6 files, 0 errors and 0
warnings. `--print-config` resolves all 6 inside the config. The `.md` /
`.mdx` files answer "File ignored because no matching configuration was
supplied". The config enables no type-aware linting (`parserOptions` is
`{ ecmaVersion, sourceType }`, with no `project`), so this diff cannot
move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's
run.

**Reverse verification.** With `data-model-rules.ts` reverted to
`b04a5295f` and the tests run from the committed state, **10 failed /
142 passed**. The failures: all 4 CBP shape cases, several-defects,
every-field, array form, the 2 `ObjectSchema.create` refusals, and the
reversed step-2 pin. The controls and every non-CBP case stayed green,
as expected. The file was restored with `git checkout HEAD -- …`, and
the restore was proven by blob hash equal to HEAD's and an empty `git
diff HEAD`.

## Acceptance notes (not changed here)

- `skills/objectstack-data/references/lint-rules.md:13` lists this
rule's severity as `warning`. That is now true only off
`controlled_by_parent`. `skills/**` is a Tier H governed surface outside
this claim's file surface, so it is left for the seat (see the report's
open question).
- `packages/plugins/plugin-security/src/security-plugin.ts`, the
paragraph above the freeze note: "Direction 1 … has NOT landed … nothing
warns on the way past". It says itself that it "goes stale when objectstack-ai#9139
lands". The dispatch fences the runtime package, so it is left for the
seat.
- The `object.zod.ts` docblock of `forceCbpMasterDetailRequired`, and
the sibling entry `cbp-master-detail-required-forced`, say the lint
"stays `warning` until v18". That is still accurate as a schedule, so
neither was edited.
- The census's earlier global red (`showcase_field_zoo.f_master_detail`)
was already fixed on `main` (`required: true`). The global warning count
is now 0.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ubflow node config, with its location (objectstack-ai#22129)

Part of objectstack-ai#21982
Clause-②: no (narrowing)

**Draft, patch round 1 done** (claim revision `6050287134`). This PR
lands the `script` / `subflow` half of the card. The card stays open for
the remainder named below.

## What changes

The build doors now refuse a key that a `script` or `subflow` node's
executor contract does not declare. They refuse it at its location, in
the existing family of flow slot refusal codes.

- **The doors:** `FlowSchema.parse` / `defineFlow()`, `defineStack`,
`objectstack validate`, `objectstack compile`, an artifact's parse, the
metadata save door's `flow` type schema, and `registerFlow`, which
parses `FlowSchema` first.
- **The code:** the existing `node-config-refused-by-contract`, `params:
{ nodeType, key }`. There is one refusal per undeclared key, anchored at
the key (`nodes.N.config.bogusKey`), and its message is the contract's
own sentence.
- **The edit:**
`packages/spec/src/automation/flow-node-config-refusals.ts`, the builtin
branch of `flowNodeConfigRefusals`. It judges key membership where
`builtinKeysJudged(nodeType)` holds. That is a builtin contract whose
type is in the spec's own schemaless class,
`SCHEMALESS_NODE_CONFIG_SCHEMAS`.
- **Why the narrow lift:** a schemaless descriptor publishes no
`configSchema`, so `registerFlow`'s undeclared-key walk skips it. Its
executor still parses the strict contract, so it refuses the node at
every run.
- **Unchanged:** `getBuiltinNodeConfigContracts()` keeps its 13 entries,
and no new code joins `FLOW_SLOT_REFUSAL_CODES`.
- **Premise corrected:** `builtinValueJudged`'s docblock said
"registration refuses an undeclared key against the descriptor". That
was false for exactly these two types. The docblock now says which door
owns which key.
- **Comments made true:** the `FlowSchema` header in `flow.zod.ts` and
the `node-config-refused-by-contract` docblock in
`flow-node-expression-paths.ts` now name each arm that judges key
membership: approval whole (objectstack-ai#21850), builtin values (objectstack-ai#21898), and
`script` / `subflow` keys.
- No `service-automation` source line moves, and there is no second key
check anywhere.

## Built-ins: which get the key refusal, and why (measured at
`15ec50e528`)

| type | in `getBuiltinNodeConfigContracts` | descriptor `configSchema`
| contract strict | executor parses it | key refusal here |
|:--|:--|:--|:--|:--|:--|
| `script` | yes | none | `strictObject` | yes (`screen-nodes.ts`
`parseNodeConfig`) | **yes** |
| `subflow` | yes | none | `strictObject` | yes (`subflow-node.ts`
`parseNodeConfig`) | **yes** |
| `decision` | no | none | `strictObject` | no: its executor reads
`conditions[]` raw | no. An undeclared key fails no run.
`decisionShapeRefusals` judges the `conditions` shape, not keys. |
| `wait`, `connector_action` | no | none | their contracts are the
FlowNode sibling blocks `waitEventConfig` / `connectorConfig`,
`strictObject` inside `FlowNodeSchema` | they read the sibling block,
not `config` | no. `FlowSchema` already refuses an unknown key in those
blocks. |
| `get_record`, `create_record`, `update_record`, `delete_record`,
`notify`, `http`, `screen`, `map`, `loop`, `parallel`, `try_catch` | yes
| yes | `strictObject` (all 11) | yes | no: the remainder, below |
| `assignment` | no | yes (keyValue map) | no: open top-level variable
names | no single contract | no |

## The remainder: the card stays open for it

Triage's direction step 2 covers every builtin whose executor contract
is strict. All 13 are. This PR takes the two schemaless ones, by the
seat's ruling `6050287134`.

- **Remainder 1, the 11 descriptor-`configSchema` builtins at the build
doors:** `get_record`, `create_record`, `update_record`,
`delete_record`, `notify`, `http`, `screen`, `map`, `loop`, `parallel`
and `try_catch`.
- They have the same gap at the build doors. Measured at this branch's
round-0 head `f281d801f` on `examples/app-showcase`, flow
`showcase_task_completed`, node `notify`, with `config.bogusKey: 1`:
    - `objectstack validate` exits 0;
- `objectstack compile` exits 0, and the artifact carries
`"bogusKey":1`;
- `registerFlow` refuses the same node: "Flow 'p' rejected: 1 undeclared
config key(s). … unknown config key `bogusKey` at config.bogusKey".
  - So boot drops the flow with a warning, and the build never says so.
- **Remainder 2, an open design choice, not decided here:** once the
spec arm covers those 11 types, who judges a builtin's undeclared key at
`registerFlow`? The spec arm pre-empts registration's descriptor walk,
and with it that walk's pinned prescriptions (`service-automation`
`config-unknown-keys.test.ts`).

## Census first (triage step 1): no writer found

| corpus | read at | `script` nodes | `subflow` nodes | with a key
outside the contract |
|:--|:--|:--|:--|:--|
| this repo: `examples/**`, `packages/platform-objects/**`,
`packages/apps/**`, `packages/create-objectstack/**` (templates),
`skills/**`, `content/docs/**` | `15ec50e528` | 6 | 2 | 0 |
| hotcrm, whole tree | `c9678036d9` | 0 | 5 | 0 |
| objectui flow designer `FLOW_NODE_CONFIG` | pin `a58626c88d` (same
file at objectui `main` `9990f9e122`) | form writes `function`,
`inputs`, `outputVariable` | form writes `flowName`, `input`,
`outputVariable` | 0 (its `timeoutMs` field writes the node; the five
retired `script` keys sit behind a `showWhen` no field satisfies) |
| objectui designer seeds `defaultNodeExtras` | `a58626c88d` | empty
`config` | empty `config` | 0 |
| objectui console preview samples | `a58626c88d` | 4 | 0 | 0 |

- **Method:** a TypeScript-AST scan for object literals carrying `id`
and `type: 'script'` / `'subflow'`, reading the keys of their `config`.
Code fences in `.md` / `.mdx` and `.json` files were parsed too.
- **Control:** over this whole repo, tests included, the same scan finds
103 nodes and flags 17. All 17 are fixtures:
  - 9 in the D2 conversion fixtures (`conversions/registry.ts`);
  - 5 in `lint` tests;
- 3 test-double keys in `service-automation` `engine.test.ts`, repaired
below.

## Doors, measured

- **`objectstack validate` / `compile`.** Built CLI at round-0 head
`f281d801f`, `examples/app-showcase` node `summarize` (`script`), one
edit: `function: 'summarizeCompletedTask' , bogusKey: 1,`.
- Control: validate exit 0, compile exit 0, and the artifact has no
`bogusKey`.
- With `bogusKey`: validate **exit 1**, compile **exit 2**, and no
artifact is written. Both print the refusal at path `nodes, 1, config,
bogusKey`.
- The mutation was made with `scripts/ablation-replace.mjs`: anchor 1 →
0, then restored to the HEAD blob, `git diff HEAD` empty.
- **`registerFlow`** (real builtin executors):
- `script` / `subflow` with `bogusKey` are refused at
`nodes.1.config.bogusKey`;
  - both controls register;
- a `script` `functionName` alias registers: it is converted before the
parse;
  - `http` `bogusKey` is still refused by the descriptor walk.
- **Pinned in `flow-builtin-node-config-keys.test.ts`** (19 tests):
- the refusal at `FlowSchema` (also inside a region body), `defineStack`
(`STACK_SCHEMA_INVALID` 422 at `flows.1.nodes.1.config.bogusKey`),
`ObjectStackDefinitionSchema`, the save door's `flow` type schema and an
artifact parse;
- the controls: no extra key; a descriptor type's key still left to
registration (`http`, `create_record`, `screen`); `decision`; a retired
`script` key keeps its tombstone path.
- **Reverse verification** at round 0, `builtinKeysJudged` mutated to
`return false`: 12 of 19 went red and the 7 controls held. It was
restored to the HEAD blob.

## Cross-lane fixtures repaired (claim revision `6050287134`)

- **`service-automation`, test only:**
- The doubles in `engine.test.ts` ("should execute unconditional
branches in parallel", "should fail when parameter type is wrong") and
in `input-schema-retry-parity.test.ts` now register under the type
`probe_step`, executor and nodes alike, never the builtin `script`.
  - The `function: 'noop'` filler went with them.
- `inputSchema` reads top-level config keys, which a real `script`
executor refuses.
- **`lint`:** `validateStackExpressions` keeps the pre-conversion
tolerance it declares.
- The filter in `validate-expressions.ts` also hands the judge's
undeclared-key refusal for a `script` node's `functionName` alias to the
callable check, which already reads that alias.
- A new pin holds that every other undeclared `script` key is still
refused there (`bogusKey`, on a canonical and on an alias source).
  - The changeset gains `'@objectstack/lint': patch`.

**Red → green.** Round 0 at `f281d801f` had 4 red in
`service-automation` and 2 red in `lint`. All six now pass at
`ef0dfb44d`:

- `engine.test.ts` › "should execute unconditional branches in parallel"
✓
- `engine.test.ts` › "should fail when parameter type is wrong" ✓
- `input-schema-retry-parity.test.ts` › "never executes a node whose
config mis-types its declared inputSchema — on ANY attempt" ✓
- `input-schema-retry-parity.test.ts` › "still retries a VALID flow
normally …" ✓
- `validate-expressions.test.ts` › "accepts a script node that names a
callable via the functionName alias" ✓
- `validate-expressions.test.ts` › "a `script` with no `function` is ONE
finding, the callable check's …" ✓

## ADR-0087

- **D3 entry:**
`18.flow-script-subflow-config-undeclared-keys-refused.ts`.
- **Rationale fragment:** step 18 `order: 87`, re-read on `origin/main`
`8fc50b764` (the merged base): its highest order is 86, and open PRs
objectstack-ai#22103 and objectstack-ai#22094 hold 86 and 85 at their heads.
- **Registry:** `registry.ts` was regenerated by
`gen:migration-registry`.
- **Changeset:** `@objectstack/spec` `minor`, BREAKING, with the
`registered` marker, plus `@objectstack/lint` `patch`.
`.changeset/pre.json` is absent on `origin/main`.

## Merge

- `origin/main` `8fc50b764` was merged by `scripts/pm/os-regen-merge.sh`
as merge commit `521e16f1f`, with parents `f281d801f` and `8fc50b764`.
There were no conflicts.
- Step 2 took `main`'s side of the generated artifacts that `main`
moved, and there was nothing more to commit.
- After a spec build on the merged tree, `check:generated` reported all
15 artifacts up to date. The delta against `main` was exactly this PR's
5 round-0 files.
- `gen:schema` was not run.
- Round 1's edits are commit `ef0dfb44d` on top.

## Verification at `ef0dfb44d`

- **Spec:**
  - `check:generated`: all 15 artifacts up to date.
- The pin files `flow-builtin-node-config-keys.test.ts` and
`flow-builtin-node-config-values.test.ts`: 63/63.
- Round 0's whole spec suite at `f281d801f`: 624 files, 18628 tests
passed.
- **lint:** the whole suite, 123 files, 5689/5689.
- **service-automation:** the whole suite, 175 files, 2120/2120. The
first attempt collided with a concurrent gate run that left
`@objectstack/spec/automation` unresolvable for 17 files; it was re-run
alone.
- **Typecheck:** `@objectstack/lint` exit 0 and
`@objectstack/service-automation` exit 0, both including
`check:test-typecheck`, over a closure rebuilt with declarations.
- **eslint, narrowed** (`--no-inline-config --format json`) over the
diff's 10 `.ts` files: 10 files, 0 errors, 0 warnings. The population is
read from the json count. `parserOptions.project` and `projectService`
are null for each file, so there is no type-aware linting and no
untouched file's verdict can move.
- **Gates:** `dispatch-gates --commands --repo
objectstack-ai/objectstack` derives 92 commands from the merged head.
All ran, with exit codes captured before any pipe. The `--ran`
reconciliation reads 91 run, 1 NOT-MEASURED, 0 UNRUN
(`check:dts-closure` recorded at its re-run).
  - 91 exit 0.
- `check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (packages
outside this worktree's build closure have no `dist`). It is read from
CI, as are round 0's `cli` published-subpath pins.
- `check:dts-closure` first exited 1, naming exactly the 19 closure
packages built with `OS_SKIP_DTS=1` for the test runs. Re-run after the
closure was rebuilt with declarations, it exits 0: 169/169 declaration
files across 71 built packages.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… verdict (objectstack-ai#22032 pass 3) (objectstack-ai#22151)

Part of objectstack-ai#22032
Clause-②: no (narrowing)

This is pass 3 of objectstack-ai#22032: a field option's `visibleWhen`. Pass 4 (the
object's own action predicates) stays fenced, and the card stays open
for it.

## What changes

**The object save door gives the build's verdict on a field option's
`visibleWhen`.** `formulas.mdx` says "the same `validateExpression`
validator backs `os build` and metadata registration". After pass 2 the
object door ran the whole field walk except the per-option loop, which
kept its own guard. So an object whose option carried `visibleWhen:
'amount > 1'` (a bare field reference) still saved with a 200, while `os
build` refused it at error. The server's option check cannot evaluate
such a predicate and fails open (logged, allowed through), so the gate
it declares is never enforced (read from `evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`).

- **The lift is the guard, nothing else (H1 held).** On `origin/main`
`8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] :
recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`),
under a `[objectstack-ai#22032] FENCED on an object write (pass 3 …)` comment. It now
reads `recordsOf(f.options)`. On an object write the loop runs at the
build's own position in the field walk, so the door gets both of the
option's checks:
  - `check(optionWhere, opt.visibleWhen, objectName, 'record')`;
- `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the
refusal of a read through a reference field on `record` or `previous`.
- **`current_user` keeps the build's two verdicts (H2).** An option's
evaluator binds the acting user (ADR-0068 D1), so the build accepts
`current_user` on an option and refuses it on the field-rule slots one
level up. The door now gives both verdicts as the build does. The
showcase's role gate, `'org_admin' in current_user.positions`, still
saves on an option, and the same text on the field's own `visibleWhen`
is refused at both doors. Both are pinned.
- **No registry change (H3 re-verified).** The
`validateStackExpressions` entry declares `runtimeTypes: ['flow',
'action', 'hook', 'object']` (`authoring-rules.ts`), and
`runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it.
`runtime-gate.ts` is untouched.
- **Docblocks made true.** `StackExpressionOptions.runtimeWriteType` now
names four admitted passes and one fenced pass.
`AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one
line that reaches a built `.d.ts`, names the per-option pass. The
function-head comment and the field walk's two comments move with it. In
`authoring-rules.ts` the registry entry gains a `[objectstack-ai#22032, pass 3]`
measurement comment, as passes 1 and 2 added theirs. Comments in four
sibling test files are corrected so that none of them still says option
`visibleWhen` is fenced.
- **The door's verdict is the build's finding (H4).** The door's 422
issue and `runAuthoringRules('build', …)` give the same rule
(`expression-invalid`), location (`object 'fx_option' · field 'province'
option 'zj' visibleWhen`), path, message and hint. The pins compare
these key by key.
- **No code change in `packages/metadata-protocol`.** Only its test file
gains the door-level pins.

## Pins

- **Lint door:**
`packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts`
(new, 14 tests).
- LIT: one refused body per finding the pass gives. These are a bare
`amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown
field `record.amont > 1`, a syntax error `record.country ==`, and the
traversal refusal through `record.account` and through
`previous.account`. Each is located at the option and asserted on its
named subject.
- CONTROL (the still-accepted cases): the `record.country` cascade, the
showcase's `current_user.positions` role gate, a grant check plus role
gate (`current_user.can(…) && …`), and a reference compared as a value
(`record.account != null`). Each is clean at the door and at the build.
- CONTRAST: `current_user` on the option and on the field's own
`visibleWhen` in one body. The build and the door both give exactly one
finding, at the field slot.
- PARITY: for each refused body, the door's findings equal the build's.
- The differential: a stored sibling's broken options are not this
write's to answer for.
- **The fence (enumeration pin)** in
`packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The
fenced site is now pass 4's alone (an action `visible`). The option
`visibleWhen` site moves to the lifted sites, beside the validation rule
and the `requiredWhen`. The build flags all four sites. The object door
flags the three lifted sites in the build's order, and
`runStackExpressionPasses` on an object write returns exactly the
build's findings for the admitted passes.
- **Protocol door:** a new pass-3 block in
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
through the real `saveMetaItem`, `publishMetaItem` and
`publishPackageDrafts`.
- (a) A bare reference, an unregistered function and a read through a
reference field are each refused on an active save. The answer is a 422
`INVALID_METADATA` carrying the build's located finding, and nothing
lands.
- (a) The card-shaped body is refused on a draft's promotion and on a
package draft publish (`outcome: 'refused'`, `failed` naming the object
with `INVALID_METADATA`, the row left a draft). The draft saves
themselves still succeed.
- (b) The showcase's cascade and its `current_user` role gate still
save, and the row lands active. The role gate rides every refused body
too, which each yield exactly one finding.
- (d) For each refused body, the door and `os build` give the same
finding on rule, where, path, message and hint.

## Reverse verification (one-off, from committed HEAD `23ce6c494c`)

- **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put
the guard back on the option loop. The new text was `const
ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt]
of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The
anchor was hit once, 1 to 0, and the blob went `879fcb828037` to
`73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on
the absolute path.
- **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files.
- **Lint suites (source): 9 failed and 14 passed, as predicted.**
- Red: the 6 LIT tests, PARITY, and the two fence tests that assert the
lifted sites.
- Green: the 4 CONTROL tests, CONTRAST, the differential, the registry
test, the build-flags-each-site test and the six formula-door tests.
- **Protocol pass-3 block (dist-mediated): 6 failed and 1 passed, as
predicted.** Red: the three (a) saves, (a) on promotion, (a) on package
publish, and (d). Green: (b).
- **Restore.** The tool restored the file: blob `879fcb828037` equals
HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths.
Lint was rebuilt, and `--absent` found the marker gone from all 14 built
files. Both suites went green again: lint 23 of 23, and the protocol
file 99 of 99.

## Measurements

- **Corpus first: the stop condition was not met (H5).** Every object
this tree ships was judged before the door changed. That is every
`*.object.ts` under `packages/**` and `examples/**` (111 files) plus the
two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was
judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse
failures), with its own group as context.
- 5 option predicates on 2 fields of 1 object, all on
`showcase_cascade`: `province`'s four `record.country` cascades (`zj`,
`gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in
current_user.positions`).
- At base `8fc50b7647`: 0 build errors and 0 build warnings for the
option pass, through `validateStackExpressions` and through
`runAuthoringRules('build')`, at both shapes. There were 0 door
expression findings over all 118 objects.
- Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare
`country` and the role gate to `sqrt(record.amount) > 1`, in a copy,
gave 2 build errors at those two options.
- At head `23ce6c494c`, and again at the merged heads `06d3cad963` and
`3c1d3262ee`: 0 door errors and 0 door advisories over every object,
through `runRuntimeAuthoringRules` with type `object`, at both shapes.
The harness passes each object's own group as context, so at
`3c1d3262ee` every one of those saves is an update and also runs the
stored-universe pass that objectstack-ai#22118 added.
- Positive control in the same harness (an option `visibleWhen: 'amount
> 1'`): 1 build error at every head. The door gave 0 at base and 1 at
head.
- **Which doors newly answer 422.** The active publish save, a draft's
promotion, and a package draft publish. Each was measured through the
real methods above. A draft save stays ungated, measured by the same
pins.

## Clause-② (measured)

- **Accept set: narrowing.** An object write in publish mode answered
200 for an option `visibleWhen` the validator refuses. It now answers
422 on the three doors above.
- **Built entry declarations.** In `@objectstack/lint` one doc comment
moves (`AuthoringRuleContext.runtimeWriteType`).
`StackExpressionOptions` and `runStackExpressionPasses` are not in the
built declarations. No exported signature moves.
- **Changeset.**
`.changeset/22032-object-save-door-option-visible-when.md` covers
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It
carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING
section with the remedy, and ADR-0087 `not-required
(no-migration-prescription)`. `@objectstack/metadata-protocol` is listed
as passes 1 and 2 listed it, although no code moves there: its save,
promotion and package-publish doors are where the BREAKING behaviour can
be seen. `.changeset/pre.json` is absent on `origin/main` (read at
`8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and
at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the
BREAKING banner, as in passes 1 and 2. The changeset says it supersedes
the earlier objectstack-ai#22032 entries' line that option `visibleWhen` is not judged
at this door.

## Merges

- **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103),
through `scripts/pm/os-regen-merge.sh`. It was clean.
- **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents
`06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`.
That brought PR objectstack-ai#22129 (objectstack-ai#21982), PR objectstack-ai#22094, PR objectstack-ai#22134, PR objectstack-ai#22133
(objectstack-ai#22118, the gate's object-write baseline keeps the written item's
stored self), PR objectstack-ai#22126 and PR objectstack-ai#22140.
- `validate-expressions.ts` auto-merged. PR objectstack-ai#22129's edit is in the flow
`script` / `subflow` region; the option loop's lift is unchanged.
- One conflict:
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
one hunk. Both sides had added a new top-level `describe` block at the
same place, after the pass-2 block. It was resolved by stacking: the
pass-3 block first, then objectstack-ai#22118's stored-self block, and every line of
both was kept. Against `7ef50a4fbb` the file shows only this branch's
lines; against `06d3cad963` it shows only objectstack-ai#22118's 114 lines.
- No generated path was touched by this branch. The rerun of the script
took `origin/main`'s side of every generated path main moved, and that
left nothing to commit.
- This branch's own changes are the same before and after the merge: for
each of the 9 files, the added and removed lines against the merge base
are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45.
- **The interaction with objectstack-ai#22118, measured.** objectstack-ai#22118 adds a third,
stored-universe pass on an update into a context collection. A finding
whose path names no entry is judged as one on the written item. The
expression rule's paths are `where` strings, so an option finding is
always judged that way.
- A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`)
over 20 cases, against this branch's lint source before the merge
(`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an
update over a stored self that is broken and over one that is clean, and
a stored sibling that is broken, each for three refused bodies, plus two
still-accepted bodies.
- The verdicts are identical, case for case. Re-saving a broken option
is still refused, including over a broken stored self, because re-saving
a row is writing it. A clean save over a broken stored self passes. A
broken sibling is never charged.
- The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and
objectstack-ai#22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three
files, and the protocol file 103 of 103.
- This matches the code. The option pass reads only the written object's
own field index, and binds `record` and `previous`, never `parent`, so
the stored universe has nothing extra to offer it.

## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main`
`7ef50a4fbb`)

- **`main` moved during the run (H6).** PR objectstack-ai#22103 landed as `ef1fcb26a2`
and touched two files this pass edits, `authoring-rules.ts` and
`runtime-gate.object-writes.test.ts`, in other hunks. It was merged with
`scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean,
and no generated path was taken from either side. After the merge: `pnpm
install --frozen-lockfile`, a full turbo build (72 tasks), and
`@objectstack/spec check:generated` ("All 15 generated artifacts are up
to date"). objectstack-ai#22118 and objectstack-ai#21982 had not landed at that read
(2026-10-08T02:40Z). Both have since landed, and they are merged at
`3c1d3262ee` (see Merges). After that merge the same sequence ran: a
full turbo build (72 tasks) and `check:generated` ("All 15 generated
artifacts are up to date").
- **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck`
exit 0, with its test-typecheck included (`--listFiles`: the five
touched or new lint test files are in the `tsconfig.test.json` program).
- **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped;
28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`:
the door test file is in the program).
- **Consumer readings.** Every package was built at the head named.
- `@objectstack/objectql`, 8 files and 282 tests passed:
`publish-package-drafts-response-conformance`,
`save-meta-response-conformance`, `publish-meta-response-conformance`,
`plugin.integration`, `engine-field-predicate-fault`,
`engine-option-permission-predicate`,
`validation/rule-validator.option-visibility` and `engine`.
- `@objectstack/rest`, 11 files and 197 tests passed: every
`meta-object-*` file and `meta-publish-package-scope`.
- `@objectstack/cli`, 3 files and 16 tests passed, run as `--project
integration` because the tier predicate puts them there:
`validate-field-predicate-traversal` (which asserts an option
`visibleWhen` `expression-invalid` finding),
`authoring-rule-command-parity` and `verify-author-time-stage`. The
three nightly-tier `*.e2e` files that assert `expression-invalid` are
left to CI.
- The search for other consumers covered every test file in the
repository carrying `visibleWhen`, matched against the save-door entry
points. Only the two packages above save an option `visibleWhen` through
a door.
- **Gates.** `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands, the same set at
`23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit
0, each exit code captured before any pipe. `--ran` reconciles 63
derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded
for each. The printed artifact-roster block names 51 families, and all
51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same
battery, one (`check:engine-double-contract`) already among the 63, and
the three PR-scoped ones (`check-partof-closing-keyword`,
`check-closing-target-claim`, `check-single-claim-paths`) run with this
PR's number and this body. The same 63 also ran at `06d3cad963`, all
exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0
on the first run. `check:dual-build-cjs-loads` and
`check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full
build) and exited 0 after the full build.
- **ESLint, narrowed to the 8 touched TypeScript files**
(`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings.
Each file is matched by `eslint.config.mjs` (`--print-config`), and none
was ignored. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.

## Acceptance notes

- **Out of this pass, reported for the seat: an option `visibleWhen`
reading `parent` gets no verdict at the build, so none at the door
either.**
- Measured at `23ce6c494c` with scratch tests that were deleted
afterwards. Through the real `saveMetaItem`, an object whose option
carries `visibleWhen: "parent.status == 'closed'"` saved with success,
and the row landed `active`. `runAuthoringRules('build')` gave 0
findings.
- The server's option check (`evaluateValidationRules`, authenticated
caller, the option picked) then logged `failed to evaluate
(authenticated caller) — allowed through`, with `Unknown variable:
parent`, and admitted the value. The option evaluator binds `record`,
`previous`, the user and permissions only.
- This is a gap in the build, which the door now mirrors. This card's
contract is parity with the build, so it is not changed here.
- **A code comment names an old spelling.** The comment above the option
loop calls the showcase's legal usage `'admin' in
current_user.positions`. The showcase now writes `'org_admin' in
current_user.positions`, and the pins use that spelling. The comment is
not changed here: per the contract review, it rides pass 4.
- **The pending objectstack-ai#22032 changesets.** Pass 1's and pass 2's changesets
each list option `visibleWhen` under "Unchanged", which was true at
their heads. This pass's changeset says it supersedes that line rather
than editing them, the same way pass 2 left pass 1's changeset alone.
Per the contract review, reconciling those lines rides pass 4.
- `formulas.mdx` could name the object save door. That would be a docs
addition, not a correction of a false line.
- **Contract review.** Triage's grade asks for one per pass. A PASS is
on record for head `06d3cad963` (`6051573476`). The head has since moved
to `3c1d3262ee` by the merge above, so the review for this head is the
seat's.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…its it can prove, you apply the rest (objectstack-ai#22142)

Fixes objectstack-ai#9591
Clause-②: no (prescription text only; no input's accept or reject result
changes)

This is the spec-lane half of the card: the shared retirement sentence
names `--write`, and the class-wide pin moves in the same PR. The
codemod itself landed in PR objectstack-ai#22108 (`a959493cdf`).

## The sentence

Before (the objectstack-ai#9529 wording):

```text
Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand.
```

After:

```text
Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.
```

The one allowed two-clause variant (a conversion that covers only part
of a value) carries the same clause: `… to list the mechanical edits for
the X case; --write applies the ones it can prove, and
WHAT-HAPPENS-TO-THE-REST.` Its two members are dashboard
`compareTo.offset` and the script node's `config.actionType`.

**Checked against the tool on `main` (`51290bca`).**
`packages/cli/src/commands/migrate/meta.ts` declares `write:
Flags.boolean({ … default: false, exclusive: ['stored'] })`. Its help
text says it rewrites the authored sources in place "for each mechanical
change traced to one literal in one project file; every other change is
listed with the reason it was not written". Without the flag the run
writes only the `--out` snapshot. The wording satisfies every ruling
that binds it:

- **Triage `6045697201`.** The sentence never says "rewrite existing
sources automatically" unqualified ("the ones it can prove"), and it
names `--write` because the default run still only lists.
- **"It must be TRUE of the tool."** Every clause is a property of the
command, read from `meta.ts`.
- **"One antecedent."** "existing sources" still names one thing. "The
ones" can only be edits, because an edit is what gets applied. The key's
fate stays in the body prose.
- **Vocabulary.** The wording matches PR objectstack-ai#22122's skill text ("lists the
mechanical edits"; `--write` "rewrites in place each edit it can trace
to one literal in one project file, lists every other with the reason it
was not written").

## Where it moved (counted at `017761f0`; the merge of `main` added no
site)

- **161 sentences the pin judges.** `packages/spec/src`: 157 (155 house
form, 2 two-clause). `packages/lint/src`: 1.
`packages/drivers/driver-turso/src`: 3. Every one passes the new
anchors; `apply them by hand` survives only in the pin's own RED
fixtures.
- **Not judged by the pin, moved anyway:**
- `migrations/registry.ts`: 3 sentences, regenerated from the moved
`entries/semantic/18.*.ts` by `gen:migration-registry`.
- The lint `chartConfig.xAxis.field` hint in
`validate-widget-bindings.ts`: a template literal with interpolation
after the sentence, so the pin cannot see it (Acceptance notes).
  - The `retiredKey()` docblock example.
- **Mechanical replacement.** A script replaced the tail `apply them by
hand` in 63 files (188 occurrences; old tail left: 0) and printed
per-file before/after counts. Two seams split mid-phrase
(`translation.zod.ts`) and the two two-clause sites were rewritten by
anchored edits that had to hit exactly once.
- **Pins in other test files.** 23 test files asserted the old sentence
verbatim, as string or regex. Each now asserts the new sentence
verbatim, so a revert reds them. The ones that assert only the unchanged
prefix (`form-layout-inline-grid-retired.test.ts`, the turso /
driver-memory `toContain('os migrate meta --from 17')`) are untouched,
because they stay true.
- **Changeset.** `.changeset/9591-retirement-sentence-write.md`: `patch`
for `@objectstack/spec`, `@objectstack/lint` and
`@objectstack/driver-turso`, the three packages whose shipped text
moves.
- **Generated.** `content/docs/references/**`: 32 files (+268/−268) from
`pnpm --filter @objectstack/spec check:generated --fix`; `check:docs`
was the only stale artifact. `check:generated` then exited 0.

## The class pin (`retired-key-migrate-sentence.test.ts`)

- **Anchors.** `HOUSE_AT_MARKER` and `MIXED_AT_MARKER`, and their
markdown twins, require the new clause. The objectstack-ai#9529 sentence, which does
not name `--write`, is now RED. Two further spellings are RED: one that
names `--write` without the qualification, and one that qualifies it but
leaves the rest unowned.
- **Withdrawn claim.** `WITHDRAWN_CLAIM` is unchanged: the unqualified
automatic-rewrite claim stays a hard RED everywhere. A new non-vacuity
case proves neither legal shape trips it.
- **Truth anchor (new).** The pin reads `os migrate meta`'s own flag
table. A `write` boolean flag must exist and must have `default: false`,
the two facts the sentence rests on. The read is covered by
`@objectstack/spec`'s existing `packages/**/*.ts` cross-package
declaration.
- **Corpus widened by one root.** `packages/drivers/driver-turso/src`
joins, on objectstack-ai#7030's terms. Its three `turso` config tombstones carry the
house sentence, and their docblock defers to `retired-key.ts`, but the
pin never walked them. Without this, a rewording leaves them behind with
every assertion green. The lint-only anti-vacuity case now covers each
widened corpus.
- **Header and docblock.** The pin header and the `retired-key.ts`
module docblock record the new sentence and why. The "the claim may be
restored" note is gone, replaced by what was restored and how far.

**Reverse verification**, run from committed HEAD `017761f0` through
`scripts/ablation-replace.mjs` (each anchor hit as declared and was
restored to a blob equal to HEAD with `git diff HEAD` empty). Expected
direction: red.

| Mutation | Result |
|:--|:--|
| A. the three `turso.zod.ts` sentences back to the objectstack-ai#9529 wording
(anchor ×3→0, blob `e25cca4d5508`→`a05fe3f09733`) | 3 failed / 12
passed, naming `driver-turso:spec/turso.zod.ts:63`, `:75`, `:82` |
| B. `meta.ts` `write` flag `default: false` → `true` (blob
`c036012c63c9`→`71acff21ef8c`) | 1 failed / 14 passed: "the sentence is
TRUE of the command it names" |
| C. `meta.ts` flag renamed `write` → `inPlace` (blob
`c036012c63c9`→`29a8a9402284`) | 1 failed / 14 passed: "os migrate meta
declares no `write` boolean flag" |

Under the old corpora, mutation A would have stayed green, because
driver-turso was in no corpus.

## One bounded fix on the same sentences: `CHATTER_POSITION_RETIRED`

The three `record:chatter` / `record:discussion` `position` value
prescriptions (`'sidebar'`, `'inline'`, `'drawer'`, in
`ui/component.zod.ts`) told the author to run a bare `os migrate meta`.
The command refuses that with `Missing required flag --from` (`meta.ts`
`run()`, the `flags.from === undefined` branch). The conversion is
`record-chatter-position-vocabulary`, `toMajor: 18`, so they now name
`--from 17`. They therefore join the pin's judged set in house form, and
the "(registered under protocol major 18)" aside goes. The fix qualifies
as bounded: the same sentence class, a mechanical change to an
already-pinned form, a file inside this claim's surface, and the same
gate family. No test pinned the old text.

## Governed surface: `.claude/skills/spec-property-retirement/SKILL.md`
(Tier S)

The pin requires the retirement playbook to teach both shapes
(`SKILL_HOUSE_TEMPLATE` and `SKILL_MIXED_TEMPLATE` must match its
convention 5). So changing the sentence forces the playbook edit, and
this PR lands as Tier S. Convention 5 now carries the two new templates.
Its note that the command "never writes a source file" was made false by
PR objectstack-ai#22108, so it is deleted. Line count 337 → 337 (ceiling 337), with
every line within the 120-byte budget: `node
scripts/pm/check-skill-line-ratchet.mjs` exits 0. ⛔ No published
`skills/**` file changes: PR objectstack-ai#22122 owns
`skills/objectstack-upgrade/SKILL.md`, and no published skill carries
the sentence (`git grep` count 0).

## 维护者速读(草稿)

**改了什么**:所有退役键报错末尾那句统一提示,从「运行 `os migrate meta --from N`
列出机械修改,然后手工改」改为「……列出机械修改;`--write` 会写入它能证明的那些,其余你手工改」。共 161 处被 pin
判定的报错文案(含 2 处两从句变体),外加生成的 registry 3 处、lint 模板字符串 1 处;其中 3 处原先写成不带
`--from` 的命令(该命令会直接拒绝),一并改正。守这句话的 pin 同步更新,并新增一条断言:CLI 必须真有 `--write`
且默认不写。

**为什么改**:`--write` 已随 PR objectstack-ai#22108 落地,旧句只说「手工改」,低估了工具;但 `--write`
只写能证明的站点,所以不能说「自动重写源文件」。新句两头都如实。

**风险与代价(含回滚)**:纯文案,不改任何 schema、键、类型、导出或错误码;解析结果不变。依赖旧整句原文匹配的调用方会失配(仓内 23
个测试已同步);前缀「…for existing sources;」不变。回滚即 revert 本 PR。在途的兄弟 PR
若新增处方仍用旧句,会被 pin 打红,后落地者改用新句。

**席位意见**:

**你要做的**:无需操作;本 PR 触 `.claude/**`(Tier S),由席位按合同审查记录落地。

## Verification (branch base `51290bca`; final head `f9ca14d548`)

- `pnpm --filter @objectstack/spec build`: VERDICT command-exit 0.
`check:generated --fix` regenerated the one stale artifact;
`check:generated` then exited 0 (15 of 15 current), and again in the
gate run at `f9ca14d548`.
- spec `vitest run --project local`: Test Files 623 passed (623), Tests
18613 passed, 1 todo, at `017761f0`.
- spec `vitest run --project repo` (53 files incl. the class pin): 53
passed (53), Tests 903 passed (903), at `017761f0`.
- `@objectstack/driver-turso` `vitest run`: Test Files 88 passed (88),
Tests 2373 passed, 33 skipped, at `017761f0` (after `pnpm
--workspace-concurrency=2 --filter '@objectstack/lint...' --filter
'@objectstack/driver-turso...' build`; the first run, before that build,
could not resolve unbuilt dependencies and is NOT MEASURED, not red).
- `typecheck` for spec (`tsc --noEmit` + `check:scripts-typecheck` +
`check:test-typecheck`), lint and driver-turso: exit 0 at `017761f0`.
- **After merging `main`** (`033e5c536d`: objectstack-ai#22122, objectstack-ai#22127, objectstack-ai#22106) as
`f9ca14d548`, with no conflict (objectstack-ai#22127 also edits
`validate-expressions.ts`): the class pin 15 passed (15);
`@objectstack/lint` `vitest run`: Test Files 123 passed (123), Tests
5688 passed (5688); lint `typecheck` exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `f9ca14d548` derives 124 commands (the
claim-time 79 plus 45). All 124 exit 0 at `f9ca14d548`. `--ran`
reconciliation: 124 derived, 124 run, 0 NOT-MEASURED, a zero derived
from the recorded exit codes. (At `017761f0`, five gates first answered
exit 3, PREREQUISITE NOT MET: one shallow-clone fixture and four that
need unbuilt dists. The clone was deepened as the gate asked, and all
five are green in the `f9ca14d548` run.)
- `node scripts/pm/check-skill-line-ratchet.mjs`: exit 0; the playbook
is 337 lines (ceiling 337), and no line is over 120 bytes.
- eslint, narrowed: `pnpm exec eslint --no-inline-config --format json`
over the 66 changed `.ts` files reports 66 files, 0 errors and 0
warnings. The population is `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob, which excludes the changed
`.md` / `.mdx` files. The config never enables type-aware linting (its
own comment at `:326`–`:328`), so this diff cannot move any untouched
file's verdict. The repo-wide `pnpm lint` is CI's.

## Siblings in flight

objectstack-ai#21982, PR objectstack-ai#22094 (objectstack-ai#13458) and PR objectstack-ai#22103 (objectstack-ai#5082) each add prescriptions
with today's sentence. Whichever lands after this one carries the new
sentence; the class pin reds it at that merge otherwise. Whichever of
those lands first, this branch merges `main` before landing.

## Acceptance notes

- **Hand-written docs still use the old sentence**
(`content/docs/automation/flows.mdx` ×2,
`protocol/objectql/query-syntax.mdx`, `data-modeling/queries.mdx`,
`protocol/objectui/actions.mdx`, `ui/apps.mdx` ×2). Each is the page's
own advice, not a quoted error, and still true of the default run; each
undersells `--write`. They are `domain:devx` pages outside this claim,
so they are not touched here.
- **QA checklist item `cli.migrate-meta-codemod`**
(`docs/qa/platform-checklist/areas/cli.json`). Its RESTART CHECK fired
when PR objectstack-ai#22108 added `--write`, and the item still asserts a print-only
command. Its step 1 greps for the objectstack-ai#9529 sentence verbatim and now finds
none. Re-authoring the item belongs to the checklist author, not this
PR.
- **Comments that say the default run "lists the mechanical edits"**
stay as they are, because they are still true: the
`migrations/registry.ts` migration notes (outside the pin's scope by
design) and the `conversions/registry.ts` comments.
- **The lint `chartConfig.xAxis.field` hint**
(`validate-widget-bindings.ts`) moved, but it remains invisible to the
class pin: a template literal, with `suggestName(…)` and the suppress
hint interpolated after the sentence.
- **A published skill still claims an automatic strip.**
`skills/objectstack-data/rules/indexing.md:31` says "run `os migrate
meta --from 16` to strip them automatically", and
`skills/objectstack-data/SKILL.md:377` says the command "strips them".
The default run strips nothing from sources, and `--write` strips only
what it can prove. `WITHDRAWN_CLAIM` has no strip spelling, so the pin
cannot see this. Widening it here would red `main` on a Tier H file this
PR may not touch, so it is reported to the PM for the skills lane.
- **The `config.actionType` two-clause tail** ("the stub and marker
values are removed") is unchanged in substance; only the `--write`
clause was inserted before it.


## Patch round 1 (written by the PM seat from the dev's report
`6052088494`)

- **Merge:** `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103) was merged through
`os-regen-merge.sh` as `feca6b5ace`. The three reference pages both
sides had changed (`api/metadata`, `data/object`, `system/migration`)
were regenerated from the merged tree as `98e2f6e373`. That brings back
objectstack-ai#22103's `unique?: false | 'global' | 'organization'` rows, which the
driver had dropped.
- **objectstack-ai#22103's sites:** the merge brought two non-test sites with the old
tail and one test that asserts it verbatim. All three carry the new
sentence at `b9d6e82619`:
- `packages/spec/src/data/object.zod.ts`
(`DECLARED_INDEX_BARE_TRUE_RETIRED`);
- `packages/lint/src/data-model-rules.ts` (the
`unique-unscoped-declared-index` fix text);
  - `unique-scope-message.test.ts`.

The pin now judges 163 sentences: `spec` 158 (156 house + 2 two-clause),
`lint` 2, `driver-turso` 3.
- **The pin's blind spot:** the `data-model-rules.ts` sentence sat in a
template literal, which the judge cannot read (escaped backticks), so it
was never judged. It is now plain-quoted, as in
`validate-expressions.ts`, and the pin's Mechanism paragraph records
that template literals are invisible to the scan. Ablation D (that
sentence back to the old tail) gives 3 failed / 12 passed, naming
`lint:data-model-rules.ts:463`. `validate-widget-bindings.ts` stays the
one template-literal site the pin cannot judge (an Acceptance note).
- **Verification at `b9d6e82619`:**
  - spec `--project local`: 623 files / 18,619 tests;
  - spec `--project repo`: 53 / 903;
  - lint: 123 / 5,689;
  - driver-turso: 88 / 2,373;
  - typecheck: exit 0 for all three packages;
  - `dispatch-gates --ran`: 124 derived / 124 run / 0 NOT-MEASURED;
  - CI: 33 success, 2 expected skips.


## Patch round 2 (written by the PM seat from the dev's report
`6053397952`; claim revised `6052335087`)

- **Why:** PR objectstack-ai#22094 (objectstack-ai#13458, `fec87e7e07`) landed first with a
two-clause prescription lacking the `--write` clause, which this PR's
class pin refuses. The sibling rule here ("whichever lands later carries
the new sentence") puts the edit in this PR.
- **Merge:** `origin/main` `959c209d56` was merged through
`os-regen-merge.sh` as `3b6335b9be`, with no hand-written conflict.
`content/docs/references/api/protocol.mdx` was regenerated as
`c40b3babd7`.
- **The edit** (`fe3af5642c`, 4 files beyond the merge):
- `packages/spec/src/kernel/manifest.zod.ts`
`PLUGIN_PERMISSIONS_LIST_FORM` now closes with "Run `os migrate meta
--from 17` to list the mechanical edits for the package manifest case;
`--write` applies the ones it can prove, and a granted-permission record
is not a source it reads." It keeps objectstack-ai#13458's own second clause and adds
the house `--write` clause, the seat's wording.
- Its verbatim pin `manifest-permissions-string-list.test.ts` moved with
it.
- The changeset's two-clause bullet now names three members and says
"before their second clause".
- **The two-clause variant now has three members:** dashboard
`compareTo.offset`, the script node's `config.actionType`, and the
package manifest `permissions` case. The pin judges 164 sentences (spec
159 = 156 house + 3 two-clause; lint 2; driver-turso 3) with 0 bad
sites.
- **Verification at `fe3af5642c`:**
  - spec `--project local`: 625 files / 18,661 tests;
  - spec `--project repo`: 53 / 903;
  - class pin: 15 / 15, and the manifest pin: 17 / 17;
  - `dispatch-gates --ran`: 124 / 124 / 0 NOT-MEASURED;
  - CI: 33 success, 2 expected skips.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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