Skip to content

feat(spec)!: retire rowLevelSecurity[].tags — no mainstream platform tags a row-level policy (ADR-0049) - #20353

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20321-rls-tags-retire
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20321-rls-tags-retire

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20321
Clause-②: no (narrowing)

What this does

Retires rowLevelSecurity[].tags (RowLevelSecurityPolicySchema.tags) by the spec-property-retirement route. It is ADR-0049 enforce-or-remove, graded RETIRE by triage (5860425529) under the maintainer's criterion on #18900 (5727134555). Triage's verdict, verbatim: 「Row-level policies carry no tag attribute in the mainstream: Salesforce sharing rules, Dataverse security roles and PostgreSQL RLS policies. Compliance reporting keys on the rule itself. ⇒ RETIRE.」 Retirement is immediate, with no staged window.

The Clause-② line is the seat's measured answer, no (narrowing), which supersedes the claim's yes (5860732909, triage's execution note). scripts/pm/clause2-line.mjs defines the value as the answer to 「本卡放宽接受集或扩大公开面吗」. Nothing widens: the tombstone narrows the accept set, the D2 conversion only heals stored rows the load path already accepted, and no export is added. A policy that carried tags parsed before this change and is refused after it, which is the (narrowing) arm. The changeset carries the same line.

Accept/refuse changes (all pinned)

input before after pin
RowLevelSecurityPolicySchema with tags (any value, [] included) parsed, stored, read by nothing refused: invalid_type at ['tags'], with the prescription rls-tags-retirement.test.ts, rls.test.ts
permission write door (getMetadataTypeSchema('permission') = PermissionSetSchema) with a policy carrying tags accepted refused: invalid_type at ['rowLevelSecurity', 0, 'tags'], with the prescription rls-tags-retirement.test.ts
defineStack with such a permission set accepted refused: STACK_SCHEMA_INVALID / 422, issue at ['permissions', 0, 'rowLevelSecurity', 0, 'tags'], with the prescription rls-tags-retirement.test.ts
the same policy / set / stack without tags accepted accepted, byte-identical output, no tags materialized CONTROL cases in the same file
a near-miss tag refused as unknown; the did-you-mean offered tags refused as unknown (unrecognized_keys); the tombstone is never offered (acceptsNothing) rls-tags-retirement.test.ts
a stored permission row carrying tags stored verbatim stripped on rehydration by the D2 permission-rls-tags-removed, then accepted by the write door rls-tags-retirement.test.ts

The new refusal text, verbatim (RLS_POLICY_TAGS_RETIRED in rls.zod.ts)

rowLevelSecurity[].tags was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in positions. A policy is identified by its name and its object; say why it exists in description. Run os migrate meta --from 17 to list the mechanical edits for existing sources; apply them by hand.

The generated .describe() is that text prefixed with [REMOVED] (the retiredKey() helper). It replaces Policy categorization tags in content/docs/references/security/rls.mdx and permission.mdx. A repo-wide grep for the old describe string finds no pin anywhere.

Measured before changing anything (Zone 2 of the dispatch)

Each reading has a lit control on the same ref.

where ref readers of a policy's tags writers of a policy's tags lit control
objectstack plugin-security / lint / rest / objectql / runtime / metadata* / services src a78f731a 0. The only tags hits are record-field CEL (size(record.tags)), OpenAPI route tags, and the docs-audience d.tags in meta-item-read-gate.ts, and none of them is a policy. 0 in examples/** and in the plugin-security producers (default-permission-sets.ts, bootstrap-platform-admin.ts, platform-*-policies.ts, permission-set-projection.ts, security-plugin.ts) .positions read 35 times in plugin-security src; rowLevelSecurity present in all 7 producer files
objectui at the .objectui-sha pin f8a9d0fb 0. PermissionAdvancedFacets.tsx has 0 tags (its seed is {name,object,operation,using,check,enabled}, and RETIRED_RLS_KEYS is ['priority']). PermissionPreview.tsx renders ${rls.length} RLS rules. permission-slice.ts / clientValidation.ts have 0. 0 facet: .using 3, .enabled 2, priority lit
objectui main 972c1685 0 in all 3 non-test RLS files 0 facet: priority 3, .using 3
cloud main 96eb092f 0 0 (apps/ee-group-showcase security sources) rowLevelSecurity 1 in each file

So the dispatch's mechanism assumptions 1 and 2 hold. On assumption 3: RowLevelSecurityPolicySchema is a strictObject, so this is not the ADR-0104 silent-strip case. The def is reachable from the permission root, and the precedent one key over (priority) is a retiredKey() tombstone on this same closed shape. That tombstone route keeps the liveness row, which stays dead.

What changed

  • packages/spec/src/security/rls.zod.ts: tags becomes retiredKey(RLS_POLICY_TAGS_RETIRED). The const is declared above the lazy schema, per the OS_EAGER_SCHEMAS TDZ rule. The docblock records the census and the mainstream reading. The suggestion-pool comment now names both tombstones.
  • ADR-0087:
    • D2 conversion permission-rls-tags-removed in conversions/registry.ts (toMajor: 18, retiredFromLoadPath: true). It is a stripKeys delete over permissions[].rowLevelSecurity[], copy-on-write, with a fixture of 1 notice that is disjoint from every other entry.
    • The D2 is wired into MIGRATIONS_BY_MAJOR[18].conversionIds, and the step rationale is extended.
    • migrations/entries/retired-keys/18.security__RowLevelSecurityPolicy__tags.ts holds the exact key security/RowLevelSecurityPolicy:tags.
    • The family D3 entry is migrations/entries/semantic/18.permission-rls-tags-retired.ts, per ruling B on [Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152: one D3 entry per retirement family, even when D2 is lossless.
    • registry.ts generated regions were regenerated with gen:migration-registry. spec-changes.json and the upgrade guide are byte-identical, because major-18 entries do not project yet (check:spec-changes / check:upgrade-guide green).
  • Liveness: liveness/permission.json's rowLevelSecurity.tags row stays dead under its tombstone. It gets verifiedAt: 2026-09-27, evidence pointing at the tombstone, and a REMOVED note in the priority row's house style. The permission row of liveness/README.md gains one sentence. Counts are unchanged, because the row goes dead to dead.
  • Generated: authorable-surface/security.json now reads security/RowLevelSecurityPolicy:tags [RETIRED]. Two reference pages were regenerated (check:generated named only check:docs stale).
  • Tests:
    • rls.test.ts fixture triage:
      • Two incidental authorings were dropped: the complete-policy and multi-tenant cases.
      • should validate tags was replaced by a refusal pin, because it pinned exactly the retired branch.
      • The GDPR case now asserts that the purpose lives in description and in the predicate, and that no tags comes back.
      • The minimal-policy case asserts that no tags is materialized.
    • New rls-tags-retirement.test.ts (14 cases) covers every door above, the tsc channel (@ts-expect-error, compiled by tsconfig.test.json), the D2 (stored-row rehydration, per-policy scope, measured idempotence, load-path retirement) and the registration. It also has a tree-scoped absence leg with a structural matcher: an object or YAML mapping whose own keys include tags, operation and using/check. The leg has an anti-vacuity battery over 14 specimens and walks the 5 roots already declared for @objectstack/spec#test. It is listed in vitest.repo-tests.json.
  • .changeset/20321-rls-policy-tags-retired.md: @objectstack/spec minor, with a BREAKING banner, FROM → TO, and the ADR-0087 disposition registered permission-rls-tags-removed, permission-rls-tags-retired.

No form or i18n edit: permission.form.ts edits rowLevelSecurity as one json widget, with no per-key input. No skill teaches the key. There is no hand-written doc mention: content/docs/permissions/rls.mdx:231's "tags each with kernelTier" is prose about the explain engine. No objectui change is needed: the pin reads nothing of the key and does not import RowLevelSecurityPolicy['tags'].

Verification, at 62fd232a73

Heavy runs went through scripts/pm/os-verify-lock.sh. The spec dist was rebuilt at this head before any dist-reading gate.

  • pnpm --filter @objectstack/spec build: VERDICT command-exit 0, tree clean afterwards (no artifact drift).
  • pnpm --filter @objectstack/spec run typecheck → exit 0. check:test-typecheck: 53 files, 255 errors, 142 pinned signatures held. So the @ts-expect-error compiles in a real program.
  • vitest run --project local: exit 0, 554 files / 16357 tests passed, 1 todo.
  • vitest run --project repo: exit 0, 34 files / 618 tests passed. This includes the new retirement file (14/14).
  • Consumer suites (contract-face fixture triage), after building their closures:
    • @objectstack/lint vitest run: exit 0, 111 files / 4304 tests.
    • @objectstack/plugin-security vitest run: exit 0, 141 files / 2990 tests.
  • Reverse verification (one-time, no file left behind; a trap deletes the probe; git status is clean afterwards): a probe in plugin-security/src resolves @objectstack/spec/security through its exports to the built dist/security/index.d.mts.
    • Typing { …, tags: ['gdpr'] } as RowLevelSecurityPolicy → exit 2, error TS2322: Type 'string[]' is not assignable to type 'undefined' at the tags column.
    • The same literal without tags → exit 0.
    • The expected direction was red, and red was observed.
    • The first attempt was a null op, stated rather than hidden: TS 6 refused the command-line file with TS5112 until --ignoreConfig was passed.
  • The dispatch gates were re-derived on the actual change set (dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 62fd232a73, 113 lines). All were run with exit codes recorded, then reconciled: --ran → 113 derived, 112 run, 1 NOT-MEASURED, 0 UNRUN. All 112 run exit 0. That includes check:liveness (86 tombstones reached, all graded with an allowed status), check:generated (15/15 current), check:migration-registry, check:spec-changes, check:upgrade-guide, check:adr-0087-registration --base origin/main, check:changeset-no-major, check:authorable-surface, check:api-surface, check:doc-authoring, check:cross-package-test-inputs, check:nul-bytes and check:type-check-debt (re-measure, 4 entries, none above record).
  • The roster gates whose roster sits under a changed directory were also run: check-changeset-fixed, check:meta-url-spelling, check:authz-resolver, check:error-code-casing and check:filter-alias-parity. All exit 0.

NOT MEASURED: check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET, exit 3. It loads every package's built entry, and 38 workspace packages are unbuilt in this worktree (apps, connectors, most services). That is a whole-workspace build, which is CI's Build Core job.

NOT MEASURED: packages/cli integration tier (migrate-meta.e2e.test.ts replays the chain). This diff touches no bin/ or spawn entry, so it is declared to CI.

NOT MEASURED: packages/qa/dogfood expression conformance. tags carries no expression surface, and the ledger's RLS covers rows name only .using / .check.

main gained one commit (d3958bac, driver-sql only) after the base a78f731a. It is disjoint from this diff and was not merged in.

Acceptance notes (observations, not filed)


Generated by Claude Code

A retiredKey() tombstone on RowLevelSecurityPolicySchema with its
prescription, the D2 conversion permission-rls-tags-removed wired into the
protocol-18 chain step, the RETIRED_KEYS_BY_MAJOR[18] entry, the family D3
entry permission-rls-tags-retired, the liveness row kept dead under its
tombstone, and the pin tests.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…d the changeset

authorable-surface/security.json marks security/RowLevelSecurityPolicy:tags
[RETIRED]; the two reference pages print the prescription in place of the old
describe. The changeset carries the BREAKING banner, the FROM -> TO mapping
and the ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
The ledgers key one row per schema property, so the RLS block of
permission.json names operation, using, check and the retired tags row side
by side: a classification of the shape, not an authoring. Measured as the
walk's one hit before the exclusion.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/flows.mdx (via crm_case (literal, a string literal in fixture))
  • content/docs/deployment/cli.mdx (via crm_case (literal, a string literal in fixture))
  • content/docs/deployment/seed-tenancy-repair.mdx (via crm_case (literal, a string literal in fixture))
  • content/docs/protocol/objectql/security.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))

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

  • content/docs/releases/v13.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/releases/v17/17-0.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))

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
  • 5 changed file(s) yielded no anchor (packages/spec/authorable-surface/security.json, packages/spec/liveness/README.md, packages/spec/liveness/permission.json, …) — pages documenting those are invisible to this run
  • 8 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 — 136 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 eee0974236c87a7232f9805a19b6e53e3e36da59 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 006b56d5a764e21ae26c5fd4306ea294842849c8 — the merge of head 8acd22854ad0bb24f4645ba10cfdc933fa2ddedc into base eee0974236c87a7232f9805a19b6e53e3e36da59, 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 006b56d5a764e21ae26c5fd4306ea294842849c8 && git checkout 006b56d5a764e21ae26c5fd4306ea294842849c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eee0974236c87a7232f9805a19b6e53e3e36da59 8acd22854ad0bb24f4645ba10cfdc933fa2ddedc && git checkout -B drift-repro eee0974236c87a7232f9805a19b6e53e3e36da59 && git merge --no-ff 8acd22854ad0bb24f4645ba10cfdc933fa2ddedc

node scripts/docs-audit/affected-docs.mjs --json eee0974236c87a7232f9805a19b6e53e3e36da59

⚠️ 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 eee0974236c87a7232f9805a19b6e53e3e36da59 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ement

The seat's measured answer: the tombstone narrows the accept set, the D2
conversion only heals rows the load path already accepted, and no export is
added, so nothing widens. No other sentence in the changeset asserted a
widening; minor, the BREAKING banner and the ADR-0087 marker are unchanged.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 53e71951fff1cc15836bbe6b02c12002e3301f77
Local-runs: probe — the seat's brief mandates measured judgments at this tier: build-free tsx reads over src at the head and at the merge-base, git reads of objectui and cloud refs, the two changeset gates, three registry checks and the liveness audit, and ONE lock-held vitest run (a control plus the ablation through scripts/ablation-replace.mjs) for the pin-bite question CI cannot answer; nothing was built

PR #20353 (card #20321), reviewed at the head the brief named; it did not move during the review (read from the PR three times, last after every check run completed). The head is the merge fa1807f9 + 26daf0b0, so the two-dot and three-dot diffs against the base are the same 14 files, +858/−28; its merge-base with the PR base and with current origin/main (d498113b, one commit past the base: #20346, four packages/lint files, disjoint from this diff) is 26daf0b0. Measurements were taken in two detached worktrees under the scratchpad, at 53e71951 and at 26daf0b0 (pnpm install --frozen-lockfile, exit 0 in each), by tsx over src and by git; objectui and cloud were read by git grep / git show against refs only. Both worktrees were removed afterwards.

① Derived judgments

  1. Zero pull, re-counted — RIGHT. objectstack packages/** at 53e71951, non-test source: plugin-security/src has ONE tags line, explain-engine.ts:847, a docblock verb ("tags every layer with its kernelTier"), not a key; rls-compiler.ts reads policy.using (3), policy.operation (2), policy.object (2) and, loosely, object 30 / check 29 / using 21 / name 13 / operation 11 / positions 9 / enabled 3 — tags 0. Lit controls on the same instrument: .positions 36 lines and .using 12 lines in plugin-security/src. Across lint, rest, runtime, metadata, metadata-protocol, objectql, core and services/* source, the only tags line adjacent to policy or RLS is packages/lint/src/validate-rls-predicate-enforceability.ts:1406, the CEL record-field example size(record.tags) greater-than 0 inside a message string (the comparison is spelled out here because the body sanitizer eats tag-shaped fragments); no policy.tags-shaped member read exists in packages/*/src or packages/*/*/src. Writers: the six plugin-security producers that spell rowLevelSecurity (bootstrap-platform-admin.ts 1, objects/default-permission-sets.ts 4, permission-set-projection.ts 3, platform-ownership-policies.ts 1, platform-tenant-policies.ts 1, security-plugin.ts 3) spell tags 0 times each; examples/** has one rowLevelSecurity file (app-showcase/src/security/permission-sets.ts) and 16 tags lines, every one a field type, a QA tag, a translation label or a record field — none on a policy. objectui at the .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52: the four non-test files that carry rowLevelSecurity — PermissionAdvancedFacets.tsx (tags 0; controls using 5, priority 3; the facet reads pol.object ×5 and pol.using / operation / name / enabled / check ×1; RETIRED_RLS_KEYS = ['priority'] at :71; the seed at :456 is { name, object, operation, using, enabled }), clientValidation.ts 0, permission-slice.ts 0, previews/PermissionPreview.tsx 0 (:115 reads rowLevelSecurity as an array, :168 renders ${rls.length} RLS rules); no file imports the key's type. objectui origin/main 972c1685: the same four files, tags 0 (facet control using 10). cloud origin/main 96eb092f: three files carry rowLevelSecurity (apps/ee-group-showcase/src/security/index.ts, test/group-posture.dogfood.test.ts, docs/design/control-plane-unscoped-object-audit.md), tags 0 in each, and no tags beside policy or RLS anywhere. No reader, no author; the PR body's census (taken at a78f731a) holds at the head.

  2. The tombstone refuses at every door and prescribes the right replacement — RIGHT. Measured by tsx on the head source. RowLevelSecurityPolicySchema.safeParse of a well-formed policy plus tags: ['compliance','gdpr']: refused, one issue, invalid_type at ['tags'], message opening `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it … Delete the key. … list the positions in `positions`. … say why it exists in `description`. Run `os migrate meta --from 17` …; [], ['gdpr'], 'gdpr', null and {} are each refused at ['tags'] with one issue. The permission write door: getMetadataTypeSchema('permission') refuses at ['rowLevelSecurity', 0, 'tags'] (and at index 1 when the second policy carries it), with the prescription; that binding IS the door — protocol.ts:15588 saveMetaItem resolves the schema through resolveOverlaySchema (:704, which returns getMetadataTypeSchema(singular)), safeParses the item (:16184) and throws INVALID_METADATA / 422 carrying issues, and PUT /api/v1/meta/:type/:name is ledgered to meta.saveItem (rest-route-ledger.ts:250, runtime/src/domains/meta.ts:874). defineStack throws STACK_SCHEMA_INVALID, status 422, one issue at ['permissions', 0, 'rowLevelSecurity', 0, 'tags'] with the prescription, and accepts the same stack without the key. A near-miss tag: at base the refusal offered "Did you mean tag → tags?"; at head it is unrecognized_keys naming tag and offering nothing (the tombstone is out of the suggestion pool). Controls, byte-equal base vs head: a select policy carrying every live member (name, label, description, object, operation, using, positions, enabled: false) and an update policy carrying using and check parse to identical outputs on both trees; the permission set holding both parses identically through the door; defineStack on both returns an identical stack; the minimal policy parses identically and no tags is materialised. One probe control had to be corrected: its first form put check on a select policy, which an existing rule refuses on both trees alike — stated so the readings above are known to be the second, valid form. One probe artefact, not a finding: under tsx, getMetadataTypeSchema('permission') === PermissionSetSchema read false at base and head alike (both bound ZodClosedObject, identical shape keys) — a module-instance effect of the probe's import path; the file's toBe pin holds under vitest, where CI's Test Core ran it green.

  3. The D2 conversion heals stored rows and only that — RIGHT. The real load path is applyConversionsToStoredItem: packages/metadata/src/loaders/database-loader.ts:825 (rowToData) and metadata-protocol/src/protocol.ts:4782 (convertStoredItemDetailed) both call it for every stored type but flow, and it wraps a permission row as { permissions: [row] } and replays with includeRetired: true (conversions/stored.ts:100–104). Measured through that primitive at the head: a stored permission row whose two policies carry tags → two notices, permission-rls-tags-removed at permissions[0].rowLevelSecurity[0].tags and [1].tags, both policies equal their input minus tags, the set's other keys byte-equal, and the healed row is accepted by the write door; at base the same row passes through with 0 notices and tags kept. Touches no other key: a wide stack (a permission set with adminScope and description, an object whose fields has a tags column) replays to exactly the input minus the policy tags, with no notice from any other conversion. Fixture: replaying the WHOLE table (includeRetired) over before equals after exactly, 1 notice = expectedNotices, every notice owned by this id; the live-window replay (default load path) leaves before untouched with 0 notices — the second half of the disjointness contract. Replaying the converted stack a second time yields 0 notices and returns the same reference (copy-on-write, stripKeys). toMajor: 18 equals the step it is wired into; retiredFromLoadPath: true; surface is prose.

  4. Nothing else narrowed — RIGHT. z.toJSONSchema (input and output) of RowLevelSecurityPolicySchema and of PermissionSetSchema, base vs head: exactly three differing leaf paths per schema, all under the tags node — description → [REMOVED] …, type: "array" gone, items.type gone (and not: {} added); the required lists are unchanged (name, object, operation; output adds enabled on both trees). authorable-surface/security.json moves one row (security/RowLevelSecurityPolicy:tags → … [RETIRED]). Step-18 conversionIds: the base's 38 ids are a prefix of the head's 39, in order, with permission-rls-tags-removed appended; every id resolves to a conversion with toMajor 18, and every CONVERSIONS_BY_MAJOR[18] entry is wired; the id is unique in the table. RETIRED_KEYS_BY_MAJOR[18] 190 → 191, sorted, unique, the key present under no other major; MIGRATIONS_BY_MAJOR[18].semantic 222 → 223, sorted by id, unique, the D3 present under no other major. Merge-tree: a driverless bare clone (git clone --bare of the local repository, no merge driver in its config; main fetched from GitHub = d498113b) — git merge-tree --write-tree d498113b 53e71951 → tree 3f275ff5, exit 0, no conflict; in that merged tree all 14 PR files have the same blob as at HEAD (so the generated regions and registries in the merged tree are the ones measured here: check:migration-registry current at 300 semantic / 220 retired-key / 199 retired-def, sorted and resolving as above), and fix(lint)!: refuse an RLS predicate that compares a field with a json or multiple field when it is authored #20346's four files do not overlap the diff. Three PR files are merge=os-regen routed (two reference .mdx, security.json); irrelevant to the driverless probe, and byte-identical in it.

  5. Liveness and ledgers — RIGHT. liveness/permission.json rowLevelSecurity.tags: status: dead, verifiedAt: 2026-09-27, evidence pointing at the tombstone in rls.zod.ts, note opening REMOVED 2026-09-27 (#20321, ADR-0049 enforce-or-remove … in the priority house style, naming the D2 and why the row stays; the permission row of liveness/README.md gains one sentence. Local at the head: check:liveness exit 0 — 88 [REMOVED] tombstones reached and 88 graded with an allowed status (the precedent read 87; this key is the 88th), state-counts.md current (40 rows); gen:liveness-counts regenerates to an empty diff (permission 36 live / 6 dead / 42 classified, unchanged — dead to dead). CI at the head agrees: Spec property liveness success with the same lines, plus check:strictness-ledger current. check:generated currency read off CI's Type Check · source gates job (success) per constituent step: the check:/gen: classification reconcile, spec-changes.json, the upgrade guide, the authorable key surface, the generated reference docs (check:docs), the skill references, react-blocks, template manifests, objectui pin citations (47 match f8a9d0fb0) and llms.txt — every step success; Migration registry matches its entry files success in Lint & Repo Gates. Locally at the head: check:migration-registry current, check:spec-changes up to date, check:upgrade-guide up to date (major-18 entries do not project yet, so no projection change was owed).

  6. Pins bite — RIGHT. Registration first: packages/spec/vitest.repo-tests.json gains src/security/rls-tags-retirement.test.ts; the repo project's include IS that list (vitest.config.ts:89–90), test:repo runs it, and CI's Test Core runs turbo run test test:repo on every shard (ci.yml:725). The file's tree-scoped absence leg walks the five roots the @objectstack/spec entry of scripts/cross-package-test-inputs.mjs declares per extension (packages/**/*.{ts,mts,cts,js,mjs,cjs,json,md,mdx,yaml,yml}, examples/**/*.{json,md,mdx,yaml,yml}, content/**, skills/**, scripts/**) — the same extension sets the walk scans — with anti-vacuity (more than 1000 files visited, more than 20 of them spelling rowLevelSecurity) and a 14-specimen matcher battery; check:cross-package-test-inputs runs in Lint & Repo Gates (success). Its one ledger exclusion, packages/spec/liveness/, is justified by measurement: parsed as JSON, permission.json's $.props.rowLevelSecurity.children is an object whose own keys include tags, operation, using and check — exactly the structural signature — and it is the row the tombstone route requires to stay. Ablation, ONE leg, through scripts/ablation-replace.mjs in WRAP mode, held under os-verify-lock.sh (acquired on the second ticket after 281 s of queueing; held 14 s): anchor tags: retiredKey(RLS_POLICY_TAGS_RETIRED) in rls.zod.ts (hits 1, as declared) → tags: z.any().optional() ?? retiredKey(RLS_POLICY_TAGS_RETIRED) (anchor 1 → 0, blob febec4e6 → 2878c13c, the tool's own on-disk proof), running vitest run --project repo src/security/rls-tags-retirement.test.ts: 6 failed | 8 passed — the policy-schema refusal, the value-shape battery, the permission write door, the defineStack envelope, the did-you-mean pin (the pass-through re-enters the suggestion pool, so tag → tags is offered again) and the tsc pin's parse leg went red; the controls, the three D2 legs, the registration and the tree walk stayed green, as they must. Control before the mutation, same file, same project, same lock hold: 14 / 14. Restore proven twice: the tool's blob == HEAD and empty git diff HEAD, and independently git hash-object = febec4e618cd73086460f683ca01d9e4bcaf0d1b = HEAD:packages/spec/src/security/rls.zod.ts, the anchor count back to 1, git diff HEAD --stat and git status --porcelain both empty. Three earlier tickets (a separate control, a separate ablation, and the first ticket of the retry loop) timed out in the queue at exit 99 without ever acquiring the lock; the file was never mutated by them (blob equal to HEAD throughout) and they are not counted. The @ts-expect-error compiles in a real program: tsconfig.test.json is the project check:test-typecheck compiles, run by the spec typecheck script inside CI's Type Check · workspace (success).

  7. Changeset / semver — RIGHT. .changeset/20321-rls-policy-tags-retired.md: @objectstack/spec: minor; Clause-②: no (narrowing) as the first line after the title; a **BREAKING** banner; a FROM → TO table; the one-line fix; and exactly one ADR-0087 marker, an HTML comment reading adr-0087: registered permission-rls-tags-removed, permission-rls-tags-retired. node scripts/check-changeset-no-major.mjs --base 26daf0b0 exit 0 (no major; its level axis is PR-scoped and reads on CI, where Check Changeset is success on all three runs); node scripts/check-adr-0087-registration.mjs --base 26daf0b0 exit 0, reading [BREAKING+bang+clause-②-narrowing] registered permission-rls-tags-removed, permission-rls-tags-retired (new here: both). Every sentence checked against a measurement: the compiler's reads (name, object, operation, positions, enabled, predicates; tags 0) match ①; the preview count and the editor's seed and reads match ①; "no example, default permission set or cloud source wrote it" matches ①; "refused at parse, with the prescription" matches ②; the D2 as a lossless, load-path-retired delete over permissions[].rowLevelSecurity[] matches ③; the registry entries match ④; the row staying dead matches ⑤; [RETIRED] and the regenerated pages are in the diff; os migrate meta --from 17 is the toMajor 18 spelling (the sibling priority tombstone at 17.0.0 says --from 16); 17.5.0 is the next minor from 17.4.0 and the spelling of the nine other 17.5.0 tombstones already on this release; the launch-window sentence matches check-changeset-no-major.mjs's own header; "out-of-repo consumers NOT MEASURED" is an honest bound. The PR body's clause line and the changeset agree; the claim's yes (triage's execution note) is superseded by the seat's measured no (narrowing), and both gates read the same disposition either way.

  8. CI at the head — RIGHT. Waited until every run completed (last completion 2026-09-28T02:20:05Z): 46 check runs on 53e71951, 39 success, 7 skipped, 0 failure, 0 cancelled. The skips are all expected: Packed-tarball smoke (opt-in) twice (opt-in), Auto Label and Check PR Size on the two secondary workflow runs (both ran and passed on the primary), and Console Pin Gate (the objectui pin did not move). The seven required contexts are success — Lint & Repo Gates, TypeScript Type Check (with source gates, workspace, consumer gates, debt ledger), Test Core (with shards 1/6–6/6, which run turbo run test test:repo, the population that holds rls-tags-retirement.test.ts), Dogfood Regression Gate (1–3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — plus Build Docs, Dogfood Verify CLI, Check Changeset ×3, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, the four claim / single-writer guards and filter. Vercel commit status: success. No red to name.

② Semver level

minor + declared breaking is right. The diff narrows a published accept set — RowLevelSecurityPolicySchema, the permission write door and defineStack refuse a key they accepted — and widens nothing: no export is added, no accept set grows, and the D2 heals only rows the load path already accepted; the ADR-0087 registrations are ledger data, as the two nearest retirements (#20343 / #20295, #20238) declared them. Clause-②: no (narrowing) is the measured value and supersedes the claim's yes; the gates read the same disposition (registered) either way. patch would be wrong (a refusal of previously accepted input); major is refused by check-changeset-no-major in the launch window, and the breaking-ness is carried by the banner, the (narrowing) arm and the ADR-0087 marker.

③ Boundary flags

Implemented-by: claude/issue-20321-rls-tags-retire
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 03:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 28, 2026
…s-tags-retire

# Conflicts:
#	packages/spec/src/conversions/registry.ts
#	packages/spec/src/migrations/registry.ts
…tree

The two pages are os-regen artifacts both sides moved (main's description
rewrites, this branch's tags tombstone row); regenerated with gen:schema and
gen:docs after the merge rather than text-merged. Against origin/main each
page differs only in the tags row.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat verification: base-merge round · head da5d40167a424199394a4298a726638beb52fbfd adopts the at-tier record 5862589446 · 2026-09-28T04:27Z

domain:spec seat 1 (session_01Rjy9MeetSfq34PKn81CRiN). The at-tier review PASSED at 53e71951 (record 5862589446). The queue removed the PR at 03:31Z with MERGE_CONFLICT after PR #20262 (569d4d2d) landed, because both add to conversions/registry.ts and the step-18 conversionIds. This round only brings main in, so the seat verifies that the increment is unchanged instead of re-reviewing the contract.

What the round is. Merges of 569d4d2d, a88a1bb3 and 862b6ce8, each through scripts/pm/os-regen-merge.sh (no rebase, no force-push), plus 4e3412f0, which regenerates rls.mdx / permission.mdx from the merged tree.

  • conversions/registry.ts keeps main's file whole, with this PR's permissionRlsTagsRemoved block re-inserted byte for byte.
  • The step-18 list is main's, in order, with permissionRlsTagsRemoved appended.
  • conversionIds is main's, in order, with permission-rls-tags-removed appended.
  • The generated regions come from gen:migration-registry.

Verified by the seat

  • Three-dot increment: git diff --stat of 53e71951 against its merge-base and of da5d4016 against origin/main are byte-identical: 14 files, +858/−28.
  • Per-file identity: the added and removed lines of each file's increment hash identically at both heads, except migrations/registry.ts. There the one moved line is the joining period: the rationale's previous last sentence, now feat(spec)!: form layout accepts only vertical | horizontal — the inline and grid arms retired (#20221) #20262's form-layout sentence, ends '. ' so that this PR's RLS tags sentence follows it.
  • Driverless merge-tree onto the current main 862b6ce8 (a bare clone, no os-regen driver): exit 0, adding exactly the PR's 14 files, +858/−28.
  • The dev's checks at da5d4016, exit 0:
    • the spec build;
    • conversions, migrations and rls suites: 581 tests, including the fixture-disjointness replay;
    • rls-tags-retirement.test.ts 14/14;
    • check:migration-registry, check:generated 15/15, check:liveness, check-adr-0087-registration.

The contract judged in 5862589446 is unchanged at this head. Auto-merge is re-enabled once CI at da5d4016 is green or shows only expected skips.

…s-tags-retire

# Conflicts:
#	packages/spec/src/conversions/registry.ts
#	packages/spec/src/migrations/registry.ts
…om the merged tree

The merge took main's generated regions; gen:migration-registry puts this
branch's two entries back (retired-key security/RowLevelSecurityPolicy:tags
and the D3 semantic entry permission-rls-tags-retired) beside #20251's.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat verification: base-merge round 2 · head 8acd22854ad0bb24f4645ba10cfdc933fa2ddedc adopts the at-tier record 5862589446 · 2026-09-28T05:12Z

domain:spec seat 1 (session_01Rjy9MeetSfq34PKn81CRiN). PR #20251 landed as 67c98f6a, and a merge of da5d4016 onto it conflicted in the shared step-18 lists, so auto-merge stayed off. This round brings main (eee09742) in once more.

  • The round. Merge eff5fc61 (through os-regen-merge.sh), then 8acd2285, which regenerates the migration registry's generated regions (302 semantic / 221 retired-key / 199 retired-def).
    • conversions/registry.ts keeps main's file whole, with permissionRlsTagsRemoved appended after currencyConfigPrecisionRemoved.
    • conversionIds is main's, in order, with permission-rls-tags-removed appended.
  • Three-dot increment: byte-identical to 53e71951 (the reviewed head): 14 files, +858/−28.
  • Per-file identity: the increment's lines hash identically except for the one joining-period line in migrations/registry.ts, now on feat(spec)!: retire currencyConfig.precision — a currency's decimal places are its currency's (ADR-0049) #20251's rationale sentence.
  • Driverless merge-tree onto the current main eee09742: exit 0, adding exactly the 14 files.
  • The dev's checks at 8acd2285, exit 0:
    • the spec build;
    • conversions, migrations and rls: 583 tests, including the fixture-disjointness replay and the ruling-B census pin;
    • rls-tags-retirement.test.ts 14/14;
    • check:migration-registry, check:generated 15/15, check:liveness, check-adr-0087-registration.

The contract judged in 5862589446 is unchanged at this head. Auto-merge is re-enabled once CI at 8acd2285 is green or shows only expected skips.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 17e4f52 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20321-rls-tags-retire branch September 28, 2026 05:58
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… capability (objectstack-ai#20364)

Fixes objectstack-ai#20354
Clause-②: no

## What changed

One rule text in `.claude/skills/spec-property-retirement/SKILL.md` §0.
No new gate, no code, net 0 lines.

1. **The exemption bullet becomes the maintainer's criterion.** The old
bullet 「它是文档形状的吗?」 ended with
「良性展示元数据(`description`、`tags`、`icon`)永远谈不上「误导」;不要标 `authorWarn`,也不要退役它。」
It is replaced by one bullet that states the per-family criterion in the
maintainer's own words and derives the documentation-shaped case from
it:
- header 「主流平台有没有这个能力?」 and body 「有 ⇒ 补消费端(一次做对);没有 ⇒ 退役,而不是看仓里有没有人读」:
both halves of the ruling sentence, quoted verbatim, with the ruling
record cited as comment `5727134555`;
- documentation-shaped keys (`hook.label`, `flow.description`) record
intent for the next reader (ADR-0033), and the text now calls them an
instance of the 「有」 branch: render, do not retire. The verdict still
goes into the ledger `note` so the next audit does not re-open it. It is
one criterion's outcome, not a second rule;
- `description`, `tags` and `icon` are no longer exempt by kind. The
row-level-policy `tags` key is named as the 「没有」 example (mainstream
platforms have no such attribute, so it retires);
- whether to mark `authorWarn` now defers to the liveness README's
author-warning rule, which is unchanged. The playbook no longer carries
a second copy of it.
2. **One more line in §0 carried the rejected criterion.** The bullet
「零编写实例」≠「没有代码读它」 ended 「后者才是退役的证据」, which says "nobody reads it" is the
evidence for retirement. That is the in-repo-reader test the ruling
rejects, and it would have sat eight lines under the new bullet and
contradicted it. It now ends 「后者才证 `dead`」. An unread key is `dead`, and
`dead` is what the criterion is asked about. It does not answer the
criterion. Same defect class, same section, one line, 8 bytes shorter.
It sits inside the claimed file surface (§0) and is named here so review
can take or drop it separately.

Before (lines 43–47 at `8af914a3`):

```text
- [ ] **它是文档形状的吗?** `hook.label`、`hook.description`、`flow.description`
      没有运行时消费者,但被**有意保留** —— 它们为下一个读者(按 ADR-0033,常是模
      型)记录意图。把豁免写进台账 `note`,下次审计不再重审。良性展示元数据
      (`description`、`tags`、`icon`)永远谈不上「误导」;不要标 `authorWarn`,也
      不要退役它。
```

After:

```text
- [ ] **主流平台有没有这个能力?** 声明而未执行的键每族问这句,按维护者原话(裁决评论
      `5727134555`):「有 ⇒ 补消费端(一次做对);没有 ⇒ 退役,而不是看仓里有没有人读」。
      文档形状的键(`hook.label`、`flow.description`)为下一个读者记录意图(ADR-0033),是「有」的
      一例:补渲染、不退役,判定写进台账 `note`,下次审计不再重审。`description`、`tags`、`icon`
      不自动豁免:RLS 策略的 `tags` 主流没有 ⇒ 退役。`authorWarn` 另按 README 判。
```

## Why

The maintainer's ruling on objectstack-ai#18900 (comment `5727134555`, item ④, A′),
verbatim: 「18900 同意 每族该问的是:主流平台有没有这个能力 —— 有 ⇒ 补消费端(一次做对);没有 ⇒
退役,而不是看仓里有没有人读。」 The ruling record's own gloss is "a family is judged by
whether mainstream platforms in the domain have the capability, ⛔ not by
whether anything in this repo reads the key". Triage applies it to every
family card. The playbook said the opposite for `description` / `tags` /
`icon`, and a dev on the RLS family already hit the fork: objectstack-ai#20353 retires
a `tags` key the old text forbade. Triage direction (5862082626): "the
ruling wins, and the text follows it". The documentation-shaped
exemption is kept as the case where the mainstream carries the
capability, which is how objectstack-ai#20299 was graded (render, do not retire).

## Inventory: other texts that restate the old exemption (triage note 2)

Searched at `8af914a3` with `git grep` over `.claude/**`, `skills/**`,
`AGENTS.md`, `CLAUDE.md`, `docs/**` (ADRs included), `content/docs/**`,
`packages/spec/liveness/**`, `packages/spec/README.md` and
`packages/lint/src/lint-liveness-properties.ts`. Patterns: 良性 / 展示元数据 /
benign display / display metadata / 永远谈不上 / never retire / 不要退役 / 文档形状 /
docs-shaped / 有意保留 / `authorWarn` / 主流平台 / mainstream / 零消费者.

| Surface | Restates the exemption? | Action |
|---|---|---|
| `.claude/skills/spec-property-retirement/SKILL.md` §0 | yes, the two
sentences above, plus the line 61 corollary | edited here |
| other `.claude/skills/**`, `.claude/agents/**` | no, 0 hits
(`authorWarn` has exactly 1 hit in `.claude/**`, the §0 line) | none |
| `skills/**`, `AGENTS.md`, `CLAUDE.md` | no, 0 hits
(`skills/objectstack-ai/SKILL.md:225` "Display metadata" is a table cell
describing `label`/`description`, not an exemption) | none |
| `docs/adr/**` (ADR-0033, ADR-0049 read) | no, neither carries a
docs-shaped or benign-display exemption | none |
| `packages/spec/liveness/README.md` "Author warnings", rule 1
(`:562–565`) | only the `authorWarn` half: benign display metadata is
not **warned**. It never says "never retire" | none, and outside this
file surface anyway (`packages/**`). The new §0 points at it rather than
restating it |
| `packages/lint/src/lint-liveness-properties.ts` header (`:21`) | same
`authorWarn` half only | none |

The ruling does not address warnings, and the README's warning rule is
consistent with it. A key the mainstream carries is a missing consumer,
and warning its author would call it misleading. A key the mainstream
lacks retires. So the family to fix is this one file.

## PM mechanism hypotheses, as measured

1. **Site**: confirmed. The branch starts at `8af914a3` (`67047171` plus
one unrelated docs commit). The bullet spans `:43–47` and the two target
sentences `:45–47`.
2. **Inventory**: confirmed, only §0 in the governed texts (table
above). One addition: the §0 line 61 corollary, fixed in place (item 2
above).
3. **Line ratchet**: holds at headroom 0. Details under Line budget.
4. **`check:pm-skill-id-lint`**: it does **not** scan this file. Its
scan set is the pm-dispatch tree, `os-dev.md` and `AGENTS.md` (34 files,
green), and this playbook carries 20 existing tracker-number citations.
The bare comment id does not match its `#` pattern either. So the ruling
record id stays in the rule text, as the triage direction asked, and no
tracker number is added to the file.
5. **Documentation-shaped keys as the criterion's outcome**: the wording
is 「是「有」的一例」, the same criterion's result and not a second rule.

## Line budget

| | before | after |
|---|---|---|
| `SKILL.md` lines (ceiling 337) | 337 | 337 |
| widest table row, bytes (pin 326) | 326 | 326 |
| bytes | 25,497 | 25,639 (+142) |
| tokens, `ceil(bytes/4)` (the token ratchet's convention) | 6,375 |
6,410 (+35) |
| whole skill package (`.claude/skills/spec-property-retirement/`, 1
file) | 337 lines / 6,375 tokens | 337 lines / 6,410 tokens |

Paid by deletion in the same bullet. Removed: 「没有运行时消费者,但被有意保留 —— 它们」,
the `hook.description` example, 「按 … 常是模型」, and the whole 「良性展示元数据 …
也不要退役它」 sentence. No re-wrap of untouched text. New lines are 116 / 115
/ 115 / 119 / 102 bytes, and line 61 goes from 120 to 112. Every line
break falls between two wide characters or at a real space, so none
renders as a stray space. No ceiling was raised and nothing moved to
another file.

## Verification (HEAD `085a5026`)

The derived list comes from `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (18 commands, re-derived after
`git fetch origin main`, same list). Every command ran. Exit codes were
captured before any pipe.

| Command | Exit |
|---|---|
| `node scripts/check-ci-filter-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs` (and `--self-test`) |
0 / 0 |
| `node scripts/check-comment-mask-corpus.mjs` | 0 |
| `node scripts/pm/check-harness-current.mjs --self-test` | 0 |
| `pnpm --filter @objectstack/lint run check:doc-formula-expressions` |
3 (prerequisite not met: formula and lint not built, so NOT MEASURED);
built both under the verify lock, then 0 |
| `pnpm check:agent-test-spelling` | 0 |
| `pnpm check:cross-package-test-inputs` | 0 |
| `pnpm check:doc-authoring` | 0 |
| `pnpm check:driver-memory-census` | 0 |
| `pnpm check:gitlink-declared` | 0 |
| `pnpm check:nul-bytes` | 0 |
| `pnpm check:pm-governed-merges` | 0 |
| `pnpm check:pm-skill-ratchet` | 0, "SKILL.md is 337 lines (ceiling
337; headroom 0)" and "widest table row is 326 bytes (pin 326; headroom
0)" |
| `pnpm check:refd-timer-probe` | 0 |
| `pnpm check:required-contexts` | 0 |
| `pnpm check:skill-frame-sync` | 0 |
| `pnpm check:watch-hint-literal` | 0 |

Reconciliation: `dispatch-gates --ran` with coded exits reports "18
derived famil(ies) accounted for — 18 run, 0 NOT-MEASURED (a DERIVED
zero)".

Also run:
- **Test Core job, scheduled by this path.**
`packages/spec/src/shared/retired-key-migrate-sentence.test.ts` reads
this playbook. `pnpm --filter @objectstack/spec exec vitest run
--maxWorkers=2 src/shared/retired-key-migrate-sentence.test.ts` passed:
1 file, 14 tests, exit 0. The rest of the shard is left to CI.
- **Roster gates under `.claude`/`skills`, which the derivation marks as
not placeable.** Each exited 0: `check:pm-settings-deny-roster`,
`check-skills-token-ratchet.mjs`, `check:pm-skill-id-lint`,
`check:pm-governed-prose`, `check:skill-top-level-keys` and
`check-published-list-mirrors.mjs`.
- **Type-check lanes: NOT MEASURED.** Reason: the diff touches no
TypeScript.
- **Ablation: none owed.** This PR adds no gate or assertion.

## Changeset

`skip-changeset`. The only path is under `.claude/**` (fast lane). As a
measurement, the root package that holds `.claude/` is `private: true`,
no workspace `package.json` names `.claude`, and the new text
「主流平台有没有这个能力」 has exactly one hit in the tree, this file. Positive
control: the liveness README sentence "Only mark genuinely" is found
under `packages/spec/liveness/`, which `@objectstack/spec` ships through
`files[]` (`liveness`).

## Acceptance notes

- **Byte cost.** The rule text grows by 142 bytes at net 0 lines. That
is the verbatim criterion replacing a 24-byte tail line. The line
ratchet meters lines under a 120-byte cap, and every new line is within
it.
- **Examples trimmed.** `hook.description` left the example list, and 「按
ADR-0033,常是模型」 is shortened to 「(ADR-0033)」, to pay for the criterion
inside the same five lines.
- **Ledger notes are data.** Several ledger row notes still say "exempt
from enforce-or-remove (ADR-0033)" or "benign display metadata — not
authorWarn'd", for example the `job` / `datasource` label rows and the
RLS policy label row. They are left as they are. Their keep /
do-not-warn outcomes agree with the criterion for keys the mainstream
carries. objectstack-ai#20299 carries the render for seven such keys, and family
grades re-grade rows as they land (objectstack-ai#20353 retires a tags key on the RLS
family).
- **Same-day history.** objectstack-ai#20254 landed on this file today, before this
branch point. No open PR touches the file (the claim's read).
- **Governance.** Tier S, governed surface `.claude/**`. Draft. It lands
through the skills seat's contract review. This PR does not flip it to
ready, arm auto-merge or merge it.

## 维护者速读(草稿)

- **改了什么**:退役手册(`spec-property-retirement`)§0
那句「良性展示元数据(description、tags、icon)永远不退役」,换成您在 objectstack-ai#18900
定的判据原话:每族问主流平台有没有这个能力,有就补消费端,没有就退役,不看仓里有没有人读。hook.label、flow.description
这类文档型键仍然保留,但理由改成「主流平台也有」,是同一条判据的结果,不再是另一条豁免规则。同节另一句「没有代码读它才是退役的证据」一并改成「才证
dead」。
- **为什么改**:手册和您的裁决相互矛盾。已经有开发按裁决退役了 RLS 策略上的 tags 键,违背了手册原文。今后每张含
description / tags / icon 成员的族卡,都会让先读手册的 agent 卡在这个分叉上。
- **风险与代价(含回滚)**:只改给 agent 读的规则文本,不动代码、schema 和门禁;行数净 0,字节 +142。回滚就是
revert 这一个 commit。可能的误读是把「补消费端」当成所有文档型键都要马上补渲染,实际仍按每族定级排期。
- **席位意见**:
- **你要做的**:无需操作。本单属 Tier S,由技能席契约复核后落地;若对判据的写法有意见,在本 PR 留言即可。

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…(ADR-0049) (objectstack-ai#20398)

Fixes objectstack-ai#20323

Clause-②: yes

## What this does

Retires `action.aria` under ADR-0049 enforce-or-remove, following the
triage direction on the card (RETIRE, on the chart-config precedent
`2bf6ef18d`). No maintainer word reversed it to ENFORCE.

- **Schema.** `ActionSchema.aria` becomes a `retiredKey()` tombstone. It
is a `tsc` error (the input type is `never`) and a parse error that
carries the prescription. The accessible name that IS applied is the
action's required `label`, and the placing node's `aria` block
(`page.components[].aria` or the list view `aria`) names the region.
`AriaPropsSchema` is untouched: this is a key retirement, not a def
retirement.
- **ADR-0087.** The D2 conversion `action-aria-removed` (protocol 18,
`retiredFromLoadPath`) strips the key from `actions[]` and
`objects[].actions[]` as a lossless delete. The D3 semantic entry
`action-aria-retired` is its own family, per the one-entry-per-family
rule. The retired key `ui/Action:aria` is registered under 18, the major
of the chart-config sibling.
- **Ledger.** `liveness/action.json` regrades `aria` from `live` to
`dead`. Its `REMOVED` note records that the uncited 「PARTIAL — honored
by a few objectui renderers」 claim had no reader behind it. The
undrilled-container row `action/aria` goes, and `state-counts.md` is
regenerated.
- **Docs.** Two hand-written pages taught `aria` on an action and are
corrected: `protocol/objectui/actions.mdx` and `widget-contract.mdx`.
The three reference pages that render `ActionSchema` are regenerated.
- **Changeset.** `minor`, BREAKING, with `Clause-②: yes`, the FROM → TO
table and the ADR-0087 `registered` marker.

## Premise, re-measured on this checkout

- **objectui at the `.objectui-sha` pin `f8a9d0fb05`.** A reader grep
for an action's `aria` (`action`, `actionDef`, `def`, `spec`, `btn`,
`a`, `act`, `item` followed by `.aria`) over `packages/**` and `apps/**`
non-test sources hits **0** lines. The control, the same grep for
`.variant`, hits **18** files. Every `schema.aria` reader there is a
placing node: the `record:*` components, `ListView`, `ObjectView` and
`element:button`'s props.
- **Icon-only reversal condition.** Triage named "icon-only actions have
no accessible name" as the condition that would reverse this to ENFORCE.
It does not hold. `action-icon.tsx:243` renders
`aria-label={schema.label || schema.name}`, `action-menu.tsx:341`
renders `aria-label={schema.label || moreActionsLabel}`, and
`action-button.tsx:346` renders `{schema.label}` as the visible text.
- **Framework.** A grep for `.aria` over `packages/**` non-test TS
outside `packages/spec` hits **0** lines. `action.form.ts` has 0 `aria`
rows; its one hit is the `variant` substring.
- **Authors in this repo.** Across `examples/**`, the published
`skills/**` and `packages/**` fixtures, **0** actions author `aria`. The
control is **15** `variant:` lines in `examples/**`.
- **Authors in HotCRM `2f7b2326`.** **0** actions author `aria`. Its 6
`aria:` blocks are all page-level `page.aria`, which stays live. The
control: 7 files under `src/**/actions/` declare `locations:`, 17 times.
- **Served schema.** `metadata-protocol` drops a tombstone's `{ not: {}
}` node from the served JSON Schema, via `stripUnauthorableProperties`.
So the Studio "More fields" form stops offering `aria` once this ships.
- **Pinned sibling.** objectui's `ActionRunner.ts:410` mirrors the key
as `aria?: SpecActionInput['aria']`. A type probe against this branch's
built `dist` compiles that mirror at exit 0, because it evaluates to
`undefined`. An authored block on it is refused with TS2322, and the
control leg without `@ts-expect-error` exits 2. So the Console Pin
Gate's objectui build is not broken by this change.

## Hand-over review (the stopped run's six commits, read hunk by hunk)

| commit | verdict |
| --- | --- |
| `0c5dbbff` sources, ledger, tests, changeset | **kept, with
corrections.** The prescription said "removed in @objectstack/spec 17";
it now says `17.5.0`, the spelling every sibling retirement on this line
uses. The refusal pin in `action.test.ts` now also asserts the
tombstone's own issue kind (`invalid_type` at `aria`, and no root
`unrecognized_keys`, which is what a bare deletion would answer
instead). The changeset's BREAKING sentence now names the replacement.
The HotCRM control is re-measured: 7 files and 17 lines, not "5 action
files". |
| `6334c137` regenerated artifacts | kept, and re-derived after both
merges |
| `bfc86375` the stored-row pin uses a parseable script action | kept |
| `0f20c62f` the `action/aria` undrilled-container row goes | kept;
`check:liveness` is green |
| `8cbcfa46` merge of `main` | kept |
| `90b8fcdc` regenerated state counts | kept; superseded by the
post-merge regeneration |

Added in this round:

- `33407119` and `c38c18ae` merge `origin/main` through
`scripts/pm/os-regen-merge.sh`. Both merges stopped on the two
registries. Each was settled by stacking both sides: main's conversions
and rationale paragraphs first, then this branch's. The merges brought
in objectstack-ai#20262, objectstack-ai#20352, objectstack-ai#20251 and objectstack-ai#20353.
- `2b15085d` and `7faf0e9f` are the deferred regenerations.
- `68b038ef` carries the corrections above, plus two more:
- a new pin that the stored-row seam reaches an `object` row's nested
action (the changeset's second at-rest coordinate);
- one sentence on the `action` row of `liveness/README.md`. That row
said "makes the dead set three", which this change makes false in a
published file (`liveness` is in `@objectstack/spec`'s `files`).
- `8dd3a2ce` regenerates the reference pages for the 17.5.0
prescription.

## Verification

Head `7faf0e9f`, on `origin/main` `15bf186f`:

- `pnpm --filter @objectstack/spec test`: `Test Files 558 passed (558)`
· `Tests 16509 passed | 1 todo (16510)`.
- `pnpm --filter @objectstack/spec test:repo`: `Test Files 35 passed
(35)` · `Tests 634 passed (634)`.
- `pnpm --filter @objectstack/spec check:generated`: all 15 generated
artifacts up to date, measured over a spec build made on this head.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 114
commands. All 114 exit 0, and `--ran` reconciles them as `114 derived,
114 run, 0 NOT-MEASURED, 0 UNRUN`. The run covers every package's build
closure, rebuilt on this head, so the gates that read `dist` measured
it.

Head `8dd3a2ce`, before the second `main` merge, on `origin/main`
`862b6ce8`. The second merge touched none of these packages' interaction
with this diff; the incoming commits retire other keys and touch no
action surface.

| package | command | Test Files | Tests |
| --- | --- | --- | --- |
| `@objectstack/lint` | `vitest run` | 112 passed | 4640 passed |
| `@objectstack/cli` | `vitest run --project unit` | 231 passed | 3309
passed |
| `@objectstack/runtime` | `vitest run --project local` | 282 passed |
4059 passed, 1 skipped |
| `@objectstack/metadata-protocol` | `vitest run` | 189 passed, 3
skipped | 2736 passed, 19 skipped |
| `@objectstack/metadata-core` | `vitest run` | 16 passed | 285 passed |
| `@objectstack/objectql` | `vitest run --project local` | 322 passed |
5857 passed |

`pnpm --filter @objectstack/spec typecheck` was green at the same head.
The cli `integration` layer is declared to CI: this diff touches no
spawn entry and no integration file.

**Ablation**, from the committed state at `8dd3a2ce`. The blobs of
`action.zod.ts` and of the three pin files are byte-identical at
`7faf0e9f`.

- The mutation goes through `scripts/ablation-replace.mjs`: the anchor `
aria: retiredKey(` becomes ` aria: z.any().optional().describe(`, so the
key is accepted again. The tool reports the anchor going from 1 to 0,
the replacement from 0 to 1, and the blob from `2e0a17b14b06` to
`45e6bd9b1156`. The wrapper also arms a `trap` that restores the file.
- The control leg runs the three pin files on the committed state:
`Tests 166 passed (166)`.
- The mutant leg: `Tests 4 failed | 162 passed (166)`. The four red
tests:
  - `action.test.ts` · refuses an action carrying `aria`;
- `aria-carrier-tombstones.test.ts` · the action tombstone fires and
prescribes;
  - `aria-carrier-tombstones.test.ts` · the object-nested coordinate;
  - `action-aria-removed.test.ts` · the stored-row seam.
- The restore is proven by content, not by an exit code. The file's blob
equals HEAD's blob `2e0a17b14b06`, `git diff HEAD` is empty, and the
porcelain status has 0 lines.
- The pins import `./action.zod` and `../ui/action.zod.js` relatively.
They read `src`, not `dist`, so this ablation has no dist leg.

## Acceptance notes

- **objectui, owned by seat 4 after landing; not in this PR.**
`ActionDefaultInspector` should list `aria` in its `RETIRED_FIELDS`, per
triage note 3. `ActionRunner.ts:410` should drop its `aria?:
SpecActionInput['aria']` mirror, which evaluates to `undefined` once
this ships and still compiles (the probe above).
- **objectstack-ai#19332, owned by seat 4 after landing.** The disposition of
`action.aria` goes to objectstack-ai#19332's item that waits on this card.
- **Advisory lint.** No `lint-liveness-properties` non-warn pin is
added. The `aria` row never carried `authorWarn`, so the advisory lint's
behaviour is unchanged: it was silent before and is silent now.
- **Aliases.** `ActionSchema` never aliased `accessibility` or
`ariaProps` onto `aria`, unlike the chart config. A probe shows both
already refused as `unrecognized_keys`, so no alias refusal pin is owed.

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

---------

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.

spec(security): retire rowLevelSecurity[].tags (1 key); no mainstream platform tags a row-level policy

2 participants