Repository navigation
feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) - #22166
Conversation
…s the settings global rung (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…m_setting Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…on reads the platform holder, pins Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
… every rung Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…layers sys_setting stores Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…ed scope option Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…atform-setting-global-rung
…; the es-ES ledger drops the retired leaf Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…atform-setting-global-rung
📓 Docs Drift CheckThis PR changes 4 package(s): 64 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 147 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 25e105e6e69080dd3cba801ff04c383e0c4dbe40 && git checkout 25e105e6e69080dd3cba801ff04c383e0c4dbe40
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d7943dd0dfba6c98afa2200a08983ec85f9849a 2d4170f6195f310a23ebe7f30cb3c31e3b455e0e && git checkout -B drift-repro 7d7943dd0dfba6c98afa2200a08983ec85f9849a && git merge --no-ff 2d4170f6195f310a23ebe7f30cb3c31e3b455e0e
node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a
|
Contract reviewServed-tier: Reviewed at 2026-10-08T03:58Z by an isolated at-tier subagent of the claiming seat ( Check-runs on the head, as read at this stamp: 34 runs — 30 ① Derived judgments
② Semver level
③ Boundary flagsThe report
Implemented-by: VERDICT: PASS The move is one source per rung with the exclusion in the predicate (D7, D14), the accept-set changes it implies are each pinned over real objects and a real engine, the one deletion hazard it created (the sweep) is closed in the same diff, the changeset's grade, marker and arm match what the diff publishes, and the stored-row move is correctly left to C7 behind the v18 boot refusal. Landing stays held on the seat's own conditions: the four in-flight runs completing green. Generated by Claude Code |
…atform-setting-global-rung
|
Regen-provenance: 6051891742 · d047787 → 92f3370 ·
|
…atform-setting-global-rung # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
The census tool's --write (node scripts/tenant-audit-census.mjs --write) regenerated the generated region of the page and the counts file. One gate-bound prose figure outside the region is updated to the census's value (52 unreadable options of 232 sites). Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
|
Regen-provenance: 6051891742 · 92f3370 → c3f5925 ·
|
…atform-setting-global-rung # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
The census tool's --write (node scripts/tenant-audit-census.mjs --write) regenerated the generated region of the page and the counts file. One gate-bound prose figure outside the region is updated to the census's value (52 unreadable options of 234 sites). Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
|
Regen-provenance: 6051891742 · c3f5925 → 2d4170f ·
|
…ove declare a description; Invite User names the invitee (objectstack-ai#22230) Fixes objectstack-ai#22182 Clause-②: no ## What changes - **`invite_user`** is declared three times, on `sys_user`, `sys_invitation` and `sys_member`. Each declaration now has a `description`: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation." The console shows it as the parameter dialog's subtitle. Before, the dialog showed the generic "Please provide the required information to continue." - **`invite_user`'s `successMessage`** is now `Invitation sent to ${result.email}`. Before, it was "Invitation sent". - **`approval_approve`** declares a `description` the way `approval_reject` beside it does: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires." The wording says "once this step has the approvals it requires" because one approval finalizes a step only under `first_response` or an override. Under `unanimous`, `quorum` and `per_group` it does not. - **Translation bundles.** I regenerated them with `node scripts/check-i18n-bundles.mjs --write`, per package. The extractor seeds each new `description` leaf with the English source in zh-CN, ja-JP and es-ES. It also keeps the old translated success message, because merge mode keeps any non-empty translated value. I translated those leaves by hand and ran `--write` a second time, so the provenance companions (`*.source-hashes.generated.ts`) dropped the copied-from-source entries the first run had recorded. Net: those companions are byte-unchanged against main. Every translated success message keeps the `${result.email}` token. - **Pins:** - `packages/platform-objects/src/identity/invite-user-action-copy.test.ts` (34 cases): the three declarations, their parity, the four bundles, and the served object metadata through `translateMetadataDocument('object', …)` over `SetupAppTranslations`. - `packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts` (6 cases): the same shape over `ApprovalsTranslations`. - **Changesets:** one `patch` each for `@objectstack/platform-objects` and `@objectstack/plugin-approvals`. ## The `${result.email}` choice was measured The card did not measure what `invite-member` answers, so I booted the door in-repo and read the live answer. I used `pnpm dev:crm -- --fresh` (before the change) and `pnpm dev -- --fresh` (showcase, after the change), signed in as the seeded admin, and sent the body the console's api handler sends: `POST /api/v1/auth/organization/invite-member` with `email`, `role` and `organizationId`. Both runs answered HTTP 200 with the bare invitation row: {"organizationId":"org_…","email":"grace.hopper@example.com","role":"member","teamId":null,"status":"pending","expiresAt":"…","createdAt":"…","inviterId":"…","businessUnitId":null,"positions":null,"id":"…"} The answer has no `data` key, and its top-level keys are not the legacy action envelope. So, at the objectui pin `a58626c8`: 1. `useConsoleActionRuntime`'s api handler passes the body through as `result.data`. 2. `readActionPayload` returns it unchanged. 3. `composeSuccessMessage` fills `${result.email}` from it. Its scope is `{ result: payload }` only. The runner has no submitted-parameter scope for success copy, so `${result.*}` is the only route. The address comes back lowercased: better-auth stores it that way. ## Served metadata, before and after `GET /api/v1/meta/objects/NAME` with `Accept-Language` is the object-metadata read the console uses. These are the `item.actions[]` entries. | object · action · locale | before (main `7d7943dd`) | after (this branch, dist rebuilt) | | --- | --- | --- | | sys_user · invite_user · en | no description · "Invitation sent" | description "Invite someone by email address. …" · "Invitation sent to ${result.email}" | | sys_user · invite_user · zh-CN | no description · "邀请已发送" | description "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。" · "已向 ${result.email} 发送邀请" | | sys_member / sys_invitation · invite_user · zh-CN | no description · "邀请已发送" | same as sys_user | | sys_approval_request · approval_approve · en | no description | "Approve this request? …" | | sys_approval_request · approval_approve · zh-CN | no description | "通过该请求?你的审批将被记录,此步骤获得所需的审批后,请求即继续流转。" | I ran the console's success-copy composition step for step over the captured answer and the served `successMessage`: - en: "Invitation sent to grace.hopper@example.com" - zh-CN: "已向 grace.hopper@example.com 发送邀请" ja-JP and es-ES are pinned at the bundle level only. Showcase declares `supportedLocales: ['en', 'zh-CN']`, so those two locales serve English on every action there. The control is `ban_user`'s label, which reads "Ban User" under ja-JP. ## Tests and gates (all at HEAD `4ccbef7b22`) - **Package tests:** - `pnpm --filter @objectstack/platform-objects test`: 65 files, 1062 tests, all passed. - `pnpm --filter @objectstack/plugin-approvals test`: 62 files, 905 tests, all passed. - **Typecheck:** `pnpm --filter … typecheck` exited 0 for both packages. Each run includes `check:test-typecheck`, and `--listFiles` shows both new test files are in the test `tsc` program. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands, and I ran all 65, plus `check:i18n-coverage` and `check:i18n-walk-parity`. All 67 exited 0. `--ran` printed "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED". The verdict lines: - `check:i18n`: "OK (9 package(s) — all bundles in sync, no undeclared authoring keys)" - `check:i18n-stale-fill`: "OK (10 bundle set(s) — no new stale fills, 0 baselined)" - `check:i18n-coverage`: "OK (13 config(s), 621 baselined untranslated string(s), none new)" - `check:i18n-walk-parity`: "11 declared group(s), 9 walked, 2 exempted" - `check:nul-bytes`: OK - **Lint, narrowed:** I ran `eslint --no-inline-config --format json` over the 14 touched `.ts` files, with 0 ignored by the config's own matching. Result: 0 errors and 0 warnings. `eslint.config.mjs` never enables type-aware linting, so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is left to CI. ## Ablation (fix committed first; every leg through `scripts/ablation-replace.mjs` WRAP mode) The subjects resolve to `src/` through relative imports, so no rebuild was involved. Each anchor hit as declared, and the blob changed on disk. Each restore was proven against the HEAD blob with `git diff HEAD` empty, and once more for all four paths at the end. | leg | mutation | pin result | | --- | --- | --- | | A | sys_member `invite_user.description` set to `undefined` | 4 failed of 34 (declares a description; mirrors agree; en carries / serves the source for sys_member) | | C | zh-CN `invite_user.successMessage` reverted to the extractor-kept "邀请已发送" (3 hits) | 6 failed of 34 (token kept ×3; zh-CN served ×3) | | D | `approval_approve.description` set to `undefined` | 2 failed of 6 | | E | zh-CN `approval_approve.description` set to the English source the extractor seeds | 2 failed of 6 (translated; served zh-CN) | ## Acceptance notes - `docs/qa/platform-checklist/areas/ux-conventions.json` (lines 27, 39 and 71) and `docs/qa/platform-checklist/FOLLOW-UPS.md` (line 686) quote the old `successMessage 'Invitation sent'`. They describe the toast that objectstack-ai/objectui#11817 measured, and they are now one literal behind. I left them alone (outside this card's file fence). - PR objectstack-ai#22166 also edits `platform-objects`' generated bundles, for other objects. Both are generator-owned (`merge=os-regen`). Whichever lands second merges `main` and regenerates. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ng the CEL spelling of each token (objectstack-ai#19939, C half) (objectstack-ai#22259) Part of objectstack-ai#19939 — this lands the C half for every spelling CEL can write today, and measures the B half empty. What stays open on the card: refusing the two spellings this PR deliberately keeps (`{NOW()}` / `{TODAY() ± N}` and `{$User.*}`), each once CEL can spell it — see "Kept, and why" and the report's open question. Clause-②: yes (narrowing) ## The rulings this executes (quoted, not paraphrased) - objectstack-ai#11182 ruling D (record `5805777944`, maintainer 「11182 D 其他同意」), item 3: "**v18 carrier.** C (refuse the template dialect at registration, per-spelling remedies: `/ 100.0`, `has()` guards, a string form for `NOW()` / `TODAY()`) and whatever of B is lossless ride the v18 train"; governing text: "ADR-0087 D2 (only lossless mappings ride an automatic conversion — the 12 DIFF spellings the round measured are not lossless)". - The card body: "B, only where lossless. An ADR-0087 D2 conversion for the spellings the objectstack-ai#11182 round measured as SAME under both engines (13 of 25); the 12 DIFF spellings are ⛔ never auto-converted (a semantic rewrite is not a conversion)." and "this repo's 21 sites migrate in the same wave". - Triage `6051196407`: the card is `domain:spec` whole; the `service-automation` and `lint` files are declared cross-lane on the claim `6052247536`. The text-slot interpolation in `builtin/template.ts` is objectstack-ai#22110's and is untouched (one docblock paragraph names the value-slot retirement; no code there changed). - Release state, re-read at push on `origin/main` `f4bed5834` (merged into this branch): `.changeset/pre.json` is **present** (`"mode": "pre"`, `"tag": "next"` — objectstack-ai#22084 entered pre mode at 07:03Z today), so per triage `6038868940` and the dispatch this is graded `major`, with a BREAKING section and an ADR-0087 `registered` disposition. It was absent at the claim (`959c209d5`) and at this branch's first merge of `main` (`7d7943dd0`); the changeset moved from `minor` to `major` in the commit after the second merge. ## What changes A flow VALUE slot no longer reads the single-brace `{…}` template dialect. In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve is refused, with the CEL spelling of each token. A string with no token is the literal text it spells; a computed value is a CEL value envelope. **One judge, every door** — `packages/spec/src/automation/flow-value-slot-template.ts`: - `valueSlotTemplateRefusals(value)` judges one value; `flowNodeValueTemplateRefusals(nodeType, config)` locates every refusal in a node's config (the ledger's `value` slots through `resolveFlowNodeValueSlots`, plus the two legacy `assignment` shapes normalised exactly as the executor normalises them); `VALUE_SLOT_TEMPLATE_REFUSAL` is the sentence every refusal leads with. - The contract says it: `celValueSlotSchema` composes the judge, so `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema` and `UpdateRecordConfigSchema` refuse it at the value's path, and `AssignmentConfigSchema`'s catchall (the bare legacy shape) does too — one new dropped-refinement site, ledgered (`automation/AssignmentConfig` `out.catchall`, totals 678 → 679). - **Registration** — `AutomationEngine.registerFlow` (`validateFlowExpressions`) pushes one located failure per refused string: `node 'w' (create_record) create_record field value at config.fields.total: …`. - **Build door** — `@objectstack/lint` `validateStackExpressions` reports the same refusal as `expression-invalid` at `error` (the existing rule family; it gates `os validate`, `os compile` and the metadata save door). This replaces the `warning` hint ruling D point 1 put there for 17.x. - **Run time** — the `create_record` / `update_record` executors refuse it at their own `parseNodeConfig` (through `FlowValueSlotSchema`), and the `assignment` executor calls `flowNodeValueTemplateRefusals` before it assigns anything; both return a guard refusal, so a fault edge cannot route it. **The remedies, per spelling** (the refusal names them for the authored token): | you wrote | the refusal prescribes | what changes | |:--|:--|:--| | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — the `has()` guard: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | | `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run | | `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` | | `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | `/ 100.0`: CEL divides two integers as integers (`123.46` becomes `123`) — every integer divisor is rewritten in the prescription | | `'Renewal — {contract.number}'` | `source: "'Renewal — ' + contract.number"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | | `'{"a": 1}'` (braces meant literally) | `source: "'{\"a\": 1}'"` | a CEL string literal | ## B — measured empty: no spelling is lossless (ADR-0087 D2) Re-measured on this branch, not copied: every spelling was evaluated through the shipped interpolator (`interpolateString`) and through the shipped CEL value path (`AutomationEngine.evaluateValueEnvelope`, the real `celScope`) over the same variables. The 25-row grid reproduces the objectstack-ai#11182 round exactly — **13 SAME / 12 DIFF**: | # | spelling and input | template | CEL | verdict | |:--|:--|:--|:--|:--| | S1–S4 | `{name}`, `{amount}`, `{oppRecord.name}`, `{oppRecord.amount}` — key present | the value | the value | SAME | | S5 | `{userList.0}` → `userList[0]`, list non-empty | `"u1"` | `"u1"` | SAME | | S6 | `{$error.message}` → `vars["$error"].message`, present | `"boom"` | `"boom"` | SAME | | S7, S8 | `Hello {o.name}`, `Total: {amount}` → concatenation, holes present | the text | the text | SAME | | S9 | `{o.owner}`, key present with `null` | `null` | `null` | SAME | | S10 | token-free `converted` → `'converted'` | the text | the text | SAME | | S11 | `{round(… / 100 * 100) / 100}`, integer-valued amount | `54000` | `54000` | SAME | | S12, S13 | `{rows}` (a list), `{flag}` (a boolean) | the value | the value | SAME | | D1, D2 | the money spelling, `amount` `1234.56` | `123.46`, `1111.1` | `123`, `1111` | DIFF | | D3 | `{10 / 4}` | `2.5` | `2` | DIFF | | D4, D5 | `{NOW()}` → `now()`, `{TODAY()}` → `today()` | ISO text | a `Date` (Timestamp) | DIFF | | D6 | `string(now())` | ISO text | refused: no `string(Timestamp)` overload | DIFF | | D7 | `{missing}` — variable absent | `undefined` | fault: unknown variable | DIFF | | D8 | `{oppRecord.owner}` — key absent | `undefined` | fault: no such key | DIFF | | D9 | `{userList.0}` — empty list | `undefined` | fault: index out of bounds | DIFF | | D10 | `Hello {o.owner}` — hole `null` | `"Hello "` | fault: no overload for string plus null | DIFF | | D11 | `{$User.Id}` → `current_user.id` | the run user id | fault: unknown variable `current_user` | DIFF | | D12 | token-free `converted` read as CEL source | the text | fault: unknown variable | DIFF | The "13 of 25" in the card counted **probes**, not spellings: S1–S13 are the present-key / integer-valued scenarios of the same spellings whose absent-key, null-hole, decimal and Timestamp scenarios are D1–D12. Read per spelling — the unit a conversion rewrites — every authored spelling has a DIFF input: a path faults where the template wrote nothing (D7–D9), text with holes faults on a null hole (D10), arithmetic truncates (D1–D3), the date macros change type (D4–D6), `$User` has no binding (D11). Only a token with no variable in it (`{1.5}`, `{100}`) maps losslessly, and none is authored in either repository. Controls beyond the 25: a `has()` guard writes `null` where the template wrote nothing (X2–X4), so it is a semantic rewrite, not a conversion. So, by D2's letter and the card's own "a semantic rewrite is not a conversion", **no D2 conversion is registered**; the retirement is the D3 semantic entry `flow-value-slot-template-dialect-refused` (step 18, rationale fragment order 88). A pin replays the whole conversion chain, retired entries included, over every measured spelling and asserts each value comes out as authored. ## Kept, and why — two spellings CEL cannot write yet A refusal must name what to write instead. For two spellings there is nothing to name, measured: - **The date macros** — `{NOW()}`, `{TODAY()}`, `± N` days. CEL's `now()` / `today()` / `daysFromNow()` / `addDays()` yield a Timestamp, which reaches the data engine as a `Date` object (measured through ObjectQL with a recording driver: `today()` into a `date` field arrives as `Date(2026-10-08T00:00:00.000Z)` where the macro wrote `"2026-10-08"`), and `string(today())` is refused for want of an overload. This is the card's own contingency ("a `packages/formula` sub-card if v18 needs it") — it does. - **The run user** — `{$User.*}`. The flow CEL scope binds no user (`current_user.id` faults, D11). Binding ADR-0068's canonical `current_user` there is a contract decision this PR does not take (open question in the report). A string whose tokens include one of these keeps its 17.x meaning; everything else in it would be refused if moved alone, so the whole string is kept. Their refusal is the remaining half of objectstack-ai#19939, after the two prerequisites. ## This repository's sites (census at `959c209d5`) A TypeScript-AST walk over `git ls-files` (every `create_record` / `update_record` `fields` value and `assignment` value, all three shapes, same-file spreads): **21 authored sites**, 20 migrated here, 1 kept. | file:line (base) | before | after | |:--|:--|:--| | `examples/app-crm/src/flows/convert-lead.flow.ts:148` | `'{account_id}'` | `source: 'account_id'` | | `examples/app-crm/src/flows/convert-lead.flow.ts:149` | `'{opportunity_id}'` | `source: 'opportunity_id'` | | `examples/app-showcase/src/automation/flows/index.ts:115` | `'{new_assignee}'` | `source: 'new_assignee'` | | `examples/app-showcase/src/automation/flows/index.ts:1235` | `'{$error.message}'` | `source: 'vars["$error"].message'` | | `examples/app-showcase/src/automation/flows/index.ts:1602` | `'{record.title}'` | `source: 'record.title'` | | `examples/app-showcase/src/automation/flows/index.ts:1603` | `'{record.assignee}'` | `source: 'has(record.assignee) ? record.assignee : null'` | | `examples/app-showcase/src/automation/flows/index.ts:1604` | `'{record.project}'` | `source: 'has(record.project) ? record.project : null'` | | `examples/app-todo/src/flows/task.flow.ts:377` | `'{completedTask.subject}'` | `source: 'completedTask.subject'` | | `examples/app-todo/src/flows/task.flow.ts:377` | `'{completedTask.description}'` | guarded `has(completedTask.description) ? … : null` | | `examples/app-todo/src/flows/task.flow.ts:378` | `'{completedTask.priority}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:378` | `'{completedTask.category}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:379` | `'{completedTask.owner}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:380` | `'{completedTask.recurrence_type}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:381` | `'{completedTask.recurrence_interval}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:384` | `'{nextDueDate}'` | `source: 'nextDueDate'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{subject}'` | `source: 'subject'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{priority}'` | `source: 'has(vars.priority) ? vars.priority : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{dueDate}'` | `source: 'has(vars.dueDate) ? vars.dueDate : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{category}'` | `source: 'has(vars.category) ? vars.category : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{$User.Id}'` | **kept** (the run user — see above) | | `packages/verify/src/handle.fixture.ts:191` | `'{resolution}'` | `source: 'resolution'` | Each guard is a judgment the template made silently: a field a screen may leave empty, or a key a row may not carry, writes `null` (on insert a field default still applies). Docs code samples migrated too: `content/docs/automation/flows.mdx` (the assignment and create-record examples, an inline comment, the hot-lead example), `content/docs/kernel/runtime-services/examples.mdx` (two fields), `packages/services/service-automation/README.md` (one field), and the test fixture `packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts:81`. **hotcrm** (public, read-only clone at `c529de2`, not touched): 91 authored sites (2 of them through the same-file `MEMBERSHIP_FIELDS` spread) — **71 refused** (31 bare references, 36 dotted paths, 4 text with holes) and **20 kept** (15 date macros — 8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}` — and 5 `{$User.Id}`, all `owner_id` on `create_record`); plus 5 in tests (4 refused, 1 kept). Its two money sites already use the CEL envelope with `/ 100.0`. ## H4 — where `{var}` is still read after this change - **In the three value slots:** the interpolator call stays (`crud-nodes.ts` `resolveFieldValues`, `logic-nodes.ts`), reached only by the two kept spellings; on every other literal it is the identity. It is removed when the kept spellings are refused. Pinned: the kept spellings resolve through the executors (`logic-nodes.test.ts`, `crud-fields-value-envelope.test.ts`), and `value-slot-template-grammar.test.ts` drives the interpolator over every kept and refused spelling so the spec's copy of the token grammar cannot drift from `resolveToken`. - **Outside them, unchanged by this card:** text slots (objectstack-ai#22110's: `notify` title / message, screen text, `end` message), `filter` values (`interpolateFilter`, the filter-placeholder hand-off), `loop.collection` / `map.collection` (the ledger's `flow-template` role), and the value-like positions `subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, and `http` (interpolated whole before its parse). ## Wrong guidance (ruling D item 2) `builtin/template.ts` and `content/docs/automation/flows.mdx` already carried `/ 100.0` on `main` (objectstack-ai#20205). This PR removes the last `round(x * 100) / 100` claim in its surface: the comment in `flow-field-expression-scale.integration.test.ts` that called it "the CEL-identical authoring pattern" (the oracle now runs as a CEL envelope with `/ 100.0`, and the docblock states integer division). `skills/objectstack-automation/SKILL.md:232` still reads `{round(x * 100) / 100}` and teaches `{token}` in `fields`; it is Tier H and not in this PR. ## BREAKING An accept-set narrowing on a published authoring surface (`Clause-②: yes (narrowing)`, copied from the claim), graded `major` on the v18 `next` pre line; the changeset carries the BREAKING banner, the FROM → TO table above and the ADR-0087 disposition `registered flow-value-slot-template-dialect-refused`. A stored flow carrying a refused value is refused at registration (skipped at boot with a warn naming it); `os validate` names each one with its CEL spelling. ## Tests and gates This PR opens at `b073d92de`. The package suites, typechecks and consumer runs below were read at `31c52e59c` (or the commit named); the second merge of `main` after it changed nothing under `packages/spec/src/automation`, `packages/lint`, `packages/services/service-automation`, `packages/triggers`, `packages/qa`, `packages/verify` or `examples` (in `packages/cli`, only its secret-rewrap files), and the spec build, generated-artifact check, spec migration / conversion / automation suites and every gate were re-run at `b073d92de`. The box is shared, so durations are not quoted. Builds, tests and typechecks ran through `scripts/pm/os-verify-lock.sh`, each read off its `VERDICT command-exit` line. - **Package suites** (vitest, `--maxWorkers=2`): `@objectstack/spec` 679 files, 19605 passed + 1 todo; `@objectstack/lint` 125 files, 5730 passed; `@objectstack/service-automation` 176 files, 2157 passed. - **Typecheck** (`pnpm --filter … run typecheck`, exit 0 each): spec, lint, service-automation, trigger-record-change, dogfood, cli, example-todo, example-showcase, example-crm, verify. - **Consumers** (after a full `turbo run build --concurrency=2`, exit 0): `trigger-record-change` 11 files / 114; `trigger-schedule` 8 / 174; `plugin-approvals` 61 / 899; `verify` 18 / 133; `example-todo` 7 / 238 (at `0d7eb274c`); `example-showcase` 33 / 408; `example-crm` 5 / 45; `mcp` 7 / 74, `metadata-protocol` 6 / 127 and `runtime` 20 / 548 — each the files of that package that author these node types or load an example (a narrowing, declared: the rest of those suites is CI's); `cli` unit 2 / 14 and integration 2 / 14 (the integration project run because this diff edits `package-install-local-boot-steps.integration.test.ts`; four more cli files I named are integration-tier and declared to CI); `dogfood` 3 / 25 (`flow-trigger-record-credential-mask`, `flow-durable-suspend`, `expression-conformance`). - **The new pins**: `packages/spec/src/automation/flow-value-slot-template.test.ts` (every refused class with its remedy, the kept spellings, the controls, every value-slot contract, every value position of a node, and the no-conversion replay); `packages/lint/src/validate-expressions.fields-value-slot.test.ts` (the build door: `expression-invalid` at `error`, located, in all three value slots and both legacy shapes; kept spellings and `filter` clean); `packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts`, `logic-nodes.test.ts` and `assignment-value-envelope.test.ts` (registration and the run-time twin, nothing written, kept spellings resolve); `value-slot-template-grammar.test.ts` (the spec judge against the interpolator, kept and refused spellings and the dispatch-order edges). - **Generated artifacts**: `pnpm --filter @objectstack/spec check:generated` — 15 of 15 current after `--fix` regenerated `api-surface/`, `export-origins/` and `content/docs/references/**` on the merged tree, re-read exit 0 at `b073d92de` after `pnpm --filter @objectstack/spec build` (exit 0); `dropped-refinements.baseline.json` hand-edited (one site, totals 678 → 679). At `b073d92de` the spec's `src/migrations`, `src/conversions`, `src/automation` and `scripts` suites: 115 files, 3251 passed. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b073d92de` (47 paths vs merge base `f4bed5834`) derived 120 commands; all 120 ran with their exit codes captured before any pipe. 119 answered exit 0 on the first pass; `check:skill-examples` answered exit 3 PREREQUISITE NOT MET (`packages/client/dist` older than `src` after the merge — nothing measured), and after `pnpm --filter @objectstack/client build` (exit 0) it answered exit 0, "262 prose examples type-check across 3 surface(s)". `--ran` reconciliation: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED". The printed artifact-roster block (35 roster, 14 checker-health self-tests) and the 11 declared wide-population families ran too: 57 exit 0; `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` answered exit 2 "NOT WIRED" (they need a PR number / body) — NOT MEASURED there; `check-partof-closing-keyword` with this body as `PR_BODY` is in the report. - **Merged `main`** twice through `scripts/pm/os-regen-merge.sh` (each merge committed first, regeneration as its own commit): at `7d7943dd0` (`pnpm install --frozen-lockfile` after it), and at `f4bed5834`, which brought `.changeset/pre.json` and objectstack-ai#22166's step-18 entry (`sys-setting-global-rung-moved`, rationale order 87 — this PR's fragment keeps 88, the next free one; `gen:migration-registry` re-run on the merged tree changed nothing). Seven later `main` commits (to `73a0a6bf1`) are not merged: `git merge-tree` answers clean, their three overlapping files (`packages/lint/src/validate-expressions.ts` / `.test.ts`, `packages/spec/src/migrations/registry.ts`) change other regions, and none adds a `{…}` value-slot string. ## Acceptance notes - **`current_user` in a flow's CEL** — the build doors and the run disagree today, independently of this PR: `validateExpression('predicate', "current_user.id == 'u1'", { scope: 'flattened', … })` (the check `registerFlow` and `os validate` run on a flow condition) answers `ok`, and `ExpressionEngine.evaluate` over the flow's scope shape answers "Unknown variable: current_user" (measured at those two primitives; a door-level run is not part of this PR). Binding ADR-0068's `current_user` in the flow CEL scope would close that and give `{$User.*}` its remedy — the open question in the report. - **Value-like `{var}` positions outside this card's three slots** still read the dialect: `subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, and `http`. Text slots are objectstack-ai#22110's; these have no carrier. - **Two findings on one value**: `validate-flow-template-paths` still checks a `{record.…}` path inside a value-slot string that is now refused anyway, so such a value draws both its path finding and the refusal. Harmless; no carrier. - **The save door**: the refusal is a `validateStackExpressions` finding, the rule the runtime publish gate runs on a flow write, so a Studio / REST / MCP save of such a flow answers `422 INVALID_METADATA` by construction — not separately measured in this PR (objectstack-ai#19938 measured that door for the envelope arm of the same rule). - **Kept-spelling fixtures**: spec and lint test fixtures that only go through `FlowSchema.parse` or a non-expression rule (`flow.test.ts`, `validate-field-consumers.test.ts`, `validate-flow-template-paths.test.ts`, `validate-readonly-flow-writes.test.ts`) still spell a value-slot template; they pass, because `FlowSchema` judges no value slot and those rules filter by their own id. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) (objectstack-ai#22266) Part of objectstack-ai#15207 Clause-②: no (narrowing) The claim (`6055795594`) declared the value `yes` with the narrowing arm and asked the dev to measure the built declaration closure. Measured: `check:api-surface` on the rebuilt `@objectstack/spec` reports the public surface unchanged, and no package in this diff adds an export, a published key or an accepted value; the diff only narrows (the ledger refuses `organization_id`, and `organization_admin`'s ledger grant loses its superuser bits). So the value is `no`, the arm stays `(narrowing)`, and the changeset carries the same line. It is the reading item (1) declared for the same shape. Item (4) of objectstack-ai#15207 is not built here, so the card stays open. ## Scope: item (2) only ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so "the organization an audit row is *about* becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor", and the object "is governed by object permission, not by the wall". The seat's ruling is option A of report `6042515710`. Item (1) landed as objectstack-ai#22107 and item (3) as objectstack-ai#22166. No stored row moves here (ADR-0131 D14): the column's fate is C7's (objectstack-ai#15211), below. **Patch round** (seat ruling `6058257824`, which widens the claim's file surface): - the changeset now states the `single`-posture consequence; - a global settings change's `config_change` row carries no `tenant_id`; - the inert `organization_id` stamps are removed in `platform-admin-standing-audit.ts` and `config-change-audit.ts`, and the predating comments are corrected, including `managed-object-write-denies.ts`' docblock. The open question on a `single` deployment holding more than one organization is ruled A (ADR-0131 D8 and §1.2(3)). ## What changes - **`sys_audit_log`** (`plugin-audit/src/objects/sys-audit-log.object.ts`) declares `systemFields: { tenant: false }`. The registry injects no `organization_id`, and a new table is provisioned without it. `tenant_id` (lookup to `sys_organization`) is unchanged and is now the only organization column; its help text says what it is. The four translation bundles and the README follow. - **The plugin-audit writers** (`audit-writers.ts`, `read-audit.ts`, `auth-event-audit.ts`) keep stamping `tenant_id` as before and drop their conditional `organization_id` stamp, which the registered schema can no longer satisfy. No fallback read or write of the retired column remains (D14). - **The settings writer** (`service-settings/src/config-change-audit.ts`): a GLOBAL-scope change is a deployment-level action about no organization, so its `config_change` row carries no `tenant_id`, whatever organization the writing session has active. Tenant- and user-scope changes keep the writer's organization. Its `organization_id` field probe and stamp are gone. The `SettingsAuditSink.tenantId` TSDoc in `settings-service.types.ts` says the same. - **The platform-admin standing writer** (`plugin-security/src/platform-admin-standing-audit.ts`): the `declaresOrganizationId` input and its stamp are gone, and the call site in `bootstrap-platform-admin.ts` with them. `tenant_id` stays NULL by ruling, and its comment block now reads against the column-less ledger. No behaviour moves: the registered ledger declares no such column after this PR. - **`managed-object-write-denies.ts`'s docblock** no longer cites the ledger as reached by the wildcard's superuser bits. `organization_admin` names it explicitly, without them. - **The read scope, option A** (`plugin-security/src/objects/default-permission-sets.ts`): - a platform row policy `sys_audit_log_org`: `tenant_id == current_user.organization_id`, operation `select`; - spread into `organization_admin` (and so its derived no-bypass variant), `viewer_readonly` and `member_default`. A set that holds no policy for an object leaves it unfiltered, so the policy goes where the shipped reads are. `viewer_readonly`'s wildcard reads the ledger. `member_default` is the baseline every authenticated human holds, so a ledger read an application set grants is scoped too. This is the placement `scimProjectionRowScope` already uses; - an explicit `sys_audit_log` entry in `organization_admin`: read only, with no `viewAllRecords` / `modifyAllRecords`; - stripped under `single` by the existing provenance rule (ADR-0105 D3), with no new code. - **Retention** (`objectql/src/lifecycle/lifecycle-service.ts`). `tenantWindowsFor` now returns the partition column with the windows. It is `organization_id` where the object has a provisioned one (unchanged). Otherwise it is the ledger's attribution field, from a one-row, name-keyed table `ATTRIBUTION_PARTITION_COLUMNS` (`sys_audit_log` → `tenant_id`), honoured only where the author really declares the field. The reaper and the archiver name only that column. - **`view_all_audit_log`** (`spec/src/security/capabilities.ts`): the description and its comment are re-premised on the attribution field and the row scope. `eval-user.zod.ts` lists the name only and restates nothing. - **ADR-0087**: the D3 entry `18.sys-audit-log-organization-column-retired.ts`, one step-18 rationale fragment, and the regenerated `registry.ts`. - **Censuses**: `scripts/platform-object-tenancy-census.json` regenerated by its own tool (in reach 50 → 49, out 34 → 35, `systemFields.tenant: false` 9 → 10). The tenant-audit and system-context censuses are green and did not move. ## Premise readings (each measured before code) - **P1, holds, with one refinement.** The ledger holds rows about deployment-level actions with no organization: - `platform_admin_standing_change`: `plugin-security` `bootstrap-platform-admin.ts` through `buildPlatformAdminStandingRow`, `tenant_id` always NULL, by ruling; - `import`: `plugin-auth` `admin-import-users.ts`, the run-level row of a platform-admin endpoint, no `tenant_id`; - administrative `create` / `update` on `sys_user`: `plugin-auth` `admin-user-endpoints.ts`, platform-admin endpoints, no `tenant_id`; - `config_change`: `service-settings` `config-change-audit.ts`. Refinement: it stamped the writing context's organization, so a global-scope key written by a session with an active organization carried that organization. Ruled into this PR (`6058257824`): a global-scope change now carries no `tenant_id`. - **P2, holds, measured** through the real permission compiler (a real `SecurityPlugin` over a real `ObjectQL` and SQL driver, with the shipped sets). With the explicit entry removed and the policy kept, an organization admin under `isolated` reads every organization's rows. Mechanism: `systemFields.tenant === false` makes `meta.tenancyDisabled` true, so `posturePermits` holds in `computeLayeredRlsFilter` and the wildcard's superuser bypass skips Layer 1. - **P3, holds.** `security-plugin.ts#collectRLSPolicies` drops a policy when `!this.orgScopingEnabled && isPlatformTenantPolicy(policy)`. `orgScopingEnabled` is `postureEnforcesWall(this.tenancyPosture)`. The provenance set is `PLATFORM_TENANT_POLICY_KEYS` in `platform-tenant-policies.ts`, built from the shipped sets' policies whose `using` names `current_user.organization_id`. The new policy is in that set (pinned). - **P4.** Writers that stamp `tenant_id`: - the record mirror (`audit-writers.ts`): the record's organization, else the session's; - the record-view writer (`read-audit.ts`): the record's organization, else the session's; - the sign-in writer (`auth-event-audit.ts`): the session's organization; - the settings writer (`config-change-audit.ts`): the writing context's organization, and none for a global-scope change since this round. Each stamped the injected column with the same value. Explicit NULL: the platform-admin standing writer. Not stamped: the two `plugin-auth` administrative writers. Writers that update existing rows: `stored-metadata-body-migration.ts` and the CLI's `audit-metadata-bodies` rewrite `old_value` / `new_value` only. A row with no `tenant_id` matches no organization under the new policy. Under a wall it is served to platform administrators only; under `single` it is served to every ledger reader. ## Who reads what (measured; rows `a1` about org A, `b1` about org B, `d1` about no organization) | posture | caller | before | after | |---|---|---|---| | `isolated` | organization admin of A | `a1` | `a1` | | `isolated` | viewer of A | not measured | `a1` | | `isolated` | platform admin (active org A) | `a1` | `a1 b1 d1` | | `isolated` | platform admin, no active organization | not measured | `a1 b1 d1` | | `isolated` | organization admin, no active organization | not measured | none | | `single` (one organization) | organization admin, both variants | `a1 d1` | `a1 d1` | | `group` (member of A and B, active A) | organization admin | `a1 b1` | `a1` | | `group` | platform admin | `a1 b1` | `a1 b1 d1` | | `single` holding two organizations | organization admin, viewer, platform admin | `a1 d1` | `a1 b1 d1` | | `isolated` | organization admin of A, reading a global settings change (`g1`, no `tenant_id`) and a tenant-scope change about A (`t1`) | not measured | `t1` | | `isolated` | platform admin, the same two rows | not measured | `g1 t1` | The `group` and two-organization `single` rows are readings, not pins. Under `group` the policy scopes to the ACTIVE organization, not the membership union; the seat accepted that as the fail-closed direction. A `single` deployment that holds two organizations boots today, reported at boot. Before this change, the SQL driver's native organization arm still narrowed the ledger there; with no column it cannot, and the policy is stripped under `single`. The seat ruled this A (ADR-0131 D8, §1.2(3)), and the changeset states it with its remedy, a walled posture. ## Pins (refused and still-accepted case each) - **Organization scope** (`plugin-security/src/sys-audit-log-row-scope.test.ts`): - an organization admin of A reads `a1` and not `b1` or `d1`; - CONTROL: the same read with the policy removed from every set serves all three; - the explicit entry is load-bearing: without it, the bypass reads all three; - a viewer is scoped the same way; - a global settings change's row (`config_change` on `sys_platform_setting`, no `tenant_id`) is not served to an organization admin, and a tenant-scope change about its organization is. The platform admin reads both. - **The settings writer** (`service-settings/src/config-change-audit.test.ts`): - the `objectstack-ai#8145 … WIRES the generic sink` case on a global manifest, written by a session with `org_1` active, asserts `tenant_id` null and no `organization_id`; - CONTROL: a tenant-scope write keeps `org_1`. - **Platform scope**: a platform admin reads all three, the deployment-level row included. CONTROL: before the change, the wall hid `d1` (and `b1`) from the platform admin. - **`single`**: the policy is a platform tenant policy by provenance, and both organization-admin variants read exactly what the pre-change read served. - **The write** (`plugin-audit/src/objects/sys-audit-log-attribution.test.ts`, a real kernel with SQLite): - a deployment-level row with no `tenant_id` is written with no refusal; - a write still naming `organization_id` is refused `INVALID_FIELD` / 400; - a filter on it is refused `INVALID_FILTER` / 400; - the record mirror stamps the record's organization into `tenant_id` and writes no `organization_id`. - **Retention** (`objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts`, a real `ObjectQL` registry): - the reaper and the archiver partition a tenant's override on `tenant_id`, and the global pass keeps the NULL rows; - no `INVALID_FILTER`, so a tenant's override no longer stops the table's reap; - CONTROLS: the ledger without the field and another column-less object carrying a `tenant_id` both run one global pass; the ledger with the injected column partitions on `organization_id`. - **DDL**: the injection plan carries no `organization_id`. The provisioned SQLite table (introspected after a real schema sync) has `tenant_id` and no `organization_id`. CONTROL: an ordinary object on the same sync has the column. ## Reverse verification (one-off, `scripts/ablation-replace.mjs`, each restore read blob == HEAD with `git diff HEAD` empty) - **A.** Delete the `sys_audit_log_org` policy literal (anchor 1 → 0). Exactly four red: the organization-admin pin, the viewer pin, the provenance pin and the `member_default` roster pin. 28 green, the controls and the platform-admin pins included. - **B.** Delete `organization_admin`'s explicit ledger entry. Exactly the organization-admin pin red; 8 green, the viewer pin included. - **C.** Delete the `ATTRIBUTION_PARTITION_COLUMNS` row. Exactly the reap and archive pins red; 4 green. - **D.** Make the settings writer stamp `tenant_id: entry.tenantId ?? null` for every scope again. Exactly the global `WIRES` case red; 16 green, the tenant-scope control included. The subjects are imported by relative source path, so no `dist` is in the path. ## Fate for C7's inventory (objectstack-ai#15211, ADR-0131 D10 fate 1) `sys_audit_log.organization_id`: **drop the column, once its values are confirmed in `tenant_id`; report the rows where they differ.** By the writer census, every writer that stamped the column stamped the same value into `tenant_id` (or NULL into both). The check is NULL-safe: a row differs when exactly one of the two is NULL, or both are set and unequal. A zero count means `os migrate apply --allow-destructive` drops the orphan the boot drift report already names. Any other count is listed with the row ids, never guessed and never dropped. Until then, schema sync is additive and the column stays as an orphan that nothing reads or writes. ## Files outside the claim's file surface - `packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts`. Its arming control read the ledger's `organization_id`. It is re-keyed to `tenant_id`, its prose names the row scope, and one test title says "superuser read bypass" for "wall bypass". With it, all 13 ledger dogfood files pass. - In the patch round, beyond the files `6058257824` names, two changes the named edits require: - `service-settings/src/settings-service.types.ts`: the `SettingsAuditSink.tenantId` TSDoc restated the removed stamp; - `plugin-security/src/bootstrap-platform-admin.ts`: the one caller passing the removed `declaresOrganizationId` input. - Within the claimed packages but not named in the claim: plugin-audit's three writer test files, translation bundles and README; plugin-security's `rbac-objects.test.ts` roster pin; objectql's federated reader census (`federated-injected-column-readers.test.ts`), whose `#reap` / `#archiveObject` rows go because those passes now name only the column `tenantWindowsFor` returns. ## Verification (at `b865914be5`) The patch round touched `service-settings` and `plugin-security` (source and tests), the changeset, and no objectql, spec or plugin-audit source. The objectql and spec runs were taken in round one, at `ef87292b75`, whose files they read are unchanged since. - service-settings: 38 files / 642 passed. plugin-security: 179 files / 3749 passed / 45 skipped. plugin-audit: 41 files / 649 passed. objectql (round one): 382 files / 7531 passed. - spec: `--project local`, 625 files / 18661 passed; `--project repo` `step18-rationale-merge` + `conversions-major18-merge`, 21 passed; `check:generated` 15 of 15 up to date; `check:api-surface` unchanged. - dogfood: the 13 files that touch the ledger, 93 passed, re-run at this head. - Typecheck exit 0 for service-settings and plugin-security at this head; for plugin-audit, objectql, spec and dogfood in round one. Test layers are included. - Gates: `dispatch-gates --commands` (no paths) derives 104 families at this head. All 104 ran with their exit codes recorded, and the `--ran` reconciliation reads a derived zero NOT-MEASURED. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 31 changed `.ts` files gives 31 results, 0 errors, 0 warnings. The population is read from `eslint.config.mjs`'s `packages/**` and `**/*` globs. That config enables no type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is CI's. ## Acceptance notes - Out of reach of the row policy: an application set that grants the superuser read bypass on the ledger (`viewAllRecords` on it or on a wildcard) skips Layer 1, as it does on every object the wall does not cover. No example app ships such a grant. - The organization row scope is the active organization's under `group`, and stripped under `single`. Both are ruled; the changeset states each, with the remedy for a multi-organization `single` deployment. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…form-global gets no organization column on that deployment — the objectstack-ai#12699 declaration made total (ADR-0131 D7) (objectstack-ai#22331) Fixes objectstack-ai#15207 Clause-②: yes (narrowing: on a deployment that declares an object platform-global, that object carries no organization column; the dev measures the built declaration closure) Measured on the built declaration closure. The value `yes` holds: `@objectstack/core` gains exports, because the one fail-closed reader of the `org-scoping` keys moved there. `resolveInjectedSystemColumns` gains an optional second parameter but no new spec export, and `check:api-surface` on the rebuilt spec reports the surface unchanged. The arm stays `(narrowing)`, because the behaviour narrows: a declared object loses its column on the declaring deployment. The changeset carries `Clause-②: yes (narrowing)`. This is the last open item of the card (items (1), (2) and (3) landed as objectstack-ai#22107, objectstack-ai#22266 and objectstack-ai#22166), so line 1 closes it. ## Scope: item (4), the objectstack-ai#12699 declaration made total ADR-0131 D7: "an object a deployment declares platform-global gets **no organization column on that deployment** (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." ADR-0131's retirement list names "objectstack-ai#12699's stand-down semantics (replaced by D7's no-column)". Claim `6061910188`. No stored row moves and no step runs at boot (ADR-0131 D14). ## What changes - **The plan** (`spec/src/data/injected-system-columns.ts`): `resolveInjectedSystemColumns(def, deployment?)`. The second argument carries the deployment's validated `platformGlobalObjects`. A declared object is planned with no `organization_id`, and the rest of its plan is unchanged. With the argument absent or empty, every plan is byte-identical to the one-argument call (pinned over eight object shapes). Author-time callers pass nothing. The module doc says where that leaves them: see P5. - **The reader** (`objectql/src/registry.ts`, `objectql/src/plugin.ts`): - `ObjectQLPlugin.start()` reads `org-scoping` FIRST, before `loadMetadataFromService` and before the first `installRegisteredSchemas`. - It installs the validated set with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. - The registry passes the set to `applySystemFields` as the plan's input, and to the tenant-index predicate (`carriesTenantScopeColumn`). - `materializeBaseLayer` gains a first stamp, `applyDeploymentTenancy`. It records the plan's answer on the base layer as `systemFields: { tenant: false }`, which is the vocabulary every registered-object reader already answers "no organization column" from. Its write-side inverse is the last strip in `stripMaterializedStampsFrom`, so a Studio GET → PUT stores the body the author wrote (objectstack-ai#4326). - Objects registered before the install (inside other plugins' `init()`) are re-planned at the install, on every contributor layer. - The registry reads the declaration in ONE place, `deploymentWithholdsTenant`, which asks the spec plan with and without the deployment input. - The plugin logs the declared list once. It warns once, by name, for an object that DECLARES its own `organization_id`: that column is the author's, so it stays and is walled. It warns once for a refused (malformed) key. - **The stand-down retires** (`plugin-security/src/security-plugin.ts`): - The third `tenancyDisabled` clause in `getObjectSecurityMeta` is gone, the one that read `platformGlobalObjects`. A declared object reaches the wall as its own `systemFields.tenant: false`. - The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list. - The plugin now warns only for the refused key it reads, `suppressUnboundedOrgAdminGrant`. That key and its behaviour are unchanged. - **The reader moves** (`deployment-org-scoping-entitlement.ts`: plugin-security → `core/src/security/`). Its consumers are now in two packages that cannot import each other: objectql reads `platformGlobalObjects`, plugin-security reads `suppressUnboundedOrgAdminGrant`. It is exported from `@objectstack/core` with its rules unchanged: absent ⇒ nothing declared; junk ⇒ the whole key refused, never coerced, each key independently. - **The provider declaration** (`plugins/organizations/src/organizations-plugin.ts`): `providesServices = ['org-scoping']` (ADR-0116 D2). See P2. - **Narratives**: `tenancy-posture.ts` (the `platformGlobalObjects` and `suppressUnboundedOrgAdminGrant` docs), `tenant-layer0-verdict.ts` (the carve-out row), `engine.ts` (two docblocks), and `auto-org-admin-grant.ts` (one docblock). - **ADR-0087**: the D3 entry `18.platform-global-object-organization-column-retired.ts`, one step-18 rationale fragment (order 89), and the regenerated `registry.ts`. - **Changeset** `.changeset/15207-platform-global-no-organization-column.md`: `major` for `@objectstack/objectql` and `@objectstack/plugin-security` (Changesets pre mode is in, `.changeset/pre.json`), `minor` for spec and core, `patch` for organizations, with its BREAKING banner, its FROM → TO and the marker. ## Premise readings (each measured before code) - **P1, HOLDS.** The harness: a booted `ObjectKernel` with `ObjectQLPlugin` over SQLite, the real `SecurityPlugin`, and a fixture provider composed after the objects plugin declaring `platformGlobalObjects: ['qa_widget_registry']`. It was measured at `799eb000c7`, before any edit: - the declared object was registered with `organization_id`, and its table was created with the column (`columnInfo`); - `security.getReadFilter(declared, member)` answered no wall (the fold in `getObjectSecurityMeta`), and the sibling answered `{ organization_id: 'org_acme' }`; - a system read of the declared table carrying `tenantId: 'org_acme'` returned only the `org_acme` row of two (the driver's tenant arm). So Layer 0 and the driver disagreed about one object. Control: with no declaration, both objects were walled. - **P2, HOLDS through the Phase 1/2 split, NOT through a per-plugin edge.** - **Where the plan is computed:** `SchemaRegistry.registerObject` → `applySystemFields` → `resolveInjectedSystemColumns`. That runs inside whichever plugin's `init()` calls `manifest.register`, and again for later registrations (`loadMetadataFromService` and `restoreMetadataFromDb` at `start()`, installs after it). The columns are fixed at `ObjectQLPlugin.start()` → `installRegisteredSchemas`. - **When `org-scoping` is registered:** in `OrganizationsPlugin.init()`, which hard-depends on the engine (its `init()` calls `manifest.register`). `serve` composes it after Auth and the app plugins, so it initializes after many object registrants, and the fixture measured that too: the declared object was already registered when the provider initialized. - **What orders them:** ADR-0116's Phase 1/2 split. Every `init()` completes before any `start()`, so the provider has registered by the engine's `start()`, and no table exists yet. The provider now declares it in `providesServices`, so "absent at start" is a declared fact (ADR-0116 D2; the AGENTS.md startup-registry cure 2). - **Measured:** neither ADR-0116 edge can order the provider ahead of the registration-time plan itself. An engine-side `optionalDependencies` on the provider is a cycle, and `resolvePluginOrder` throws on an optional edge too. An edge from every object registrant is an open-ended set. That is why the registry re-plans at the install. - **Validation:** a provider that registers outside `init()` with a different declaration fails the boot at `kernel:ready`, naming both lists and `providesServices`. - **No boot move:** no plugin moved, and no data step runs at boot. - **P3, HOLDS.** With the key absent, every object's registered shape is byte-identical: pinned as JSON equality between a registry with no install and one with an empty install, plus the spec pin over eight shapes, plus the kernel pin. With junk (`platformGlobalObjects: 'qa_widget_registry'`), the engine warns `'platformGlobalObjects' REFUSED` once, and every object keeps its column and its wall. - **P4, HOLDS.** At `799eb000c7`, `git grep platformGlobalObjects` finds no declarer outside tests: the spec schema and docs, the reader, plugin-security's consumer and log, and tests only. Every pin uses a fixture provider. - **P5, measured.** Author-time surfaces compute the plan with no deployment, so on the declaring deployment they still name `organization_id` for a declared object: - the linter's addressable-name set (`lint/src/system-fields.ts`); - the import mapper (`spec/src/data/import-mapping-target.ts`); - the tenancy census (`scripts/platform-object-tenancy-census.mjs`); - the CLI's authoring filter judge. Runtime surfaces on that deployment agree with it: the registry, the DDL, the `/meta` read exits (pinned through `ObjectStackProtocolImplementation`), the metadata bridge that `describe` reads, the lifecycle provenance (`absent`), and the field doors (`INVALID_FIELD` / `INVALID_FILTER`). The plan's module doc and the changeset say so. One one-shot surface depends on composition: `os migrate plan` / `apply` composes the host config's plugins, not `serve`'s posture-driven `OrganizationsPlugin`. A declaring deployment whose provider arrives only through `serve` would get a migrate plan that adds the column back. That is under Acceptance notes, for C10. ## Pins (refused and still-accepted case each) - **The plan** (`spec/src/data/injected-system-columns.test.ts`, 5 cases): - a declared object has no `organization_id`, and only that moves; - CONTROL: a sibling keeps it; - the array form works; - absent, empty set and empty list are byte-identical over eight shapes; - a nameless record is never declared. - **The registry** (`objectql/src/registry-deployment-platform-global.test.ts`, 7 cases): - registered after the install: no column, no tenant index, the record present; - registered before the install: re-planned on every contributor layer, `extend` included; - absent or empty: JSON-identical; - an authored `organization_id` is kept and reported; - an object that opted out itself is untouched; - the `/meta` read exit serves the registry's answer; - a stored body converges at the read seam, and the write seam takes the record off. CONTROL: an author's other `systemFields` member survives, and a non-declared object is never touched. - **The kernel** (`plugin-security/src/platform-global-no-organization-column.test.ts`, 8 cases, booted kernel with a fixture provider): - the plan and the DDL, with the sibling control; - absent key; - junk key (warned once; every column kept); - Layer 0 composes no wall and the driver reaches every row, while the sibling is walled at both; - a write naming `organization_id` is refused `INVALID_FIELD` / 400, and the sibling accepts it; - **no stand-down path remains**: the plugin over an engine whose plan never received the declaration walls the object; - a provider registered after the objects reaches the plan; - a provider that registers in `start()` refuses the boot by name. - **Layer 0 and the driver at once** (`tenant-layer0-verdict-end-to-end.test.ts`): a member's predicate update on the declared object now matches both rows (it matched one before, the driver's tenant arm), and the bulk event names no organization. CONTROL: the sibling sweep matches one row and names `org_acme`. - **plugin-security reads no declaration** (`deployment-platform-global-exemption.test.ts`, rewritten): - a declared object that still carries its column is walled under `isolated` and `group`, and on the ADR-0123 D2 write path; - the registered shape is not walled, and the sibling is; - under `single`, Layer 0 is inert on both; - a junk `platformGlobalObjects` draws no warning from this plugin, and a junk suppress key is warned once; - the arming log carries no platform-global line. - **The reader** (`core/src/security/deployment-org-scoping-entitlement.test.ts`, 11 cases): absent, well-formed, four junk shapes (whole-key refusal), per-key independence, and memo per instance. ## Reverse verification (one-off, `scripts/ablation-replace.mjs` in hold mode with a trap restore) The plan's read of the declaration in `injected-system-columns.ts` was neutralised: the anchor `!(name !== '' && deploymentDeclaresPlatformGlobal` was replaced so it never matches (anchor 1 → 0, blob `6e571966dd` → `88efcbbb14`). The spec was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 6 built files. Results: - the spec plan: exactly the two declared-object pins red, 20 green; - the registry: 5 of 7 red, with the absent-declaration and opted-out controls green; - the kernel: 4 of 8 red. The plan, Layer 0 / driver, write refusal and ordering pins went red. The absent-key, junk-key, no-stand-down and late-provider controls stayed green; - the end-to-end verdict: the declared-object sweep red, 2 green. Restore leg: - blob == HEAD (`6e571966dd`), `git diff HEAD` empty, the whole tree clean; - the spec rebuilt, and `ablation-dist-preflight.mjs --absent` finds the marker in none of the 234 built files; - the same files re-run green: spec 22, registry 7, kernel and end-to-end 11. ## Fate for C7 (objectstack-ai#15211) and C10 On a declaring deployment, each declared object's existing `organization_id` column is ADR-0131 D10 fate 1 (column dropped). Schema sync is additive, so the physical column stays, and the boot drift report names it orphaned. The declarer (cloud's control plane, C10) owns the data step: confirm nothing reads it, then `os migrate apply --allow-destructive`. Its backfill decides any value that must survive. C7's inventory records the declared set per deployment with this entry id. No boot step reads or writes the column (D14). ## Files outside the claim's file surface - `packages/plugins/organizations/src/organizations-plugin.ts`: one declaration, `providesServices`. It is the "provider declaration" the claim's ordering bullet names, in the provider's own file. Its lane is re-declared by the seat. - `packages/core/src/security/deployment-org-scoping-entitlement.ts` and `.test.ts`, and `core/src/security/index.ts`: the reader's new home, so that both consumers can import it (the claim allows "if its reader moves"; `core` is on the claim's declared lanes). - `packages/objectql/src/federated-injected-column-readers.test.ts`: two census rows for the two new `organization_id` seams. That census fails on any undisposed seam. - Within the claimed packages: `objectql/src/plugin.ts`, `plugin-security/src/auto-org-admin-grant.ts` (one docblock), and four plugin-security test files. ## Acceptance notes - `os migrate plan` / `apply` compose the host config's plugins, not `serve`'s posture-driven organizations runtime. On a declaring deployment whose provider is composed only by `serve`, a migrate plan reads no declaration and would add the column back to a declared object's table (additive sync). Carrier: C10 (cloud's control plane composition), noted, not filed: there is no in-repo declarer to reach it with. - Author-time tools name `organization_id` on a declared object; the declaring deployment refuses it as an unknown field. This is inherent to a deployment input, and stated in the plan's docs and the changeset. - `OrganizationsPlugin.providesServices` was absent before, so ADR-0116's stage-1 check could not name it for an `init()`-time requirer of `org-scoping`. None exists in-repo (`check:init-service-contract` green). ## Verification (head `5322c2b755`, which merged `origin/main` at `dc4a5c6308` through `os-regen-merge.sh`; the regeneration wrote nothing) - **Suites, each through the verify lock:** - spec `--project local`: 626 files, 18728 passed, 1 todo; - objectql `--project local`: 385 files, 7562 passed; - core: 83 files, 2258 passed; - organizations: 11 files, 151 passed; - plugin-security: 183 files, 3835 passed, 45 skipped. It was run at `86db7e86f4`; since then plugin-security changed one test file's type annotation, and objectql (aliased to source there) lost one unused accessor. The four plugin-security files this PR touches were re-run green afterwards. - spec `--project repo`: `step18-rationale-merge` and `conversions-major18-merge`, 21 passed. - **Typecheck** exit 0 for core, objectql, plugin-security, organizations and spec. Each package's script includes `check:test-typecheck`, and every ledger held unchanged. - **Spec artifacts:** `check:generated` reports 15 of 15 up to date. `check:migration-registry`: 400 semantic entries. `check:api-surface` unchanged. - **Gates:** `dispatch-gates --commands --repo objectstack-ai/objectstack` (no paths) at `5322c2b755` derives 109 families. All 109 ran, each exit code captured before any pipe, all 0. The `--ran` reconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. - Fixed on the way: `check:engine-double-contract` asked for the new pin's three doubles in the ledger (`--write`), and `check:slot-lookup` refused one untyped service lookup in a test. - `check:dts-closure`, `check:dual-build-cjs-loads` and `check:i18n` first stopped on unbuilt packages (a prerequisite). They are green after a full build (72 tasks, 71 cached). - Also green, run by hand: `check:init-service-contract` (36 declared) and `check:startup-registry-verdict` (none recording a verdict the boot can contradict). - **Lint, a proven narrowing:** `eslint --no-inline-config --format json` over the 21 changed `.ts` files gives 21 results, 0 errors, 0 warnings. The population is `eslint.config.mjs`'s `packages/**` and `**/*` TS globs. The config states it enables no type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is CI's. - **Measurements, one-off and not committed:** - P1, on a scratch copy of the kernel harness at `799eb000c7`; - P2's cycle, `resolvePluginOrder` over the two declarations: it throws `Circular dependency detected: com.objectstack.engine.objectql`. CONTROL: without the soft edge, the order is engine then organizations; - the reverse verification above, restored and proven (preflight `--absent`, tree clean, the same files green). - `origin/main` has moved 8 commits since `dc4a5c6308`, three of them through this PR's files (`registry.ts`, `engine.ts`, `security-plugin.ts`) and the double ledger. A no-commit merge probe auto-merges them with no conflict. The next hop merges them through `os-regen-merge.sh`. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #15207
Clause-②: yes (widening: a new platform object
sys_platform_setting, and a new name ifpackages/specexports one; the global rung's storage narrows onsys_setting. The dev measures the built declaration closure.)The line above is the claim's (
6049595369), copied verbatim. The measured arm is in the changeset:Clause-②: yes (narrowing)— the diff widens (a new object,SysPlatformSettingandCONFIG_CHANGE_GLOBAL_OBJECT_NAMEexported, a new name inPLATFORM_OBJECTS_BY_PACKAGE) AND narrows (theglobaloption ofsys_setting.scoperetires, and the global rung's storage leavessys_setting).@objectstack/spec's exported TYPE surface does not move: the name list is data, and the new ADR-0087 entry and rationale fragment live in the generated registry.Scope: item (3) only
This PR builds scope item (3) of #15207: the
scope: 'global'rung ofsys_settingleaves the tenant-scoped object (ADR-0131 D7, §6 Q3) for a tenant-lesssys_platform_setting, and the settings cascade reads the new source. Item (1) landed as PR #22107. Items (2) and (4) are not here, so #15207 remains open. No stored row moves in this PR: existing global rows move in the v18 operator ceremony (C7, #15211), per ADR-0131 D14.What changes
sys_platform_setting(packages/platform-objects/src/system/sys-platform-setting.object.ts), registered by the settings service besidesys_setting:(namespace, key)for the deployment:unique: 'global'on(namespace, key);sys_settingrow:value,value_enc(read-only),encrypted,locked,locked_reason,updated_by(read-only);scope, nouser_id, and no organization column (systemFields: { tenant: false });requiredPermissions: ['manage_platform_settings']; generic APIget/listonly.SettingsService(settings-service.ts):scope: 'global'lands insys_platform_setting, keyed(namespace, key), never insys_setting;sys_platform_settingalone. Everysys_settingread now names its rungs ($orovertenant/user, oruser_id/tenant), so ascope = 'global'row a pre-v18 database still holds there is not a second source. There is no fallback read and no boot-time move (D14);SpecifierScopeandsource: 'global'are unchanged;getManyreads it once per call:sys_settingkeeps its resolveLocalizationContext reads the same sys_setting namespace three times per request #10826 bound (at most two reads), plus onesys_platform_settingread.config_changeaudit row: a global-scope change now namessys_platform_setting(CONFIG_CHANGE_GLOBAL_OBJECT_NAME, exported besideCONFIG_CHANGE_OBJECT_NAME). A tenant- or user-scope change still namessys_setting. Without this, the row would name a table the value is not in.sys_setting.scopeno longer declaresglobal(H6).sys_setting_audit.scopekeeps it, because the audit writer records the changed key's scope and a global change is still a change. The translation bundles drop the retired leaf, and the es-ES echo ledger drops its row (47 to 46 echoes).os secret orphans/os secret rewrap(packages/cli/src/utils/secret-reference-union.ts): the settings family of thesys_secretreference union reads BOTH holders,sys_setting.value_encandsys_platform_setting.value_enc. If either cannot be read, the whole family gaps (H3).sys-setting-global-rung-moved, and one step-18 rationale fragment. The registry is regenerated.spec-changes.jsonand the upgrade guide do not move, because step 18 is not projected yet.systemFields.tenant: false8 to 9;as anyspread ofbypassis gone (171 to 173 threading a tenant context);settings-service.ts2 to 1, a ratchet down.Readings (measured at
51290bca2c, re-checked after mergingmain)H1 holds: nothing moves to configuration. A census of every registered manifest (the built
builtinSettingsManifestsplus objectql'slifecycleSettingsManifest) found 109 keys at the global rung in seven namespaces:auth29,ai35,storage11,mail10,sms10,knowledge10 andlifecycle4.lifecycle.retention_overridesis the one tenant key in a global manifest. Every one of the seven requiresmanage_platform_settingsto read and to write through the door, so every one is edited live in Setup. Their in-tree consumers re-read on change:plugin-authandorganizationsusegetNamespace('auth'), and plugin-auth re-applies onsubscribe('auth');plugin-email(mail),service-sms(sms) andservice-storage(storage) each re-apply onsubscribe;lifecycleis read on every sweep;aiandknowledgehave no in-tree value reader beyond theirtestactions, whichrunActionresolves live.No key is boot-read only, so there is no
open_questionsentry for a configuration move.No writer attributes a global row to an organization (the stop condition did not fire).
SettingsService.setManyis the only writer of a settings row. It writes under{ isSystem: true }, with notenantIdand a row that names no organization.sys_settingisunclassifiedin the platform-object tenancy inventory, soresolveSystemInsertOrganizationderives nothing.tenantId: 'org_1', and the stored row carries no organization.CryptoContextpassed toencryptcarriestenantId: ctx.tenantIdfor every rung. The only in-tree provider (LocalCryptoProvider) binds no tenant into the AAD, andmaterialiseRowdecrypts with notenantIdat all;config_changeaudit row stamps the writer's organization (item (2)'s object).H2: global-rung readers outside the service. Census of every non-test source naming
sys_setting. The procedure fires: it flags the union and the orphan command.cli/src/utils/secret-reference-union.ts(settings collector)cli/src/commands/secret/orphans.ts, lines 300 to 309 (legacy-inline guard rows)cli/src/utils/sys-secret-orphan-sweep.tsorphans.tshands itcore/src/security/resolve-authz-context.ts, line 1696scope: 'tenant'; the service leg goes throughgetManymetadata-protocol/src/migrations/sys-setting-identity-index.tsmcp/src/plugin.ts,plugin-hono-server/src/current-user-endpoints.tsresolveLocalizationContextservice-settings/src/sys-secret-orphan-report.tsH3 holds, and is closed here. Before the union fix, a credential held only in
sys_platform_setting.value_encis attributable (its(namespace, key)is a declared encrypted specifier) and unreferenced bysys_setting. That is exactly the deletable shape, so the sweep would delete the credential in force. Ablation B below turns the three pins red, including the sweep'sreferenced→ deletable.H4: the AAD binds no holder object and no organization.
LocalCryptoProvider's version-2 AAD is the 0xFF lead byte, a version label, thenlp(scope) || lp(namespace) || lp(key).scopeis the producer vocabulary (settings). It binds no object name, no organization and no tenant (aadForVersion2,local-crypto-provider.ts). So C7's row move needs no re-encryption: copyvalue_enc(thesys_secrethandle) unchanged into the new row with the same(namespace, key). Thesys_secretrow does not move. A pin seals a handle with an organization in the context, places it in asys_platform_settingrow, and resolves it.H5: object permission holds, and the settings door is unaffected. This was measured with a scratch harness, not committed: the real
SecurityPluginmiddleware, the registry-processedsys_platform_settingand the shipped permission sets, running afind.PERMISSION_DENIEDsinglePERMISSION_DENIEDisolatedPERMISSION_DENIEDrequiredPermissions, any postureThe control row is why the gate ships with the object: with no column there is no wall. The settings door is unchanged.
settings-admission-tenancy-posture.test.tsandconfig-change-audit.test.tsdrive the plugin's own routes over a realObjectQLwith ascope: 'global'manifest, and the row lands insys_platform_setting(200).H6: half holds. After this PR no writer writes
scope: 'global'intosys_setting, so itsglobaloption is retired, with the ADR-0087 entry. Thesys_setting_audit.scopemirror is still written (the audit writer recordsentry.scope), so it stays. The parity pin now reads:sys_setting.scope=SpecifierScopeSchemaminusglobal, andsys_setting_audit.scope=SpecifierScopeSchema.Zone 3's suggested pin "a tenant and a user value still override it" is falsified by the unchanged rank table.
scopeRankgivesglobalrank 1 andresolveKeyFromRowstakes the first non-null rung, so a global value OUTRANKS the tenant and user rungs. It did before this PR and does after it. The pin asserts what the rulings keep: global from the new store outranks both, and the tenant and user rungs answer once it is empty.What C7 (#15211) must do with the rows (recorded, not built)
sys_settingrow atscope = 'global', write onesys_platform_settingrow with the samenamespaceandkey, and copyvalue,value_enc,encrypted,locked,locked_reasonandupdated_by. Then remove the source row.value_encis copied verbatim: re-encryption is neither needed nor wanted (H4). Thesys_secretrow stays where it is, and its handle id is unchanged.(namespace, key)with more than one global row is possible on a pre-sys_setting's declared row identity is unenforced on everytenantandglobalrow —user_idis NULL there and SQL UNIQUE is NULL-distinct #8629 database, because NULL-distinct unique let duplicates in. The ceremony has to pick one.sys_platform_setting's(namespace, key)unique refuses the second.Tests
All suite counts below were read at the merged head
d0477879af, unless a line says otherwise.pnpm --filter @objectstack/service-settings exec vitest run: 37 files, 638 tests passed. This includes the newsettings-global-rung.test.ts(8 cases, a realObjectQL) and the fixtures re-premised so global rows sit insys_platform_setting.pnpm --filter @objectstack/platform-objects exec vitest run: 65 files, 1036 tests passed. Before the echo-ledger fix, the run had 4 red, all inobjects-es-es-echo-decisions.test.ts, for the retired leaf.pnpm --filter @objectstack/spec exec vitest run src/system src/migrations src/data/api-methods-batch-conformance.test.ts: 55 files, 1980 tests passed.pnpm --filter @objectstack/cli exec vitest run --project integrationover the touched files (union, sweep, rewrap,src/commands/secret): 7 files, 91 tests passed. Theunitlayer: 263 files, 3874 tests passed, at the pre-merge head1137107352.service-settings,platform-objects,specandcli, at1137107352.scripts/ablation-replace.mjsin WRAP mode. Each anchor hit 1 → 0 and the blob changed. Each restore read blob == HEAD withgit diff HEADempty, andgit status --porcelainread 0 lines after all four. The subjects are imported by relative source path, so nodist/is in the resolution path.sys_settingread readmitsscope: 'global': red,a global-scope key never consults sys_setting at all.scope: 'global': red,a scope=global row still in sys_setting is NOT read.sys_setting: 7 of 8 red.sys_settingalone: red,names a handle held ONLY by sys_platform_setting.value_enc,an unreadable sys_platform_setting gaps the WHOLE settings family, and the sweep'sREFERENCED, never deletable.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatd0477879afderived 125 families. All 125 were run, each exit code captured before any pipe, and all 125 exited 0.--ranreconciles 125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN.check:engine-double-contract,check:objectql-double-limit,check:where-matcher,check:i18n-coverage,check:i18n-walk-parity,check:type-check-coverage,check:type-check-debtandcheck:empty-changeset, which the diff touches.check:api-surface("unchanged"),check:migration-registry,check:spec-changes,check:upgrade-guide,check:platform-object-tenancy-census,check-tenant-audit-census,check:query-options-erasure,check:i18nandcheck:nul-bytes.mainwas merged twice, the second time throughscripts/pm/os-regen-merge.sh, because PR feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) #22103 also writespackages/spec/src/migrations/registry.ts. Aftergen:migration-registry, the regenerated registry is byte-identical to the merge, and it holds both this PR's entry and feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) #22103'sdeclared-index-bare-unique-true-retired.eslint --no-inline-config --format jsonatd0477879afread 37 results, the 37 changed.tsfiles: 0 errors and 0 warnings, so none was ignored by the config.eslint.config.mjsenables no type-aware linting (noparserOptions.project, per its own comment), so this diff cannot move the verdict on any untouched file. The fullpnpm lintis CI's.Acceptance notes (observed, not changed here)
cli/src/commands/secret/orphans.ts, lines 300 to 309, builds the sweep's legacy-inline guard fromsys_settingrows only. Today nothing writes legacy inline ciphertext intosys_platform_setting: the plugin always wiresLocalCryptoProviderplus thesys_secretstore. After C7 moves pre-Phase-3 global rows, though, an inline value could sit there. The guard is withhold-only, and the union, which decides deletion, already reads both holders. Natural carrier: C7 (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211).metadata-protocol/src/migrations/sys-setting-identity-index.ts: two operator texts on its degraded arms still say global-scope settings rows "can still be created" insys_setting. No writer creates one after this PR. The text goes fully stale when C7 empties the layer. Carrier: C7 (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211).packages/spec/src/system/settings-manifest.zod.ts: the module TSDoc says values persist insys_setting, and its resolution list was already missing the global rung. No.describe()names the store, so it is left untouched per the claim.SettingsService.setManypassestenantId: ctx.tenantIdinto theCryptoContexton every rung, whilematerialiseRowdecrypts with none. No in-tree provider readstenantId, so this pulls nothing today. A per-tenant-key KMS provider would seal and open under different keys on every rung.Generated by Claude Code