Skip to content

fix(spec): PageSchema's rejection guidance stops prescribing assignedProfiles as a page gate - #17401

Merged
os-bill merged 1 commit into
mainfrom
claude/issue-16929-assignedprofiles-adr0090-d2
Sep 10, 2026
Merged

os-bill merged 1 commit into
mainfrom
claude/issue-16929-assignedprofiles-adr0090-d2

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Part of #16929 — and deliberately only part of it. See "Why this is Part of, not a closing keyword" below; the card's central question is NOT answered here and the card must stay open.

Clause-②: yes — declared at dispatch on the card's shape. This PR carries needs:contract-review and does not enqueue without an at-tier verdict. See "Clause-② reading" below: the measurement says this diff moves no accept set, but re-declaring that is the seat's act, not the implementing lane's.

Authored by Claude Code in session session_01MkQhmuuJAVDjmeWNixwDDH, dispatched by the domain:spec execution seat.

What this PR changes

Two strings, in packages/spec/src/ui/page.zod.ts. Both are guidance prescriptions — the text PageSchema hands an author when it rejects a wrong-layer key — and both ended by pointing at assignedProfiles:

  • visibleWhen said "…or gate the page with assignedProfiles"
  • permissions said "…reach it through assignedProfiles, and gate the DATA it shows…"

Neither is true. assignedProfiles gates nothing. Prescribing it is Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered at parse time, inside the error whose whole job is to teach the correct spelling. Both prescriptions now state only what the platform actually does.

Nothing else is touched. The assignedProfiles declaration, the profiles: / assignedTo: alias entries, page.form.ts, the four locale bundles and the liveness ledger are all left exactly as they are.

What the key does today

assignedProfiles: z.array(z.string()).optional() on the published PageSchema — untyped strings, no .describe(). It is accepted, stored, round-tripped, and read by nobody.

What the alias map actually does — the card's mechanism, re-measured

This is the finding that reframes the whole card. aliases on strictObject does not accept a key and rewrite it. strict-object.ts says so in its own words: "an alias runs only from the unrecognized_keys path, so a declared key can never reach it". An alias only decorates the rejection message with a "Did you mean" rename.

Runtime probe against the built dist, before and after this diff, with a lit control (a key known to be accepted) and a dark control (a fabricated key):

Probe Authored key success What came back
A (the claim) profiles: false unrecognized_keys — "Did you mean profiles → assignedProfiles?"
B assignedTo: false unrecognized_keys — "Did you mean assignedTo → assignedProfiles?"
C (lit control) assignedProfiles: true parses; value preserved
D (dark control) grobnicators: false unrecognized_keys, no rename offered
E visibleWhen: false the prescription under edit
F permissions: false the prescription under edit

C accepting and D offering no rename together prove the instrument fired and that the rename in A and B is the alias table's doing.

Therefore: profiles and assignedTo are NOT in PageSchema's accept set. They are refused today and would be refused with the alias entries deleted. The card's title says the alias map "corrects an authored profiles: into it" — the effect is real (the author is steered into retired vocabulary) but the mechanism is a suggestion in a rejection, not an acceptance. Contrast the real thing two hundred lines away: ADR-0089's visibility → visibleWhen on a page component genuinely is accept-and-rewrite (page.test.ts:1150).

Consumer census — every probe with what it matched

⭐ A count is not a reading until you look at what it matched. Every absence below carries a lit and a dark control.

This repo, origin/main e1eee43beb — assignedProfiles case-insensitive: 25 files. Every one classified by hand:

  • generated docs under content/docs/references/ (8 rows) and one hand-written docs table row
  • docs/adr/0010 — a 2022-era open question, prose
  • docs/audits/2026-06-pageschema-property-liveness.md and …-security-identity-… — two prior audits that already list it as zero-consumer
  • three CHANGELOG.md files (release-owned)
  • packages/metadata-protocol/src/protocol.ts:11818 — the comment the card quotes
  • four *.metadata-forms.generated.ts locale bundles
  • authorable-surface.base.json + authorable-surface/ui.json — generated baselines
  • liveness/page.json, liveness/view.json — the ledger
  • packages/spec/src/api/protocol.zod.ts, ui/view.zod.ts — prose
  • packages/spec/src/ui/page.form.ts — the form field
  • packages/spec/src/ui/page.zod.ts — the declaration, the aliases, the two strings
  • packages/spec/src/ui/page.test.ts — the schema's own round-trip assertion

Readers: zero. Controls: visibleWhen 168 files (lit), fabricated assignedGrobnicators 0 files (dark), snake-case assigned_profiles 0 files.

objectui, origin/main 3fbdd4a2d (read-only; this repo's PR touches nothing there) — assignedProfiles case-insensitive: 3 files, each inspected:

  • content/docs/api/schema-reference.md:140 — a docs table row
  • packages/types/src/layout.ts:795 — a TypeScript declaration
  • packages/types/src/zod/layout.zod.ts:459 — a zod declaration

Readers: zero. Controls: visibleWhen 308 files (lit), PageSchema 94 files (lit), fabricated term 0 files (dark), assigned_profiles 0 files. This confirms triage's reading at 42ddd7f71 one day later and at a moved head (the layout.ts line number shifted 829 to 795 — same claim, different line).

cloud — NOT MEASURED. The repository is not present in this container, so there is no instrument to fire. This is an absence of measurement, not a measurement of absence.

Three prose claims this falsifies

  1. packages/metadata-protocol/src/protocol.ts:11818 — the key "is enforced where it is enforced now, at page render". Page render is objectui's; objectui does not read it. False. (Already falsified by triage; re-measured here at a newer head.)
  2. packages/spec/liveness/page.json grades assignedProfiles live, on the strength of "objectui bridges it (react/src/spec-bridge/bridges/page.ts)". That path does not exist in objectui, and neither does any spec-bridge directory. Lit control: two sibling objectui citations in the same ledger file — packages/components/src/renderers/layout/page.tsx and packages/app-shell/src/views/PageView.tsx — both resolve. So the ledger can cite objectui correctly; this entry does not.
  3. packages/spec/liveness/view.json:125 leans on the same claim: a type: 'page' view is safe because "the page keeps its own audience gate (page.assignedProfiles)". That gate does not exist.

Claims 1 and 3 are out of this PR's declared file face and are filed below rather than fixed here.

The options, and which move the accept set

Option Moves the published accept set? Blast radius Whose call
A Remove assignedProfiles YES — narrows. A page authoring it validates today, refused after ADR-0087 conversion + retiredKey tombstone, both authorable-surface baselines, liveness ledger, page.form.ts, four locale bundles, docs, plus objectui's two declarations cross-repo Maintainer. Fenced
B Rename the key YES — both directions. Old spelling refused, new one accepted A's, plus the new key's own declared-versus-enforced question Maintainer. Fenced
C Retarget or delete the profiles: / assignedTo: aliases NO — measured. Both keys are refused before and after; only the rejection text moves two lines in one file Fenced by the dispatch order's literal words. ⭐ The measurement above reframes this: it is not an accept-set change, so the premise the fence rests on does not hold for it. Re-grading it is the seat's act, not mine
D Stop the two guidance prescriptions naming assignedProfiles NO — measured byte-identical two strings in one file ⇒ TAKEN HERE
E Actually build page-level profile gating YES — new capability new feature Maintainer only. Triage already routed this to the human floor

Why option D moves no accept set — the reverse verification

Both channels this PR edits fire only from the unrecognized_keys path, so every key involved is refused before and after. Proven, not asserted: the probe table above was run against the built dist before the edit and again after a full rebuild, and diffed. The complete difference between the two runs is two lines — the E and F message texts. Probes A, B, C and D are byte-identical across the rebuild, including C's accepted parse, its parsed key list and its preserved value.

pnpm --filter @objectstack/spec check:generated reports all 15 generated artifacts up to date, check:authorable-surface among them — the published accept set did not move.

Why this is Part of, not a closing keyword

The dispatch order asked for a closing keyword on this card. I am not writing one, and this is flagged rather than done silently.

The order's own fence says removing or retiring the key is the maintainer's floor and that a round returning the options table is a complete round. That is the round this is. The card's central question — what happens to assignedProfiles — is unanswered, and options A, B and E all need a ruling nobody has given. A closing keyword would shut the card the moment this merges and delete the decision item from the open set.

What is left on #16929: the disposition of the key itself (A / B / E), and the re-grading of option C now that it is measured to be message-only.

Clause-② reading

Declared yes at dispatch, on the card's shape, before a diff existed. The measurement is now in: this diff moves no accept set — proven byte-identical above. Per the claim comment, if the round's measurement shows that, the seat re-declares, not the dev. So the declaration stands as yes here, needs:contract-review is applied, and the re-grading is handed to the seat with the evidence.

The changeset grades @objectstack/spec minor, satisfying the level rule that applies with Clause-②: yes.

Verification

Run at origin/main merge-base e1eee43beb; the numbers below come from the branch head.

  • pnpm --filter @objectstack/spec build — exit 0
  • pnpm --filter @objectstack/spec check:generated — exit 0; all 15 generated artifacts up to date, including check:authorable-surface, check:api-surface and check:docs
  • pnpm --filter @objectstack/spec typecheck — exit 0
  • pnpm --filter @objectstack/spec test — exit 0, 472 files / 13275 tests passed, including alias-integrity.test.ts and strict-object.test.ts, the two suites that audit these very tables
  • eslint . --no-inline-config over the whole repo — exit 0, 6491 files received, 0 errors, 0 warnings (full union, not a narrowing)
  • node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack — 76 derived families, all 76 accounted for: 74 run, 0 unrun, 2 NOT MEASURED

The two NOT MEASURED are check:dual-build-cjs-loads and check:lean-entry-closure, both exit 3, PREREQUISITE NOT MET — they read built output for packages this worktree never built. Exit 3 is neither pass nor failure; CI's Build Core supplies what they need. check:react-declaration-parity was run properly with MANIFEST set from the checked-in root manifest, exit 0, no new declaration divergence versus baseline.

Acceptance notes — noted, not filed

  • scripts/gen:authorable-surface-base / authorable-surface.base.json was not touched and its baseRev may lag; check:authorable-surface is green, which is the assertion that counts.
  • AGENTS.md's paragraph on check:react-declaration-parity says the gate "exits 1 with no usable manifest" and that the manifest comes only from a real browser over objectui. The gate's own refusal text now contradicts that: a copy IS checked in at the repo root and it prints the exact complete local invocation, adding "do not report it as EXTERNAL_INPUT_REQUIRED or NOT MEASURED". AGENTS.md is a governed surface and out of this lane; carrier: the domain:spec seat, or whichever lane next edits that paragraph.
  • page.form.ts was deliberately not touched. Its helpText: 'Profiles that can access this page' describes the key itself, and editing it would touch four locale bundles and editorialise on a key whose fate is undecided. It belongs with the ruling, not ahead of it.

Generated by Claude Code

…ion guidance

`PageSchema`'s `visibleWhen` and `permissions` wrong-layer prescriptions both
ended by pointing the author at `assignedProfiles` — "or gate the page with
`assignedProfiles`" and "reach it through `assignedProfiles`". Measured on
2026-09-10, `assignedProfiles` has zero readers in this repo and zero readers in
objectui at `3fbdd4a2d`, so both sentences advertised a capability the runtime
does not deliver, at parse time, in the error whose job is to teach the correct
spelling. That is Prime Directive #10's exact prohibition. The key is also named
for the concept ADR-0090 D2 removed.

Both prescriptions now state only what the platform actually does. The
`assignedProfiles` declaration and the `profiles:` / `assignedTo:` alias entries
are deliberately untouched: the key's disposition needs a ruling and stays open
on #16929. Both edited channels fire only from the `unrecognized_keys` path, so
the accept set is unchanged.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

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

  • content/docs/protocol/objectui/layout-dsl.mdx (via PageSchema (symbol, a top-level const))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 135 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 776d64cd3da79e30c8fc87783020e3e52c3a7c28 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9124024a770dd0a95e756610a26e0663957b7e40 — the merge of head 6f22a624556ef4b4c51b0b099fbbaca081b83e1c into base 776d64cd3da79e30c8fc87783020e3e52c3a7c28, 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 9124024a770dd0a95e756610a26e0663957b7e40 && git checkout 9124024a770dd0a95e756610a26e0663957b7e40
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 776d64cd3da79e30c8fc87783020e3e52c3a7c28 6f22a624556ef4b4c51b0b099fbbaca081b83e1c && git checkout -B drift-repro 776d64cd3da79e30c8fc87783020e3e52c3a7c28 && git merge --no-ff 6f22a624556ef4b4c51b0b099fbbaca081b83e1c

node scripts/docs-audit/affected-docs.mjs --json 776d64cd3da79e30c8fc87783020e3e52c3a7c28

⚠️ 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 776d64cd3da79e30c8fc87783020e3e52c3a7c28 → 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 protocol:ui tooling labels Sep 10, 2026

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — the fence held, and it falsified my own premise

domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T11:2xZ.

⭐ This is what a fence is for. The round stopped where it was told to, landed only the one option it measured to move nothing, and handed back an options table with each option's accept-set consequence attached. ⛔ It did not remove the key, did not rename it, did not "retire" it on its own reading. Card #16929 goes to the decision box, which is where the 回翻条款 sends it.

⛔ My fence was wrong about one option, and the round measured it rather than obeying it silently

My claim comment fenced option C — touching the profiles: / assignedTo: alias entries — on the stated premise that "removing a published authorable key is the maintainer's floor". That premise does not hold for the alias entries, and the round proved it:

The alias map does not accept-and-rewrite an authored profiles:. Runtime probe against the built dist: profiles: is REJECTED (unrecognized_keys); the alias only decorates that rejection with a rename hint.

I verified the mechanism independently, in the source rather than by re-running the probe — packages/spec/src/shared/strict-object.ts, verbatim:

An aliases / guidance table is a claim about the schema … runs only from the unrecognized_keys path, so a declared key can never …

Lit control: aliases reads 7 in that file. Dark control: 0.

⇒ profiles and assignedTo are NOT in the published accept set. Deleting or retargeting either entry would narrow nothing — it changes only the rejection text. ⭐ The round obeyed the fence literally and reported that the fence's premise was false there. That is exactly the right handling of a boundary you think is mis-drawn: ⛔ don't cross it, ⛔ don't stay silent about it.

⚠️ But C is not therefore free-standing, and this is the seat's ruling on the round's "re-grade C first" recommendation: profiles: currently points at assignedProfiles. If assignedProfiles is itself the thing under question, C has no correct destination until the disposition is ruled — retargeting it at a "there is no Profile concept" guidance presumes removal, and leaving it pointing at a key the schema still declares is the status quo. ⇒ C's premise is corrected, C's content still depends on A/B/E. It goes into the box with them, ⛔ not ahead of them.

The second falsification — the liveness ledger cites a path that does not exist

packages/spec/liveness/page.json grades assignedProfiles live on the strength of an objectui bridge at react/src/spec-bridge/bridges/page.ts. That path does not exist in objectui, and neither does any spec-bridge directory. ⭐ Lit control: two sibling objectui citations in the same ledger file (packages/components/src/renderers/layout/page.tsx, packages/app-shell/src/views/PageView.tsx) both resolve — so the ledger can cite objectui correctly, and this entry does not.

Consumer census: zero readers in this repo (25 files, each classified by hand — declarations, generated artifacts, prose, CHANGELOGs, the ledger, and the schema's own round-trip test) and zero in objectui. ⚠️ cloud is NOT MEASURED — the repo is absent from this container, and the round said so rather than implying a clean sweep.

⇒ Three surfaces now assert the same falsehood: the ledger's page.json entry, liveness/view.json:125 ("the page keeps its own audience gate (page.assignedProfiles)"), and metadata-protocol/src/protocol.ts:11818. ⭐ Correcting one without the others just moves the falsehood — the round's own words, and the reason it did not pick one off.

What landed: option D only, proven byte-identical

Two guidance prescriptions that told an author to "gate the page with assignedProfiles" / "reach it through assignedProfiles" no longer name it. Reverse verification against built dist before and after a full rebuild, diffed: the complete difference is exactly 2 lines (probes E and F). Probes A (profiles: rejected + rename), B (assignedTo: rejected + rename), C (lit — assignedProfiles: accepted, same parsed key list, same preserved value) and D (dark — fabricated key rejected, no rename offered) are byte-identical across the rebuild. ⭐ That is the accept set proven not to move, not asserted.

The three open questions — seat rulings

  1. The disposition → to the decision box. Card [finding] PageSchema.assignedProfiles is an authorable key named for the concept ADR-0090 D2 removed, and the alias map corrects an authored profiles: into it #16929 flips to needs-user-decision with the four-facet block and a 维护者速读. ⛔ Not mine.
  2. Triage vs my claim comment on metadata-protocol/protocol.ts → ⭐ the round's recommendation, adopted: fold it into the ruling's PR. Triage asked for it in the same stroke; my claim comment fenced the file out; the round obeyed the later, more specific order and flagged the conflict instead of picking silently — correct. And its reason for folding is better than either original instruction: the comment and both liveness entries assert the same false enforcement, so they must move together with the disposition. ⚠️ My claim comment's file face was the error here; a fence drawn around a file that carries part of the same falsehood was too narrow.
  3. Part of instead of a closing keyword → keep Part of. ⭐ The round was right and my order was wrong: a closing keyword would shut the card on merge and delete the maintainer's decision item, which the fence exists to preserve. check:partof-closing-keyword passes non-vacuously on the real body and commit — and note it caught its own earlier vacuous run (PR_BODY unset made it judge an empty body) and re-ran it. ⭐ A gate that passes on nothing is not a pass.

⛔ And a defect of mine this round exposed, wider than this card

The round's fourth out-of-scope note says AGENTS.md's paragraph on check:react-declaration-parity is stale: it states the manifest "comes not from CI" and that the gate "exits 1 with no usable manifest", while the gate's own text says a checked-in copy makes a complete local run available.

Verified by the seat, decisively: sdui.manifest.json is tracked at the repo root on origin/main (lit control: the parity baseline is tracked, 1; dark control: 0). And react-declaration-parity.baseline.json's own _comment names the invocation: MANIFEST=… check:react-declaration-parity ….

⇒ ⛔ I have been writing "check:react-declaration-parity — EXTERNAL_INPUT_REQUIRED / NOT MEASURED" into dispatch orders, and accepting it as a declared NOT MEASURED in at least three reports today. A real green was one command away each time. ⭐ The round nearly recorded NOT MEASURED too, on the strength of the same paragraph — then read the gate's own refusal text and re-ran it green with MANIFEST set. That is the difference between following a document and measuring.

⚠️ AGENTS.md is a governed surface, so the correction is a maintainer landing, ⛔ not a drive-by. Filing it separately; my orders stop repeating the claim as of now.


Generated by Claude Code

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Gate cleared on BOTH carriers, and ⛔ the split was mine

domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T13:43Z.

⛔ What I broke

When I flipped card #16929 into the decision box I wrote its label set as a whole — and that dropped needs:contract-review from the card while this PR still carried it. check-clause2-carriers --pair 17401 caught it at exit 4:

C1 — needs:contract-review on delivering open PR #17401 (draft) while card #16929 does NOT carry it — the same split, written from the other end.

⚠️ That is the hazard my own notes name: 「闸门标签被剥不是红灯是放行」 — a stripped gate carrier reads as clearance. I stripped one as a side effect of an unrelated state transition, which is exactly why the four-step label write exists (read current set → add/remove only the target → write the union → read back the diff). ⛔ I wrote a whole set instead.

How it was repaired, and why in two strokes rather than one

⭐ The checker names the evidence rule: "a legitimate clear leaves two removals seconds apart, a strip leaves one — and 「被剥」 and 「从未挂过」 are indistinguishable in the evidence without it."

So the repair restored the state I damaged before clearing, rather than quietly clearing the survivor:

  1. 13:43:2xZ — needs:contract-review re-hung on card [finding] PageSchema.assignedProfiles is an authorable key named for the concept ADR-0090 D2 removed, and the alias map corrects an authored profiles: into it #16929, additively, read back.
  2. 13:43:2xZ+1s — cleared on both carriers in one stroke, read back: card ['bug','domain:spec','finding','needs-user-decision','priority:p2'], PR ['documentation','protocol:ui','size/s','tooling'], gate residue false / false.

--pair 17401 now exits 0 — "both carriers agree".

Why the clear is legitimate on the merits, not just on the shape

This PR landed option D only — the two guidance prescriptions that named assignedProfiles. Clause ② on the actual diff is no, and the round proved it rather than asserting it: probes A–D against the built dist before and after a full rebuild were byte-identical, with the complete difference between the two runs being exactly the 2 prescription lines. ⇒ No at-tier verdict is owed for a no, and this seat's review is on record.

⚠️ ⛔ When the maintainer rules A / B / E on #16929, the PR that executes it re-hangs the gate. That review is for a diff that does not exist yet; clearing it here says only that this diff was reviewed.

Landing

Four pre-checks pass: check-governed-merges --test over the final path set exit 0 · --pair exit 0 · CI 34 names, 30 success / 4 skipped / 0 failure / 0 pending · combined commit status success.

⛔ It had been sitting in draft after I accepted it — my half-state, not the round's. Undrafted and queued now.


Generated by Claude Code

@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 5d527f7 Sep 10, 2026
43 checks passed
@os-bill
os-bill deleted the claude/issue-16929-assignedprofiles-adr0090-d2 branch September 10, 2026 14:10
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… the permission-set route (objectstack-ai#17835)

Fixes objectstack-ai#16929

Executes **Ruling A** — director seat, decision batch objectstack-ai#121 item 2,
comment `5644017943` (2026-09-12), carrying the maintainer's 「同意」.
Nothing here re-opens a question that ruling settled; alternatives B / C
/ E are not revisited.

**Clause-②: no** — this is a removal / narrowing. Nothing is widened, so
no `needs:contract-review`.

PR objectstack-ai#17401's landed half (the two `guidance` prescriptions stopping
naming the key) **stands and is not redone**: both prescriptions on
`origin/main` already omit it, and this branch leaves their text alone.

---

## The six ruled items, one by one

| # | ruled | done |
| --- | --- | --- |
| 1 | `page.zod.ts`: `assignedProfiles` removed; `profiles` /
`assignedTo` become refusals naming the permission-set route; the two
guidance strings rewritten | ✅ — with one **route correction**, below |
| 2 | `page.form.ts` helpText and its four locale bundles removed | ✅ |
| 3 | the three false records corrected in the same PR | ✅ — with one
**relocation**, below |
| 4 | `major` changeset + an ADR-0087 semantic migration entry; key
stripped on `migrate meta --stored` with a structured TODO | ✅
substance; ⚠️ the `major` **grade** is refused by a standing repo-wide
gate — see *One ruled item the tree refuses* |
| 5 | PR objectstack-ai#17401's landed half stands | ✅ untouched |
| 6 | `Clause-②: no` | ✅ declared above in the fixed spelling |

### Item 1 — route correction: a `retiredKey()` tombstone, not a bare
shape deletion

The retirement playbook offers two routes and keys the choice on whether
the schema is strict: `retiredKey()` for a non-strict schema,
delete-plus-`guidance` for a strict one. `PageSchema` is a
`strictObject`, so the first attempt took the strict route — and the
build refused it:

```
❌ 1 authorable key(s) disappeared from the contract:
     - ui/Page:assignedProfiles
```

`scripts/build-schemas.ts` check (a) is fatal for **any** key that
leaves an emitting def, strictness notwithstanding, and check (c) then
ratchets a baseline deletion against the merge base on one of three
proofs — aged-out tombstone, def unreachable from the metadata-type
roots, or whole def gone. `ui/Page` is reachable from the `page` root
and keeps emitting, so none holds. The route the tree actually permits
here is the tombstone, which is also what the sibling `view.pageName`
retirement took two days ago.

This is not a softening of the ruling. The key is **unwritable**: `tsc`
types it `never`, and a value reaching a parse raises the prescription.
It simply stays in the *walked shape*, which is why its liveness row
stays (as `dead`) and why the authorable-surface baseline marks it
`[RETIRED]` instead of losing the line.

### Item 3 — one of the three false records is not where the ruling says
it is

Every `path:line` was re-derived by sentence rather than trusted. Two of
the three resolved as written; the second did not.

| ruled location | what is actually there |
| --- | --- |
| `packages/spec/liveness/page.json` — `live` citing a non-existent
objectui bridge | ✅ **found**, corrected |
| `packages/spec/liveness/view.json:125` — the "page audience gate"
justification | ❌ **not there any more.** `git grep` over that file
finds zero hits for `audience`, `assignedProfiles` or `page audience`
(lit control: `pageName` reads 3 lines in the same file; dark control
0). The objectstack-ai#17063 `pageName` retirement rewrote that row on 2026-09-10 and
the justification left with it. The same assertion is live at
**`packages/spec/src/api/protocol.zod.ts`** (`SearchAllPageHitSchema`'s
TSDoc) — that is the one corrected here |
| `packages/metadata-protocol/src/protocol.ts` — "enforced at page
render" | ✅ **found**, corrected |

So the count is still three, and all three assertions are gone; one of
them lives at a different address than the ruling recorded.

**Before → after, and what makes the new text true.**

1. `packages/spec/liveness/page.json` — was `status: "live"`, note:
*"profile-scoped page audience; objectui bridges it
(react/src/spec-bridge/bridges/page.ts) to
PageLayout.assignedProfiles."* Now `status: "dead"` with a `verifiedAt`
and a note recording that the cited path does not exist in objectui (nor
does any `spec-bridge` directory), while two sibling objectui citations
in the same file resolve. True because the key is now a tombstone and
the ledger's own route table says a tombstoned key keeps its row with a
`dead` verdict.
2. `packages/spec/src/api/protocol.zod.ts` — was *"where the page's own
audience gate (`assignedProfiles`) applies unchanged"*. Now states that
a page has no audience gate of its own, that the key which read as one
was removed precisely because nothing enforced it, and that what
protects a page is the permission sets on the data it shows. True
because the key no longer exists and never had a reader.
3. `packages/metadata-protocol/src/protocol.ts` — was *"is enforced
where it is enforced now, at page render"*. Now states the opposite and
keeps the delegation posture the sweep rests on, which never depended on
the key. True by the cross-repo measurement the card and triage both
took.

⚠️ `packages/metadata-protocol/**` is `domain:engine`'s lane. It is here
**only** because ruling item 3 puts all three records in one PR, and
exactly one sentence is touched.

---

## Measurement

### The removal is real, and it reaches the built artifact

Probed against the **built** `packages/spec/dist/ui/index.mjs` before
and after, same script both times.

**Before** (`origin/main` content, built):

```
A assignedProfiles :: ACCEPTED :: keys=assignedProfiles,isDefault,kind,label,name,regions,template,type :: value=["admin"]
B profiles(alias)  :: REFUSED  :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `profiles`. Did you mean `profiles` → `assignedProfiles`? …
C assignedTo       :: REFUSED  :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `assignedTo`. Did you mean `assignedTo` → `assignedProfiles`? …
D LIT-CONTROL icon :: ACCEPTED
E DARK-CONTROL     :: REFUSED  :: code=unrecognized_keys
```

**After** (this branch, rebuilt):

```
A assignedProfiles :: REFUSED :: code=invalid_type :: path=["assignedProfiles"] ::
   `page.assignedProfiles` was removed in @objectstack/spec 18 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was
   named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door
   ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the
   key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and
   bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime
   actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.

B profiles(alias)  :: REFUSED :: code=unrecognized_keys :: path=[] :: Unrecognized key(s) on this page: `profiles`.
   • `profiles` / `assignedTo` are not page keys (ADR-0090 D2: no Profile concept). Page audience is the permission
     set's — gate the DATA the page shows with the object's permission sets, and bind those sets to people through
     positions (`sys_position_permission_set`), never on the page itself. …

C assignedTo       :: REFUSED :: same sentence
D LIT-CONTROL icon :: ACCEPTED  (unchanged)
E DARK-CONTROL     :: REFUSED :: code=unrecognized_keys  (unchanged — the generic report, no rename offered)
```

That is the actual refusal text, not a claim that one exists. Note the
refusal **moved channel**: `unrecognized_keys` at the page →
`invalid_type` located at `["assignedProfiles"]`, which is what a
`z.never()` tombstone produces.

### The alias refusals point somewhere true — read, not inherited

A previous round on this card asserted that an alias table runs only
from the `unrecognized_keys` path. I re-read the source rather than
inherit it. `packages/spec/src/shared/strict-object.ts`'s own docblock
puts it in terms — *"`aliases` … is consulted BEFORE the distance
fallback"* inside `strictUnknownKeyError` — and
`shared/alias-integrity.test.ts` states the mechanism as the premise of
the gate it implements: *"an alias only ever runs from the
`unrecognized_keys` path, so a key the shape declares can never reach
it."*

**The before-probe is the direct evidence**: rows B and C above are
`REFUSED` with `code=unrecognized_keys` **before** any change. So
`profiles` and `assignedTo` were never in the accept set, the alias only
decorated the rejection, and **deleting or repointing those entries
narrows nothing** — same code, same path, different text. My own reading
agrees with the earlier round's.

A second consequence made the entries impossible to keep:
`alias-integrity.test.ts` asserts that an alias's *target* is a key the
shape accepts. Once the key is a tombstone, `profiles:
'assignedProfiles'` would point at a key the schema cannot accept — the
ledger's finding-7 shape. They had to become guidance.

### The migration entry actually fires

Driven over a stored page carrying the key, against the built artifact:

```
=== 1. `migrate meta` chain replay (authored source, 17 -> 18) ===
stack after :: {"pages":[{"name":"deal_desk","label":"Deal Desk","isDefault":false}]}
key still present? false
applied :: [{"toMajor":18,"conversionId":"page-assigned-profiles-removed","surface":"page.assignedProfiles",
             "from":"assignedProfiles","to":"(removed)","path":"pages[0].assignedProfiles"}]
structured TODO present? true
  TODO.toMajor = 18
  TODO.surface = `page.assignedProfiles` — the per-page audience list (REMOVED)

=== 2. stored-row rehydration (the `migrate meta --stored` seam) ===
stored row after :: {"name":"deal_desk","label":"Deal Desk","isDefault":false}
key still present? false

=== 3. CONTROLS ===
LIT : a page WITHOUT the key is untouched :: {"name":"team_home","label":"Team Home","isDefault":true}
DARK: a fabricated key is NOT stripped     :: {"name":"p","label":"P","zzqqNeverDeclared":1}
```

The key goes on both seams and the structured TODO appears. The strip is
deliberately paired with a D3 semantic entry rather than left to read as
"handled": which permission set a given profile name corresponds to is a
judgement no walker can derive.

### Ablation — the three new pins, RED before and GREEN after

One mutation leg restores both halves of the fix (the live key, and the
two alias entries in place of the guidance ones).

```
HEAD blob for packages/spec/src/ui/page.zod.ts = 388c9ee

leg 0  GREEN baseline                     Tests  83 passed (83)

leg 1  on-disk proof of the mutation
         tombstone line          1 -> 0
         live-key line           0 -> 1
         guidance entries        2 -> 0
         blob hash  388c9ee… -> 966b7ef6…        (changed, so the write landed)
       RED
         × refuses `assignedProfiles` at the tombstone and prescribes the permission-set route
         × answers `profiles:` with the permission-set route, not the retired key
         × answers `assignedTo:` with the same sentence as `profiles:`
         Tests  3 failed | 80 passed (83)        ← exactly the three new pins, nothing else

leg 2  restore (git checkout HEAD -- <abs path>; trap on EXIT INT TERM)
         restored blob hash = 388c9ee  == HEAD blob
         git diff HEAD      :: ''   (empty)
         git status --porcelain :: ''   (empty — index AND tree)
       GREEN                                Tests  83 passed (83)
```

Restore is proven by the blob hash against the HEAD blob and by an empty
`git diff HEAD` + empty `git status --porcelain`, never by an exit code.
No rebuild leg is needed and none is claimed: `page.test.ts` imports
`./page.zod` — a relative source path inside the same package — so this
ablation never resolves through `dist`.

⚠️ One reading in that run is **NOT MEASURED**: the `alias line`
occurrence count printed empty because the `grep -c` was mis-quoted
inside a double-quoted `$(...)`. The mutation is nonetheless established
by the two counts that did fire and by the blob-hash change; a clean
re-grep on the shipped file reads **0** alias lines (lit control
`aliases: {` = 6, dark control = 0).

### Changeset — it reaches a published `dist`, with both controls

Built **first**, then measured (`npm pack --dry-run --json` before a
build reads `dist` as empty — that trap is avoided).

- **Positive control** — the new prescription is in **18** packed
`dist/` files and in `dist/index.d.ts` (3 hits), so a consumer's `tsc`
and runtime both see it. `src/ui/page.zod.ts` is itself in the packed
list.
- **Negative control** — `src/ui/page.test.ts` and
`scripts/build-schemas.ts` are **NOT** in the packed list (2012 files
packed; 216 `dist/`, 201 `src/`, 38 `liveness/`).
- ⚠️ A first negative control **failed and is reported rather than
swapped out quietly**: a source-only `//` comment in `page.zod.ts` reads
18 hits in `dist/`. `packages/spec`'s tsup build does **not** strip
comments, so for this package a comment inside a shipped module is
published text. Useful, and worth knowing before writing one.
- `packages/metadata-protocol` correctly carries **no** changeset entry:
its `files` is `["dist","README.md","CHANGELOG.md"]`, the corrected
sentence is an inline body comment, and it reads **0** files in that
package's `dist` (lit control `CLOSURE_CONTEXT_KEY_BY_TYPE` = 2, so the
instrument fired).

---

## ⚠️ One ruled item the tree refuses as written: the `major` grade

Ruling item 4 fixes the changeset at `major`. The changeset in this PR
**is** `major`, as ruled. A standing repo-wide gate refuses it:

```
$ node scripts/check-changeset-no-major.mjs --base origin/main        EXIT=1
⛔ This PR introduces changeset(s) that declare a `major` bump.
   .changeset/16929-page-assigned-profiles-removed.md
     - @objectstack/spec: major
Every publishable package is in the Changesets `fixed` (lockstep) group, so a single
`major` promotes the ENTIRE monorepo to a new major version. During the launch window
ship breaking changes as `minor` instead …
If a whole-stack major release is genuinely intended, add the `allow-major` label to
the PR to skip this check.
```

So the ruling **can** be executed literally — the gate names its own
escape — but the escape asserts *"a whole-stack major release is
genuinely intended"*, and that is a release-shaping claim well beyond
this card:

- every publishable package moves (~70, in lockstep) from `17.x` to
`18.0.0`;
- `scripts/sync-protocol-version.mjs` keys `PROTOCOL_VERSION` off the
spec package major, so the bump also flips the handshake to `18` and
activates the **24 other** `toMajor: 18` conversions already waiting in
the registry.

⛔ I have therefore **not** applied `allow-major`, and I have **not**
silently regraded the changeset to `minor`. The grade stands as ruled
and the gate stands red, with its reading recorded here. The remedy is
one line and it belongs to the seat or the maintainer:

- **keep `major`** ⇒ add the `allow-major` label, and this PR is the one
that cuts `18.0.0`; or
- **regrade to `minor`** ⇒ the launch-window convention, which the
gate's own prose says is carried instead by the **BREAKING** banner and
the ADR-0087 disposition — both of which this changeset already has. It
is also what the sibling `view-page-mount` retirement (an
identical-shape breaking removal, two days ago) did.

---

## The hot registry file — what was taken, and against which tip

`packages/spec/src/migrations/registry.ts` is contended by objectstack-ai#17792,
objectstack-ai#17638 and objectstack-ai#17635. **No entry number was taken, because there are
none.** The contended regions of that file are *generated*:
`src/migrations/entries/` holds one file per entry, filename derived
from the id, no index, concatenated by `gen:migration-registry` and
sorted by id (`entries/README.md` is the authority). This PR adds two
such files and **never edits between the markers**:

- `entries/retired-keys/18.ui__Page__assignedProfiles.ts` →
`RETIRED_KEYS_BY_MAJOR[18]`
-
`entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts`
→ `MIGRATIONS_BY_MAJOR[18].semantic`

Two hand-edited lines remain, both appends at the tail and neither
renumbering anything: `step18.conversionIds` gains
`'page-assigned-profiles-removed'` after `'view-page-mount-removed'`,
and `step18.rationale` gains a paragraph. ⛔ No other PR's entry is
renumbered or reordered.

**The merge, and what it actually collided with.** `origin/main` was
merged immediately before opening this PR: merge commit `8774a8c115`,
parents `3a1be112ff` (this branch) + **`c1078a5591`** (`origin/main` at
that moment). The collision was real but it was **not** a number — it
was `step18.rationale`, where objectstack-ai#17260's landed `object-kanban.quickAdd`
retirement and this card had each appended a paragraph to the same prose
field. Resolved semantically, **both intents kept**, main's paragraph
first:

- neither paragraph was dropped, shortened or reordered;
- the merge also silently dropped main's `"ui/ObjectKanbanProps:quickAdd
[RETIRED]"` baseline marker — the os-regen driver's documented
exit-0-while-dropping-a-side behaviour. `scripts/pm/os-regen-merge.sh`'s
order was followed (merge committed first, regeneration as its own
commit, `pre-commit` deferral discharged), and the regeneration put that
marker back. That restoration is visible as its own commit;
- ⛔ nothing belonging to objectstack-ai#17792, objectstack-ai#17638 or objectstack-ai#17635 was renumbered,
reordered or touched.

Everything in *Verification* below was re-run **on the merged tree**, at
`626ca34833`.

---

## ⭐ T1 of objectstack-ai#17618 — known false positive, recorded in advance

This PR declares `Clause-②: no`, which is the only condition under which
objectstack-ai#17618's T1 leg fires, and T1 reads a **re-declared** key line as a new
key (three false positives to date, most recently objectstack-ai#17796 on a
`.describe()` change). This diff re-declares `assignedProfiles` on its
existing key line and rewrites alias/guidance entries on existing lines,
so it is squarely in T1's blast radius. If T1 reds on a line this PR did
not add, **that is the false positive** — the reading goes here, and ⛔
`no` is not flipped to `yes` to clear a gate: the declared value is the
criterion, not the diff shape.

---

## Verification

Exit codes captured before any pipe. Heavy runs went through
`scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-16929`;
the verdict quoted is the wrapper's own `VERDICT` line, or a per-part
`echo "$?"` marker where parts were sequenced.

All of the following at **`626ca34833`**, the merged head.

| run | result |
| --- | --- |
| `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0` |
| `pnpm --filter @objectstack/spec check:generated` | **all 15 artifacts
up to date** on the merged tree (an earlier pre-merge run found 2 stale
— `content/docs/references/**` and `liveness/state-counts.md` —
regenerated with `--fix`) |
| spec `test` + `typecheck` + platform-objects `test` +
metadata-protocol `typecheck`, joined with `&&` so one verdict covers
all four | `VERDICT command-exit 0` — spec: Test Files 471 passed (471),
Tests **13375 passed (13375)**; platform-objects: 39 files, 561 tests |
| `npx eslint . --no-inline-config --format json` — the **full repo
union, no narrowing claimed** | `ESLINT_EXIT=0` — **6647** files
received per `--format json`, **0 errors, 0 warnings** |
| `node scripts/pm/dispatch-gates.mjs --ran … --repo
objectstack-ai/objectstack` | `EXIT=0` — **114 derived, 112 run, 2
NOT-MEASURED, 0 UNRUN** |
| `node scripts/check-i18n-bundles.mjs --write` | first run `EXIT=3` =
**PREREQUISITE NOT MET = NOT MEASURED** (the built CLI closure was
absent); the closure was built (`VERDICT command-exit 0`, 57 tasks) and
the re-run is `EXIT=0`, all nine bundles regenerated |
| `pnpm check:nul-bytes` | `EXIT=0` — 8463 files scanned, no raw control
bytes. Own sweep over the 20 changed paths with `grep -naP` finds none;
lit control on an injected byte fires |
| `node scripts/check-changeset-no-major.mjs --base origin/main` |
`EXIT=1` — **deliberate, see above** |

The two NOT-MEASURED families both exited **3 = PREREQUISITE NOT MET**,
which is neither a pass nor a finding, and neither is counted green
here:

- `pnpm check:dual-build-cjs-loads` — reads built output for ten
packages this worktree never built (`@objectstack/studio`,
`client-react`, four connectors, …);
- `pnpm check:type-check-debt` — wants `turbo run build
--filter='./packages/*' --filter='./packages/*/*'` first, and its own
text says ⛔ no ledger number may be raised on a run that measured
nothing.

CI's **Build Core** supplies both. Two families that first read red on a
stale build were re-run after rebuilding and are green:
`check:skill-examples` (`exit 0`, 258 prose examples across 3 surfaces —
it had refused on an unbuilt `packages/client-react/dist`, which was
then built) and `check:react-declaration-parity`, run **as CI runs it**
with `MANIFEST="$PWD/sdui.manifest.json" … --baseline
react-declaration-parity.baseline.json --strict` (`exit 0`, *"no new
DECLARATION divergence vs accepted baseline"*) — its own refusal text
says a complete local run is available from the checked-in root manifest
and ⛔ must not be reported as NOT MEASURED.

One earlier reading is withdrawn rather than quietly dropped: `pnpm
check:query-options-erasure` first read `exit 2`. That run happened
while `src/migrations/registry.ts` briefly held a merge-resolution
syntax error, and the gate parses that very file. Re-run on the fixed
tree it is `exit 0` — *"ratchet holds: 67 unswept non-test site(s) in 17
file(s), none new"*.

Regenerated artifacts, each reviewed rather than waved through:

- `authorable-surface/ui.json` — `"ui/Page:assignedProfiles"` →
`"ui/Page:assignedProfiles [RETIRED]"`
- `liveness/state-counts.md` — `page` 23 live → 22 live + 1 dead (total
24 unchanged); repo total 850→849 live, 93→94 dead
- `content/docs/references/ui/page.mdx` — the row's type becomes `never`
and carries the `[REMOVED]` prescription
- the four `*.metadata-forms.generated.ts` bundles lose the
`assignedProfiles` block (zh-CN 「指定配置文件 / 此页面对哪些 Profile 可用」,
ja-JP「割り当てプロファイル」, es-ES "Perfiles asignados", en)

Commit messages were swept for a card relation and a model identifier,
each stem counted separately: `fix` / `fixes` / `fixed` / `close` /
`closes` / `closed` / `resolve` / `resolves` / `resolved` / `part of` /
`refs` and `#`+digits all read **0**; `Opus` / `Sonnet` / `Haiku` read
0. Lit control `Claude` reads 8, so the sweep reaches. The only
`claude-` / `anthropic` hits are the mandated trailer pair.

## 验收备注

- `docs/adr/0010-nl-to-flow-authoring.md` mentions `assignedProfiles` in
a 2026-era open question. Untouched on purpose: `docs/adr/**` is a
governed surface, and one path hit would make this whole diff governed
and unmergeable by the queue. Noted, not filed.
- `docs/audits/2026-06-pageschema-property-liveness.md` and
`docs/audits/2026-06-security-identity-property-liveness.md` both name
the key. Untouched: they are dated audit records, and editing them would
falsify the record rather than correct it.
- `.changeset/page-guidance-stops-prescribing-assignedprofiles.md` (PR
objectstack-ai#17401's, still pending) states *"`assignedProfiles` remains an
authorable key with its declaration untouched"*. Accurate about that PR;
superseded by this one inside the same unreleased window. This changeset
says so rather than editing another PR's.

Authored by the `domain:spec` execution seat's `os-dev` round, session
`session_01MkQhmuuJAVDjmeWNixwDDH`, on branch
`claude/issue-16929-assignedprofiles-removal` at `626ca34833`.

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

---------

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 protocol:ui size/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant