Repository navigation
fix(spec): PageSchema's rejection guidance stops prescribing assignedProfiles as a page gate - #17401
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
ACCEPT — the fence held, and it falsified my own premise
⭐ 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 silentlyMy claim comment fenced option C — touching the The alias map does not accept-and-rewrite an authored I verified the mechanism independently, in the source rather than by re-running the probe —
Lit control: ⇒
The second falsification — the liveness ledger cites a path that does not exist
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. ⇒ Three surfaces now assert the same falsehood: the ledger's What landed: option D only, proven byte-identicalTwo The three open questions — seat rulings
⛔ And a defect of mine this round exposed, wider than this cardThe round's fourth out-of-scope note says AGENTS.md's paragraph on Verified by the seat, decisively: ⇒ ⛔ I have been writing " Generated by Claude Code |
Gate cleared on BOTH carriers, and ⛔ the split was mine
⛔ What I brokeWhen I flipped card #16929 into the decision box I wrote its label set as a whole — and that dropped
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:
Why the clear is legitimate on the merits, not just on the shapeThis PR landed option D only — the two LandingFour pre-checks pass: ⛔ 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 |
… 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>
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-reviewand 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 thedomain:specexecution seat.What this PR changes
Two strings, in
packages/spec/src/ui/page.zod.ts. Both areguidanceprescriptions — the textPageSchemahands an author when it rejects a wrong-layer key — and both ended by pointing atassignedProfiles:visibleWhensaid "…or gate the page withassignedProfiles"permissionssaid "…reach it throughassignedProfiles, and gate the DATA it shows…"Neither is true.
assignedProfilesgates 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
assignedProfilesdeclaration, theprofiles:/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 publishedPageSchema— 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.
aliasesonstrictObjectdoes not accept a key and rewrite it.strict-object.tssays so in its own words: "an alias runs only from theunrecognized_keyspath, 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):successprofiles:unrecognized_keys— "Did you meanprofiles→assignedProfiles?"assignedTo:unrecognized_keys— "Did you meanassignedTo→assignedProfiles?"assignedProfiles:grobnicators:unrecognized_keys, no rename offeredvisibleWhen:permissions: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:
profilesandassignedToare NOT inPageSchema'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 authoredprofiles: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'svisibility→visibleWhenon 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/maine1eee43beb—assignedProfilescase-insensitive: 25 files. Every one classified by hand:content/docs/references/(8 rows) and one hand-written docs table rowdocs/adr/0010— a 2022-era open question, prosedocs/audits/2026-06-pageschema-property-liveness.mdand…-security-identity-…— two prior audits that already list it as zero-consumerCHANGELOG.mdfiles (release-owned)packages/metadata-protocol/src/protocol.ts:11818— the comment the card quotes*.metadata-forms.generated.tslocale bundlesauthorable-surface.base.json+authorable-surface/ui.json— generated baselinesliveness/page.json,liveness/view.json— the ledgerpackages/spec/src/api/protocol.zod.ts,ui/view.zod.ts— prosepackages/spec/src/ui/page.form.ts— the form fieldpackages/spec/src/ui/page.zod.ts— the declaration, the aliases, the two stringspackages/spec/src/ui/page.test.ts— the schema's own round-trip assertionReaders: zero. Controls:
visibleWhen168 files (lit), fabricatedassignedGrobnicators0 files (dark), snake-caseassigned_profiles0 files.objectui,
origin/main3fbdd4a2d(read-only; this repo's PR touches nothing there) —assignedProfilescase-insensitive: 3 files, each inspected:content/docs/api/schema-reference.md:140— a docs table rowpackages/types/src/layout.ts:795— a TypeScript declarationpackages/types/src/zod/layout.zod.ts:459— a zod declarationReaders: zero. Controls:
visibleWhen308 files (lit),PageSchema94 files (lit), fabricated term 0 files (dark),assigned_profiles0 files. This confirms triage's reading at42ddd7f71one day later and at a moved head (thelayout.tsline 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
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.)packages/spec/liveness/page.jsongradesassignedProfileslive, on the strength of "objectui bridges it (react/src/spec-bridge/bridges/page.ts)". That path does not exist in objectui, and neither does anyspec-bridgedirectory. Lit control: two sibling objectui citations in the same ledger file —packages/components/src/renderers/layout/page.tsxandpackages/app-shell/src/views/PageView.tsx— both resolve. So the ledger can cite objectui correctly; this entry does not.packages/spec/liveness/view.json:125leans on the same claim: atype: '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
assignedProfilesretiredKeytombstone, both authorable-surface baselines, liveness ledger,page.form.ts, four locale bundles, docs, plus objectui's two declarations cross-repoprofiles:/assignedTo:aliasesguidanceprescriptions namingassignedProfilesWhy option D moves no accept set — the reverse verification
Both channels this PR edits fire only from the
unrecognized_keyspath, so every key involved is refused before and after. Proven, not asserted: the probe table above was run against the builtdistbefore 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:generatedreports all 15 generated artifacts up to date,check:authorable-surfaceamong them — the published accept set did not move.Why this is
Part of, not a closing keywordThe 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
yesat 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 asyeshere,needs:contract-reviewis applied, and the re-grading is handed to the seat with the evidence.The changeset grades
@objectstack/specminor, satisfying the level rule that applies withClause-②: yes.Verification
Run at
origin/mainmerge-basee1eee43beb; the numbers below come from the branch head.pnpm --filter @objectstack/spec build— exit 0pnpm --filter @objectstack/spec check:generated— exit 0; all 15 generated artifacts up to date, includingcheck:authorable-surface,check:api-surfaceandcheck:docspnpm --filter @objectstack/spec typecheck— exit 0pnpm --filter @objectstack/spec test— exit 0, 472 files / 13275 tests passed, includingalias-integrity.test.tsandstrict-object.test.ts, the two suites that audit these very tableseslint . --no-inline-configover 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 MEASUREDThe two NOT MEASURED are
check:dual-build-cjs-loadsandcheck: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-paritywas run properly withMANIFESTset 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.jsonwas not touched and itsbaseRevmay lag;check:authorable-surfaceis green, which is the assertion that counts.check:react-declaration-paritysays 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: thedomain:specseat, or whichever lane next edits that paragraph.page.form.tswas deliberately not touched. ItshelpText: '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