fix(spec): retire page.assignedProfiles and answer profiles: with the permission-set route - #17835
fix(spec): retire page.assignedProfiles and answer profiles: with the permission-set route#17835os-bill wants to merge 6 commits into
page.assignedProfiles and answer profiles: with the permission-set route#17835Conversation
…e false records Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…oute Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…ur locale bundles Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…signedprofiles-removal # Conflicts: # packages/spec/src/migrations/registry.ts
Discharges the os-regen deferral the merge commit recorded. Restores the `ui/ObjectKanbanProps:quickAdd [RETIRED]` baseline marker the textual merge dropped, and fixes the rationale concatenation where both sides appended a paragraph to step18. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 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 e9c6bf85b6ea5cefe1fd60c8e0b913b6eb2b2fb3 && git checkout e9c6bf85b6ea5cefe1fd60c8e0b913b6eb2b2fb3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0cd841a166453f6f4e4524587969f0c2137be3ab 626ca348337be009e933d8ce9badb16680d3f005 && git checkout -B drift-repro 0cd841a166453f6f4e4524587969f0c2137be3ab && git merge --no-ff 626ca348337be009e933d8ce9badb16680d3f005
node scripts/docs-audit/affected-docs.mjs --json 0cd841a166453f6f4e4524587969f0c2137be3ab
|
Fixes #16929
Executes Ruling A — director seat, decision batch #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 #17401's landed half (the two
guidanceprescriptions stopping naming the key) stands and is not redone: both prescriptions onorigin/mainalready omit it, and this branch leaves their text alone.The six ruled items, one by one
page.zod.ts:assignedProfilesremoved;profiles/assignedTobecome refusals naming the permission-set route; the two guidance strings rewrittenpage.form.tshelpText and its four locale bundles removedmajorchangeset + an ADR-0087 semantic migration entry; key stripped onmigrate meta --storedwith a structured TODOmajorgrade is refused by a standing repo-wide gate — see One ruled item the tree refusesClause-②: noItem 1 — route correction: a
retiredKey()tombstone, not a bare shape deletionThe retirement playbook offers two routes and keys the choice on whether the schema is strict:
retiredKey()for a non-strict schema, delete-plus-guidancefor a strict one.PageSchemais astrictObject, so the first attempt took the strict route — and the build refused it:scripts/build-schemas.tscheck (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/Pageis reachable from thepageroot and keeps emitting, so none holds. The route the tree actually permits here is the tombstone, which is also what the siblingview.pageNameretirement took two days ago.This is not a softening of the ruling. The key is unwritable:
tsctypes itnever, and a value reaching a parse raises the prescription. It simply stays in the walked shape, which is why its liveness row stays (asdead) 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:linewas re-derived by sentence rather than trusted. Two of the three resolved as written; the second did not.packages/spec/liveness/page.json—liveciting a non-existent objectui bridgepackages/spec/liveness/view.json:125— the "page audience gate" justificationgit grepover that file finds zero hits foraudience,assignedProfilesorpage audience(lit control:pageNamereads 3 lines in the same file; dark control 0). The #17063pageNameretirement rewrote that row on 2026-09-10 and the justification left with it. The same assertion is live atpackages/spec/src/api/protocol.zod.ts(SearchAllPageHitSchema's TSDoc) — that is the one corrected herepackages/metadata-protocol/src/protocol.ts— "enforced at page render"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.
packages/spec/liveness/page.json— wasstatus: "live", note: "profile-scoped page audience; objectui bridges it (react/src/spec-bridge/bridges/page.ts) to PageLayout.assignedProfiles." Nowstatus: "dead"with averifiedAtand a note recording that the cited path does not exist in objectui (nor does anyspec-bridgedirectory), 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 adeadverdict.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.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/**isdomain: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.mjsbefore and after, same script both times.Before (
origin/maincontent, built):After (this branch, rebuilt):
That is the actual refusal text, not a claim that one exists. Note the refusal moved channel:
unrecognized_keysat the page →invalid_typelocated at["assignedProfiles"], which is what az.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_keyspath. 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" insidestrictUnknownKeyError— andshared/alias-integrity.test.tsstates the mechanism as the premise of the gate it implements: "an alias only ever runs from theunrecognized_keyspath, so a key the shape declares can never reach it."The before-probe is the direct evidence: rows B and C above are
REFUSEDwithcode=unrecognized_keysbefore any change. SoprofilesandassignedTowere 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.tsasserts 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:
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).
Restore is proven by the blob hash against the HEAD blob and by an empty
git diff HEAD+ emptygit status --porcelain, never by an exit code. No rebuild leg is needed and none is claimed:page.test.tsimports./page.zod— a relative source path inside the same package — so this ablation never resolves throughdist.alias lineoccurrence count printed empty because thegrep -cwas 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 controlaliases: {= 6, dark control = 0).Changeset — it reaches a published
dist, with both controlsBuilt first, then measured (
npm pack --dry-run --jsonbefore a build readsdistas empty — that trap is avoided).dist/files and indist/index.d.ts(3 hits), so a consumer'stscand runtime both see it.src/ui/page.zod.tsis itself in the packed list.src/ui/page.test.tsandscripts/build-schemas.tsare NOT in the packed list (2012 files packed; 216dist/, 201src/, 38liveness/).//comment inpage.zod.tsreads 18 hits indist/.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-protocolcorrectly carries no changeset entry: itsfilesis["dist","README.md","CHANGELOG.md"], the corrected sentence is an inline body comment, and it reads 0 files in that package'sdist(lit controlCLOSURE_CONTEXT_KEY_BY_TYPE= 2, so the instrument fired).majorgradeRuling item 4 fixes the changeset at
major. The changeset in this PR ismajor, as ruled. A standing repo-wide gate refuses it: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:
17.xto18.0.0;scripts/sync-protocol-version.mjskeysPROTOCOL_VERSIONoff the spec package major, so the bump also flips the handshake to18and activates the 24 othertoMajor: 18conversions already waiting in the registry.⛔ I have therefore not applied
allow-major, and I have not silently regraded the changeset tominor. 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:major⇒ add theallow-majorlabel, and this PR is the one that cuts18.0.0; orminor⇒ 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 siblingview-page-mountretirement (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.tsis contended by #17792, #17638 and #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 bygen:migration-registryand sorted by id (entries/README.mdis 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].semanticTwo hand-edited lines remain, both appends at the tail and neither renumbering anything:
step18.conversionIdsgains'page-assigned-profiles-removed'after'view-page-mount-removed', andstep18.rationalegains a paragraph. ⛔ No other PR's entry is renumbered or reordered.The merge, and what it actually collided with.
origin/mainwas merged immediately before opening this PR: merge commit8774a8c115, parents3a1be112ff(this branch) +c1078a5591(origin/mainat that moment). The collision was real but it was not a number — it wasstep18.rationale, where #17260's landedobject-kanban.quickAddretirement and this card had each appended a paragraph to the same prose field. Resolved semantically, both intents kept, main's paragraph first:"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-commitdeferral discharged), and the regeneration put that marker back. That restoration is visible as its own commit;scheduledcache-warmup strategy — the cron it selected left in this same major (ADR-0049) #17638 or feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635 was renumbered, reordered or touched.Everything in Verification below was re-run on the merged tree, at
626ca34833.⭐ T1 of #17618 — known false positive, recorded in advance
This PR declares
Clause-②: no, which is the only condition under which #17618's T1 leg fires, and T1 reads a re-declared key line as a new key (three false positives to date, most recently #17796 on a.describe()change). This diff re-declaresassignedProfileson 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 ⛔nois not flipped toyesto 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.shwithOS_VERIFY_LOCK_SLOT=issue-16929; the verdict quoted is the wrapper's ownVERDICTline, or a per-partecho "$?"marker where parts were sequenced.All of the following at
626ca34833, the merged head.pnpm --filter @objectstack/spec buildVERDICT command-exit 0pnpm --filter @objectstack/spec check:generatedcontent/docs/references/**andliveness/state-counts.md— regenerated with--fix)test+typecheck+ platform-objectstest+ metadata-protocoltypecheck, joined with&&so one verdict covers all fourVERDICT command-exit 0— spec: Test Files 471 passed (471), Tests 13375 passed (13375); platform-objects: 39 files, 561 testsnpx eslint . --no-inline-config --format json— the full repo union, no narrowing claimedESLINT_EXIT=0— 6647 files received per--format json, 0 errors, 0 warningsnode scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstackEXIT=0— 114 derived, 112 run, 2 NOT-MEASURED, 0 UNRUNnode scripts/check-i18n-bundles.mjs --writeEXIT=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 isEXIT=0, all nine bundles regeneratedpnpm check:nul-bytesEXIT=0— 8463 files scanned, no raw control bytes. Own sweep over the 20 changed paths withgrep -naPfinds none; lit control on an injected byte firesnode scripts/check-changeset-no-major.mjs --base origin/mainEXIT=1— deliberate, see aboveThe 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— wantsturbo 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 unbuiltpackages/client-react/dist, which was then built) andcheck:react-declaration-parity, run as CI runs it withMANIFEST="$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-erasurefirst readexit 2. That run happened whilesrc/migrations/registry.tsbriefly held a merge-resolution syntax error, and the gate parses that very file. Re-run on the fixed tree it isexit 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—page23 live → 22 live + 1 dead (total 24 unchanged); repo total 850→849 live, 93→94 deadcontent/docs/references/ui/page.mdx— the row's type becomesneverand carries the[REMOVED]prescription*.metadata-forms.generated.tsbundles lose theassignedProfilesblock (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/refsand#+digits all read 0;Opus/Sonnet/Haikuread 0. Lit controlClaudereads 8, so the sweep reaches. The onlyclaude-/anthropichits are the mandated trailer pair.验收备注
docs/adr/0010-nl-to-flow-authoring.mdmentionsassignedProfilesin 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.mdanddocs/audits/2026-06-security-identity-property-liveness.mdboth 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 fix(spec): PageSchema's rejection guidance stops prescribingassignedProfilesas a page gate #17401's, still pending) states "assignedProfilesremains 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:specexecution seat'sos-devround, sessionsession_01MkQhmuuJAVDjmeWNixwDDH, on branchclaude/issue-16929-assignedprofiles-removalat626ca34833.Generated by Claude Code