Skip to content

feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) - #22166

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-15207-platform-setting-global-rung
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-15207-platform-setting-global-rung

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15207

Clause-②: yes (widening: a new platform object sys_platform_setting, and a new name if packages/spec exports one; the global rung's storage narrows on sys_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, SysPlatformSetting and CONFIG_CHANGE_GLOBAL_OBJECT_NAME exported, a new name in PLATFORM_OBJECTS_BY_PACKAGE) AND narrows (the global option of sys_setting.scope retires, and the global rung's storage leaves sys_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 of sys_setting leaves the tenant-scoped object (ADR-0131 D7, §6 Q3) for a tenant-less sys_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 beside sys_setting:
    • one row per (namespace, key) for the deployment: unique: 'global' on (namespace, key);
    • the value and encryption columns of a sys_setting row: value, value_enc (read-only), encrypted, locked, locked_reason, updated_by (read-only);
    • no scope, no user_id, and no organization column (systemFields: { tenant: false });
    • governed by object permission (D7): requiredPermissions: ['manage_platform_settings']; generic API get / list only.
  • SettingsService (settings-service.ts):
    • a write at a key declared scope: 'global' lands in sys_platform_setting, keyed (namespace, key), never in sys_setting;
    • the cascade's global rung is read from sys_platform_setting alone. Every sys_setting read now names its rungs ($or over tenant / user, or user_id / tenant), so a scope = '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);
    • the rank table, the lock check, SpecifierScope and source: 'global' are unchanged;
    • the global rung does not depend on the user, so getMany reads it once per call: sys_setting keeps its resolveLocalizationContext reads the same sys_setting namespace three times per request #10826 bound (at most two reads), plus one sys_platform_setting read.
  • config_change audit row: a global-scope change now names sys_platform_setting (CONFIG_CHANGE_GLOBAL_OBJECT_NAME, exported beside CONFIG_CHANGE_OBJECT_NAME). A tenant- or user-scope change still names sys_setting. Without this, the row would name a table the value is not in.
  • sys_setting.scope no longer declares global (H6). sys_setting_audit.scope keeps 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 the sys_secret reference union reads BOTH holders, sys_setting.value_enc and sys_platform_setting.value_enc. If either cannot be read, the whole family gaps (H3).
  • ADR-0087: one D3 semantic entry, sys-setting-global-rung-moved, and one step-18 rationale fragment. The registry is regenerated. spec-changes.json and the upgrade guide do not move, because step 18 is not projected yet.
  • Regenerated census artefacts:
    • platform-object tenancy census: 83 to 84 registered objects, out of reach 33 to 34, systemFields.tenant: false 8 to 9;
    • tenant-audit census: two sites moved from unreadable options to readable, because the as any spread of bypass is gone (171 to 173 threading a tenant context);
    • query-options erasure baseline: settings-service.ts 2 to 1, a ratchet down.

Readings (measured at 51290bca2c, re-checked after merging main)

H1 holds: nothing moves to configuration. A census of every registered manifest (the built builtinSettingsManifests plus objectql's lifecycleSettingsManifest) found 109 keys at the global rung in seven namespaces: auth 29, ai 35, storage 11, mail 10, sms 10, knowledge 10 and lifecycle 4. lifecycle.retention_overrides is the one tenant key in a global manifest. Every one of the seven requires manage_platform_settings to read and to write through the door, so every one is edited live in Setup. Their in-tree consumers re-read on change:

  • plugin-auth and organizations use getNamespace('auth'), and plugin-auth re-applies on subscribe('auth');
  • plugin-email (mail), service-sms (sms) and service-storage (storage) each re-apply on subscribe;
  • lifecycle is read on every sweep;
  • ai and knowledge have no in-tree value reader beyond their test actions, which runAction resolves live.

No key is boot-read only, so there is no open_questions entry for a configuration move.

No writer attributes a global row to an organization (the stop condition did not fire).

  • SettingsService.setMany is the only writer of a settings row. It writes under { isSystem: true }, with no tenantId and a row that names no organization.
  • sys_setting is unclassified in the platform-object tenancy inventory, so resolveSystemInsertOrganization derives nothing.
  • The new pin writes a global key with a writer context of tenantId: 'org_1', and the stored row carries no organization.
  • Two organization signals do sit on the global WRITE PATH, but neither attributes the settings ROW:
    • the CryptoContext passed to encrypt carries tenantId: ctx.tenantId for every rung. The only in-tree provider (LocalCryptoProvider) binds no tenant into the AAD, and materialiseRow decrypts with no tenantId at all;
    • the config_change audit 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.

reader reads the global rung? disposition
cli/src/utils/secret-reference-union.ts (settings collector) yes: every row, no scope filter fixed here (reads both holders)
cli/src/commands/secret/orphans.ts, lines 300 to 309 (legacy-inline guard rows) yes: every row outside the claim's surface, not edited (see Acceptance notes)
cli/src/utils/sys-secret-orphan-sweep.ts no read: pure, consumes the rows orphans.ts hands it none
core/src/security/resolve-authz-context.ts, line 1696 no: the direct read pins scope: 'tenant'; the service leg goes through getMany none
metadata-protocol/src/migrations/sys-setting-identity-index.ts index maintenance over all rows, pre-v18 global rows included; it reads no value none (see Acceptance notes)
mcp/src/plugin.ts, plugin-hono-server/src/current-user-endpoints.ts no: comments only; they read through resolveLocalizationContext none
service-settings/src/sys-secret-orphan-report.ts no read: a pure classifier over caller-supplied rows, with no in-tree caller none

H3 holds, and is closed here. Before the union fix, a credential held only in sys_platform_setting.value_enc is attributable (its (namespace, key) is a declared encrypted specifier) and unreferenced by sys_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's referenced → deletable.

H4: the AAD binds no holder object and no organization. LocalCryptoProvider's version-2 AAD is the 0xFF lead byte, a version label, then lp(scope) || lp(namespace) || lp(key). scope is 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: copy value_enc (the sys_secret handle) unchanged into the new row with the same (namespace, key). The sys_secret row does not move. A pin seals a handle with an organization in the context, places it in a sys_platform_setting row, and resolves it.

H5: object permission holds, and the settings door is unaffected. This was measured with a scratch harness, not committed: the real SecurityPlugin middleware, the registry-processed sys_platform_setting and the shipped permission sets, running a find.

posture organization admin platform admin member system context (the service)
none registered 403 PERMISSION_DENIED admitted, no filter 403 admitted
single 403 PERMISSION_DENIED admitted, no filter 403 admitted
isolated 403 PERMISSION_DENIED admitted, no filter 403 admitted
control: same object without requiredPermissions, any posture admitted, no filter admitted 403 admitted

The 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.ts and config-change-audit.test.ts drive the plugin's own routes over a real ObjectQL with a scope: 'global' manifest, and the row lands in sys_platform_setting (200).

H6: half holds. After this PR no writer writes scope: 'global' into sys_setting, so its global option is retired, with the ADR-0087 entry. The sys_setting_audit.scope mirror is still written (the audit writer records entry.scope), so it stays. The parity pin now reads: sys_setting.scope = SpecifierScopeSchema minus global, and sys_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. scopeRank gives global rank 1 and resolveKeyFromRows takes 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)

  • For every sys_setting row at scope = 'global', write one sys_platform_setting row with the same namespace and key, and copy value, value_enc, encrypted, locked, locked_reason and updated_by. Then remove the source row.
  • value_enc is copied verbatim: re-encryption is neither needed nor wanted (H4). The sys_secret row stays where it is, and its handle id is unchanged.
  • A (namespace, key) with more than one global row is possible on a pre-sys_setting's declared row identity is unenforced on every tenant and global row — user_id is 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.
  • Until the ceremony runs, a moved key answers from its next rung or the manifest default. The v18 boot refusal (D10) is what stops a deployment from running in that state.

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 new settings-global-rung.test.ts (8 cases, a real ObjectQL) and the fixtures re-premised so global rows sit in sys_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 in objects-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 integration over the touched files (union, sweep, rewrap, src/commands/secret): 7 files, 91 tests passed. The unit layer: 263 files, 3874 tests passed, at the pre-merge head 1137107352.
  • Typecheck green for service-settings, platform-objects, spec and cli, at 1137107352.
  • Ablations were one-off, through scripts/ablation-replace.mjs in WRAP mode. Each anchor hit 1 → 0 and the blob changed. Each restore read blob == HEAD with git diff HEAD empty, and git status --porcelain read 0 lines after all four. The subjects are imported by relative source path, so no dist/ is in the resolution path.
    • A, the null-user sys_setting read readmits scope: 'global': red, a global-scope key never consults sys_setting at all.
    • A2, the user-keyed read readmits scope: 'global': red, a scope=global row still in sys_setting is NOT read.
    • D, the global rung read from sys_setting: 7 of 8 red.
    • B, the union reads sys_setting alone: 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's REFERENCED, never deletable.

Gates

  • Derivation. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at d0477879af derived 125 families. All 125 were run, each exit code captured before any pipe, and all 125 exited 0. --ran reconciles 125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN.
  • What the battery covers. It includes the claim-time list. It adds 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-debt and check:empty-changeset, which the diff touches.
  • The generated-artifact gates are green: 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:i18n and check:nul-bytes.
  • The merge. main was merged twice, the second time through scripts/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 writes packages/spec/src/migrations/registry.ts. After gen: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's declared-index-bare-unique-true-retired.
  • Lint, a proven narrowing. eslint --no-inline-config --format json at d0477879af read 37 results, the 37 changed .ts files: 0 errors and 0 warnings, so none was ignored by the config. eslint.config.mjs enables no type-aware linting (no parserOptions.project, per its own comment), so this diff cannot move the verdict on any untouched file. The full pnpm lint is CI's.

Acceptance notes (observed, not changed here)


Generated by Claude Code

claude added 10 commits October 8, 2026 00:48
…s the settings global rung (ADR-0131 D7)

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>
…layers sys_setting stores

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
…; the es-ES ledger drops the retired leaf

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:system tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/platform-objects, @objectstack/service-settings, @objectstack/spec, touching 40 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/platform-objects/src/system/index.ts, packages/services/service-settings/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

64 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/platform-objects/src/system/index.ts, packages/services/service-settings/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 38 pages)
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 25e105e6e69080dd3cba801ff04c383e0c4dbe40 — the merge of head 2d4170f6195f310a23ebe7f30cb3c31e3b455e0e into base 7d7943dd0dfba6c98afa2200a08983ec85f9849a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7d7943dd0dfba6c98afa2200a08983ec85f9849a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d0477879af76f89370d19f7b587917a13510d23a
Local-runs: none

Reviewed at 2026-10-08T03:58Z by an isolated at-tier subagent of the claiming seat (6049595369, revised 6051733303). Read-only: the card and its ten comments, the PR body, its 42-file list, the net diff against main (merge-base ef1fcb26a2, +1338 / -246, equal to the PR's file list), the head's check-runs, ADR-0131 D7 / D10 / D14 / §6 / §8 and ADR-0087 D3 plus the disposition addendum, all on origin/main. Nothing built, run or re-run. The dispatch order and the dispatching seat's ACCEPT were not inputs to the judgments below.

Check-runs on the head, as read at this stamp: 34 runs — 30 success, 2 skipped (Console Pin Gate, which the diff's unchanged .objectui-sha does not trigger, and the opt-in Packed-tarball smoke), 2 in_progress (Lint & Repo Gates, Test Core (1/6)). No run has failed. Green and recorded: Check Changeset, Governed Surface Queue Guard (no governed path in the file list), Spec property liveness, Temporal Conformance (live PG + MySQL), every Dogfood Regression Gate shard and Dogfood Verify CLI (a real kernel booting the real security stack over the settings objects), all four Type Check jobs, Build Core, Test Core shards 2 to 6. The two in-flight runs are the gate verdicts for lint and the repo gates (i18n, censuses, api-surface among them) and the last test shard; landing waits on them per the seat's own hold, and this record does not pre-judge them.

① Derived judgments

  1. sys_platform_setting — right. packages/platform-objects/src/system/sys-platform-setting.object.ts declares systemFields: { tenant: false } and requiredPermissions: ['manage_platform_settings'], exactly ADR-0131 D7's shape for deployment-level runtime settings ("a tenant-less object holds only values an operator must change without a restart … governed by object permission, not by the wall"). The column absence is pinned through resolveInjectedSystemColumns (the derivation applySystemFields consumes) with a per-object control that re-adds the column when the opt-out is removed, and sys_setting is pinned as keeping its column. The row identity is (namespace, key) with unique: 'global' spelled explicitly (ADR-0120 D1; both key parts NOT NULL, so the NULL-distinct hole sys_setting needed a runtime index for does not exist here). No scope, no user_id. The value and encryption columns mirror sys_setting's with the same read-only posture (value_enc, updated_by), pinned column by column. managedBy: 'engine-owned', apiMethods: ['get', 'list']: the generic API reads, the settings door writes. The capability it names is platform-scoped in PLATFORM_CAPABILITIES (pinned), so no organization administrator holds it by role — the H5 control row (no gate ⇒ a walled deployment's organization_admin reads every row with no filter) is why the gate ships with the object, as item (1) already established for the seven plumbing objects.
  2. Registration and the name — right. settingsObjects is [SysSetting, SysPlatformSetting, SysSettingAudit] (pinned); the service reads both stores on every resolution, so a kernel registering one without the other fails loudly rather than answering with a rung missing. SysPlatformSetting is exported from @objectstack/platform-objects/system; PLATFORM_OBJECTS_BY_PACKAGE gains 'sys_platform_setting' in sorted position (a data constant — the type surface of @objectstack/spec does not move, and check:api-surface is one of the in-flight repo gates). The tenancy census records the object out of reach (83 → 84 registered, 33 → 34 out of reach, systemFields.tenant: false 8 → 9), as the gate binds.
  3. The global option of sys_setting.scope retires — right. After this diff no code path writes a global row there: rowIdentity sends a scope: 'global' row to the platform object, and the three consumers of the identity (upsertRow's probe, insert and update; readStoredHandle's re-read; and through them reapRotatedSecret) use the identity's object. The service has no delete verb at all (grep of the head: find, insert, update only). SpecifierScopeSchema is unchanged and must stay: a manifest author still writes scope: 'global', and source: 'global' is still the cascade's resolution value, so there is no authorable key to tombstone and no RETIRED_KEYS_BY_MAJOR row owed — the retirement is of a storage object's select option, declared through the D3 entry. sys_setting_audit.scope keeps global because buildSettingAuditWriter still records entry.scope for a global change; the parity pin is re-premised on both sides (sys_setting.scope = spec enum minus global; the audit object = the full enum, pinned against SpecifierScopeSchema.options rather than a literal), and the es-ES echo ledger drops the retired leaf (47 → 46, pinned). The generated bundles for the four locales drop the leaf and carry the corrected locked help text. The new object's own labels are not in the generated bundles; neither are sys_metadata_activation's nor sys_presence's, the other tenant-less system objects, so the bundle population is not "every system object" — the i18n gates are the in-flight repo-gates verdict.
  4. Exported constants — right. CONFIG_CHANGE_GLOBAL_OBJECT_NAME = 'sys_platform_setting' is exported from @objectstack/service-settings beside CONFIG_CHANGE_OBJECT_NAME, and the config_change sink picks by entry.scope === 'global'; both directions are pinned over the plugin's own routes and a real ObjectQL (a global change names the platform object and leaves sys_setting empty; a tenant change names sys_setting and leaves the platform object empty). Without this the ledger would name a table the value is not in. PLATFORM_SETTING_OBJECT stays module-private to the service, unlike the configurable opts.objectName for sys_setting — one store, no second knob; right.
  5. The cascade's read and write path — right, and D14 holds. loadRowSets is one loadGlobalRows (find('sys_platform_setting', { where: { namespace } }) under SETTINGS_SYSTEM_CONTEXT, no tenant-audit bypass because the object has no tenant field) plus one loadScopedRows per grouping argument (sys_setting, $or naming the rungs in the query: [{ user_id }, { scope: 'tenant' }] user-keyed, [{ scope: 'tenant' }, { scope: 'user' }] null-keyed), each row tagged with the rung its store says it is (toSettingsRow). There is no fallback read of sys_setting at scope = 'global' and no boot-time move: the exclusion is in the predicate, and ADR-0131 D14's "never a fallback read, never an automatic boot step" is honoured in code and in the service's docblock. The in-memory branch stays one scope-tagged store, which is the engine-less fallback and not a dual read. scopeRank (global = 1), the first-non-null walk, the lock check (finds the global row by its tag) and source: 'global' are untouched. getMany's resolveLocalizationContext reads the same sys_setting namespace three times per request #10826 bound holds: at most two sys_setting finds per call plus one platform find (pinned by object: 2 + 1 for mixed scopes). The write side: a global row is inserted and updated without scope / user_id (the object declares neither) and without the former bypassTenantAudit / as any erasures (the erasure baseline ratchets 2 → 1); assertUserReferenceResolves is skipped only for the platform object, which has no user column. Pinned over a real ObjectQL with the real objects and the real adapter: a global write lands one platform row and no sys_setting row, with a tenant context in the writer and no organization on the row (the stop condition did not fire); the rung resolves from the platform store with source: 'global'; a global lock still refuses a lower-rung write; a global value outranks tenant and user, which answer once it is empty.
  6. What an existing database answers before 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) — right. A sys_setting row still at scope = 'global' is not a rung: pinned with a control (the identical row one rung down at scope = 'tenant' IS read by the same query, so the silence is the exclusion and not an empty read), and a global-scope key never consults sys_setting at all. Until the v18 ceremony moves the row, the key answers from its next rung or the manifest default; the v18 boot refusal (D10 point 5) is what keeps a deployment from running in that state, and the card's records hold C6(3) and C7 to one release. The changeset says so to consumers under "Existing databases — nothing moves automatically". The sys_setting identity index keeps covering those rows (its NULL-safe bucket now described as holding tenant/user rows with no organization plus the pre-v18 global rows until the ceremony). The move recipe (same (namespace, key); copy value, value_enc, encrypted, locked, locked_reason, updated_by; remove the source; the platform unique refuses a pre-sys_setting's declared row identity is unenforced on every tenant and global row — user_id is NULL there and SQL UNIQUE is NULL-distinct #8629 duplicate, so the ceremony picks one) is recorded for C7, not built — right, since C7 owns the ceremony.
  7. No re-encryption on the move — right. LocalCryptoProvider's AAD binds scope || namespace || key of the producer and neither the holder object nor an organization; pinned by sealing with tenantId: 'org_1' in the context, placing the handle in a platform row, and resolving it. A sys_secret handle copied verbatim opens.
  8. The CLI secret-reference union — right, and the H3 hazard is closed in the same diff. SETTINGS_HOLDER_OBJECTS = ['sys_setting', 'sys_platform_setting']; both are read on every run with the fields each holder has; a missing driver or a throwing read on either gaps the whole settings family, which refuses deletion. Pinned: a handle held only by sys_platform_setting.value_enc is enumerated by the union (holder string names the object) and decided referenced, never deletable, by the sweep, with the genuine orphan still deleted as the positive control; an unreadable platform object gaps the family; the rewrap guards and the rewrap fixture register the second holder. Before this, every provider credential at the global rung would have read as unreferenced and been swept in force — this was the one accept-set change that had to ship with the move, and it does.
  9. Readers outside the service (H2 census) — accepted. The direct sys_setting readers on main are the union (fixed here) and orphans.ts:300-309 (the sweep's legacy-inline guard, withhold-only; see ③). resolve-authz-context.ts pins scope: 'tenant'; sys-setting-identity-index.ts maintains an index and reads no value; sys-secret-orphan-report.ts is a pure classifier with no in-tree caller (grep: only re-exported from the package index).
  10. H1 — nothing moves to configuration — accepted on the dev's census. 109 global-rung keys in seven namespaces, every manifest requiring manage_platform_settings to read and write through the door and so edited live in Setup, with consumers re-reading on subscribe or per sweep; the semantic entry's reason records the census and its commit. That is D7's and §6 Q3's test ("values an operator must change without a restart") applied, with no open_questions entry because no key is boot-read only.
  11. Governed surfaces — none. The file list touches no docs/adr/**, .claude/**, skills/**, AGENTS.md or CLAUDE.md; Governed Surface Queue Guard is green. This review is owed by the claim's path limb (packages/spec/src/**) and its Clause-②: yes.

② Semver level

  • The changeset matches the diff. .changeset/15207-settings-global-rung-platform-setting.md bumps @objectstack/platform-objects, @objectstack/service-settings, @objectstack/spec and @objectstack/cli at minor, carries the BREAKING banner, and the ADR-0087 marker adr-0087: registered sys-setting-global-rung-moved. .changeset/pre.json is absent on origin/main, so the launch-window convention (triage 6038050559: minor + BREAKING + ADR-0087 disposition until pre mode is in) applies; ADR-0087's addendum keeps the protocol step and the npm level on separate axes, and the D3 entry is what the exemption requires. registered names an id that resolves at the head (the D3 entry in 18.sys-setting-global-rung-moved.ts, mirrored into the regenerated registry.ts with a step-18 rationale fragment at order 87) and is new in the diff — the right category; already-registered or no-migration-prescription would both be false. The body states the FROM → TO mapping for every authored reference (scope = 'global' filters, list-view columns and seeds; kernels registering the settings objects by hand; generic reads of global values needing manage_platform_settings), which is what AGENTS.md asks of a breaking changeset. Check Changeset is green.
  • The levels per package are right. platform-objects: a new exported object (widening) and a narrowed select domain (breaking, BREAKING-bannered). service-settings: a new exported constant and a storage move its consumers see only through the documented reads. spec: a new name in a data constant and a new migration entry. cli: the union reads a second holder and gaps on it. None is a patch; none needs major while pre mode is off.
  • The Clause-② line. The PR body's line 2 is the claim's Clause-②: yes (widening: …) verbatim, as the claim form requires, with the measured arm declared beneath it; the changeset carries Clause-②: yes (narrowing), which is where check:adr-0087-registration reads the arm (AGENTS.md Post-Task Checklist §3). Per scripts/pm/clause2-line.mjs, yes (narrowing) is the one spelling for "a diff that widens one surface and narrows another; both facts are true and both are read" — and both are true here: it widens (sys_platform_setting, SysPlatformSetting, CONFIG_CHANGE_GLOBAL_OBJECT_NAME, the name in PLATFORM_OBJECTS_BY_PACKAGE) and narrows (sys_setting.scope loses global; the global rung's storage leaves sys_setting). The value agrees across both carriers (yes); the arm differs because the claim predicted and the dev measured, and the measured arm is the one the gate reads. Right.

③ Boundary flags

The report 6051681390 carries no open_questions; its six deviations and five out-of-scope findings are the flags. Each is answered here, none is escalated.

  1. Files outside the claim's surface (config-change-audit.ts, index.ts, manifest.ts; the CLI fixtures rewrap.guards.test.ts, sys-secret-rewrap.test.ts, sys-secret-orphan-sweep.test.ts; the four translation bundles, the es-ES hash ledger and echo-decisions test; tenant-audit-census.mdx and the audit counts; the tenancy census; the erasure baseline) — answered. The seat's revision 6051733303 amended the claim with each. Every one is a consequence the diff cannot land without: the object's registration, the ledger naming the store a value changed in, the generated fallout of a retired option, and three gate-bound counts (the tenant-audit census moved because the as any spread of bypass is gone: 171 → 173 threading a tenant context, 54 → 52 unreadable). No scope expansion.
  2. orphans.ts:300-309 left unedited — answered, carrier C7. That read builds the sweep's legacy-inline withhold guard from sys_setting alone. Today nothing writes inline ciphertext into sys_platform_setting (the plugin always wires LocalCryptoProvider with the sys_secret store, so a new global row holds a handle or nothing); only C7's move of a pre-Phase-3 global row could put inline ciphertext there. The guard only withholds; the decision to delete is the union's, which now reads both holders and is pinned to do so. The seat's revision records the pointer 6051723395 on 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 with the move recipe. Leaving it is the narrower, correct cut; the hazard the file could carry does not exist before the ceremony that would create it.
  3. The changeset's arm yes (narrowing) against the claim's yes (widening …) — answered in ②. Measured, both arms are true, and the changeset is where the gate reads it.
  4. The falsified Zone 3 pin ("a tenant and a user value still override it") — answered; the dispatch was wrong and the PR is right. scopeRank gives global rank 1 (head line 820) and resolveKeyFromRows takes the first non-null rung, so a global value outranks tenant and user before and after this diff. The committed pin asserts the order the rulings keep: global from the new store outranks both; tenant then user answer once it is empty. A pin asserting the dispatch's sentence would have been red against unchanged behaviour. The seat already owns the correction in 6051733303.
  5. The 403 matrix measured by a scratch harness, not committed — answered, carried by precedent; not a blocker. What is committed: the gate declared on the object and its capability's platform scope (pinned); the row placement and the door's 200 over the plugin's own routes and a real ObjectQL (settings-admission-tenancy-posture, config-change-audit); the system-context opt-in on every platform-object verb (settings-system-context.pin). What is not committed: the admit / PERMISSION_DENIED table per posture, which would need plugin-security's middleware and shipped sets — outside this claim's surface, exactly as item (1) landed its seven objects (6043540291 Decision 2, PASS 6045154038). The D3 entry's acceptanceCriteria asserts the 403; the dogfood gates on this head, green, boot the real security stack over these objects, and the H5 reading is recorded in the PR body. If the seat wants the matrix held by CI, that is a plugin-security pin card to file, not a change to this diff.
  6. The CryptoContext.tenantId asymmetry (encrypt passes ctx.tenantId on every rung; materialiseRow decrypts with none) — answered; an acceptance note, no reach. Pre-existing on every rung, not introduced here. The only in-tree provider binds no tenant into the AAD (H4, pinned by the sealed-with-org_1, opened-from-the-platform-row case), so it pulls nothing today, and C7's verbatim copy of value_enc is safe for the shipped provider. A per-tenant-key KMS provider would have to resolve the asymmetry before it could seal and open the same row — that is a design question for whoever adds such a provider, filed by them, not a defect in this diff.
  7. The sys-setting-identity-index.ts operator texts ("global-scope settings rows can still be created") and the settings-manifest.zod.ts module TSDoc — answered, carrier C7 / none. The texts go fully stale when C7 empties the layer; no .describe() names the store, so the TSDoc stays per the claim. Neither changes behaviour.
  8. The user-scoped key resolved with no userId reads every user's rows — pre-existing, read not measured, not in this diff's reach; the seat filed finding(service-settings): a user-scoped settings key resolved with no userId answers with whichever user row the namespace load returns first — measure who reaches it #22168 under the leak exception. Right disposition.
  9. The stray /build1.pid at the container's filesystem root and the commit-trailer spelling — container hygiene and a trailer form, neither in the repository nor in the diff; noted, no review consequence.

Implemented-by: claude/issue-15207-platform-setting-global-rung
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 6051891742 · d047787 → 92f3370 · comm -23 <(git diff --name-only d0477879af 92f33709c8 | sort) <(git diff --name-only ef1fcb26a2 7ef50a4fbb | sort) minus the regenerated outputs → (empty)

domain:spec seat 1 (#6017) · os-litant · session session_01LAi5BVvQNiYzepSAcsoFLK · 2026-10-08T04:24Z. A pointer, not evidence: the queue guard re-runs the test on the committed trees.

  • 92f33709c8 is one merge commit with parents d0477879af (the head contract review PASS 6051891742 judged) and 7ef50a4fbb (origin/main). It was made by os-regen-merge.sh.
  • The regeneration chain (gen:migration-registry, gen:spec-changes, gen:upgrade-guide, then check:generated against a freshly built dist) wrote zero bytes on the committed merge, so there is no regeneration commit. The dev's report is 6052173703 on feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207.
  • Re-run by this seat: the files that differ between the two heads, minus those main changed (ef1fcb26a2..7ef50a4fbb), minus the regenerated outputs, are empty. The PR's net diff is unchanged: 42 files, +1338 / −246. 41 of them are blob-equal across the hop; registry.ts also carries main's two new step-18 entries.
  • The rationale fragment at order 87 ties with main's flow-script-subflow-config-undeclared-keys-refused and renders after it in id order. Ties are documented and already exist at nine other orders, so it is not renumbered.
  • Since 7ef50a4fbb, main gained four commits that touch no generated path.

claude added 2 commits October 8, 2026 05:06
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 6051891742 · 92f3370 → c3f5925 · comm -23 <(git diff --name-only 92f33709c8 c3f59255c5 | sort) <(git diff --name-only 7ef50a4fbb 6ed0c0f3e5 | sort) → (empty)

domain:spec seat 1 (#6017) · os-litant · session session_01LAi5BVvQNiYzepSAcsoFLK · 2026-10-08T05:40Z. The second hop on the same record. A pointer, not evidence: the queue guard re-runs the test.

  • The hop: 61edbced61 merges origin/main 6ed0c0f3e5 into 92f33709c8, made by os-regen-merge.sh. c3f59255c5 is node scripts/tenant-audit-census.mjs --write on the merged tree. The dev's report is 6053162737 on feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207.
  • Re-run by this seat: the files that differ across the hop, minus those main changed (7ef50a4fbb..6ed0c0f3e5), are empty, before any regenerated output is subtracted. The PR's net diff is unchanged: 42 files, +1338 / −246.
  • ⚠️ One hand edit, read by this seat and judged to carry the PASS: content/docs/permissions/tenant-audit-census.mdx :125, "54 of the 232 sites" became "52 of the 232 sites". The census gate refused 54 and computes 52. That is the same −2 this PR carried at 92f33709c8 (two settings-service sites became readable), restated on main's new total of 232. It is gate-bound documentation prose outside --write's region. It is not a contract-review face (content/docs/** is excluded there), and it moves no accept set, export or behaviour.
  • The other figures on the page, read sentence by sentence: "60 sites … 8 said so and 52 were simply unread" (:153–:154), the table rows 172 / 52, and the measured-at line. Each is the census's output on the merged tree, and check-tenant-audit-census holds all 23 prose figures.
  • The four translation bundles main also touched: the PR's own delta in each is byte-identical across the hop.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 05:48
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 05:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 8, 2026
claude added 2 commits October 8, 2026 06:18
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 6051891742 · c3f5925 → 2d4170f · comm -23 <(git diff --name-only c3f59255c5 2d4170f619 | sort) <(git diff --name-only 6ed0c0f3e5 7d7943dd0d | sort) → (empty)

domain:spec seat 1 (#6017) · os-litant · session session_01LAi5BVvQNiYzepSAcsoFLK · 2026-10-08T06:29Z. The third hop on the same record. A pointer, not evidence: the queue guard re-runs the test. The merge queue removed this PR at 06:16Z (MERGE_CONFLICT): 7d7943dd0d had moved the tenant-audit census again.

  • The hop:
  • Re-run by this seat:
    • The files that differ across the hop, minus the files main changed (6ed0c0f3e5..7d7943dd0d), are empty, before any regenerated output is subtracted.
    • The PR's net diff is unchanged: 42 files, +1338 / −246.
    • 40 of the 42 have the same blob before and after the hop. The other 2 are the census page and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md.
  • ⚠️ One hand edit, the same shape as the second hop, read by this seat and judged to carry the PASS:
    • Where: content/docs/permissions/tenant-audit-census.mdx :125.
    • The change: "54 of the 234 sites" became "52 of the 234 sites".
    • Why: the census gate refused 54 and computes 52. That is the PR's −2 (two settings-service sites became readable), restated on main's new total of 234.
    • Why it carries: it is gate-bound documentation prose outside --write's region. It is not a contract-review face (content/docs/** is excluded). It moves no accept set, export or behaviour.
  • The rest of the page's diff across the hop is --write's own output: "threading a tenant context" 172 → 174, sources scanned 615 → 617, declared objects 116 → 117, and the measured-at line. check-tenant-audit-census holds all 23 prose figures on the new head (the dev's run; CI re-runs it).
  • git merge-tree origin/main 2d4170f619 is clean.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit c52bfb4 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15207-platform-setting-global-rung branch October 8, 2026 07:54
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants