diff --git a/.changeset/15196-position-environment-write-through.md b/.changeset/15196-position-environment-write-through.md new file mode 100644 index 00000000000..6c9a31dac14 --- /dev/null +++ b/.changeset/15196-position-environment-write-through.md @@ -0,0 +1,36 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security)!: under `single`, a position created or edited in Setup is also written to the environment ledger, and positions Setup wrote earlier are backfilled into it once (ADR-0131 D3) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0131 D3 gives positions one home, the environment registry. Under `single`, a position an administrator creates in Setup used to be a `sys_position` row only: no environment definition, so the security catalog read did not find it. Under `single`, every Setup create, edit, rename and delete of a position now also writes the position's definition through the metadata door, at environment scope. + +**What a Setup position write does now, under `single`.** + +- **Create.** The row is written as before, with the same checks: a required label, the reserved built-in names, and one name per organization. The definition `{ name, label, description, delegatable }` is then saved from that row as an environment item, and the security catalog read resolves it at once. `active` and `is_default` stay on the row only. +- **Edit.** The row is updated, and the definition is saved again from it. A patch that touches only `active` or `is_default` writes the row and nothing else. +- **Rename.** The new name's definition is saved, and the old name's definition is deleted. +- **Delete.** The row is deleted, then the definition. If the definition delete fails, an error is logged that names the remedy, because the next boot would bring the position back from the surviving definition. +- **Unchanged.** Under a walled posture, every write behaves as before. System writes (the seeders and the package door) are never translated. On a kernel without a metadata door, the row is written as before. For a position a package or a built-in declares, a Setup edit is written to the row exactly as before, and nothing is written to metadata. +- **No grant changes.** Every reader still reads the row, so a user holding a position is granted exactly what they were granted before. + +**What stops being accepted.** Under `single`, the data door now refuses a create, or a rename into, a position name that the metadata door refuses. The refusal is the metadata door's own: `400 INVALID_REQUEST` for a name outside the item-name grammar (uppercase, a space, a hyphen, a leading digit or underscore), or `422 INVALID_METADATA` for a name `PositionSchema.name` refuses (a single character, a dot). No row is kept. `PositionSchema` already declared such names rejected, and a position named that way could never have a definition. + +- An edit of an existing row that already carries such a name is still accepted. It stays a row write, with no definition. +- **Remedy.** Name the position in lowercase `snake_case`: it starts with a letter, is at least two characters, and holds letters, digits and underscores only. For example, write `sales_manager` instead of `Sales Manager`. Then re-point any assignment that names the old spelling. + +**One more refusal follows from the new definitions.** Positions, permission sets and capabilities hold one name per deployment. Once a Setup position is defined in the environment ledger, a package that registers a position under the same name is refused with `422 NAMESPACE_CONFLICT`, and the refusal names both holders. Before this change, the same registration was accepted. + +**The one-time backfill.** At `kernel:bootstrapped`, under `single`, every position whose name the security catalog read does not resolve gets an environment definition from its row. This covers positions Setup wrote before this release. + +- A name the environment ledger, a package or a built-in already declares is left alone. +- A name the metadata door refuses is not written. It is reported at `warn`, with its remedy, as a final class. +- If two rows of one name disagree, the name is reported at `error` and nothing is written for it. +- When every name is decided, the verdict is recorded in `sys_migration` (`adr-0131-position-environment-backfill`). If a write fails or two rows disagree, the verdict is not recorded, and the next boot runs the pass again. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index abf8b23143d..b67fef25a87 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **120 +because the flag is not one concept: it is a single boolean read at **121 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -108,6 +108,7 @@ that silently does not happen. | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | | 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | +| 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the seeders and the package door write rows for definitions that already have their home, untouched. Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | | 14 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin` | @@ -141,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 120 sites**. +The largest single consumer — **17 of the 121 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -284,7 +285,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 120 read sites +- **Shipped semantics.** `isSystem` is a published contract with 121 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -358,16 +359,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 28 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 126 | ✅ | +| — parsed as a property **read** | 127 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **120** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 117 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **121** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 118 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | -| Files containing at least one elevation read | 56 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 102 | ✅ | +| Files containing at least one elevation read | 57 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 103 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were @@ -431,7 +432,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **56** +cannot say WHICH read inside a function it means, and **8** of the **57** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 51cc40e008e..d1f40735b38 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 51 of them — just over a fifth +typed `any` have no type to read, and there are 53 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 237 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 240 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **237** | +| 175 write call sites | quoted in the merged changeset | **240** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 237** decidable, **80** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 104 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **159 of 240** decidable, **81** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 104 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 51 sites reached -through an erased (`any`) receiver, and the 50 that name their object through a +one, and both are counted in the generated tables below: the 53 sites reached +through an erased (`any`) receiver, and the 52 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -125 of 237 (53%) as decidably elevated, with 104 more whose elevation is a +128 of 240 (53%) as decidably elevated, with 104 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 237`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 240`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **237** | -| …whose object name is statically decidable | 157 | -| …whose object name is chosen at run time | 80 | -| …against an object with tenancy ENABLED | 156 | +| write call sites on the application surface | **240** | +| …whose object name is statically decidable | 159 | +| …whose object name is chosen at run time | 81 | +| …against an object with tenancy ENABLED | 158 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 176 | +| threading a tenant context | 179 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 125 | +| threading a decidably ELEVATED (`isSystem`) context | 128 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 104 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 186 | -| receiver erased, placed by the object NAME | 31 | +| receiver carried a readable engine type | 187 | +| receiver erased, placed by the object NAME | 33 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 107 | -| object name spelled through a `const` | 50 | +| object name spelled through a `const` | 52 | | object name is an `object: string` parameter | 19 | -| object name is some other run-time expression | 61 | +| object name is some other run-time expression | 62 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `8e432893f`. +Measured on 2026-10-08 at `715ba6f44`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 621 | -| engine-shaped types recognised | 70 | +| tracked non-test sources scanned | 623 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 975bbe16d47..3053e77f8fa 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 237 | -| Object name statically decidable | 157 | -| Object name chosen at run time | 80 | -| Against a tenancy-enabled object | 156 | +| Write call sites | 240 | +| Object name statically decidable | 159 | +| Object name chosen at run time | 81 | +| Against a tenancy-enabled object | 158 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 176 | +| Threading a tenant context | 179 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 125 | +| Threading a decidably elevated context | 128 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 104 | @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `8e432893f`. +Measured on 2026-10-08 at `715ba6f44`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 621 | -| engine-shaped types recognised | 70 | +| tracked non-test sources scanned | 623 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | @@ -177,6 +177,9 @@ Measured on 2026-10-08 at `8e432893f`. | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/security-plugin.ts` | `insert` | `sys_position_permission_set` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 2 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 012a9f998e9..e0788757d8d 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -657,8 +657,11 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { await seedPrincipals(world, 'single'); await clearAllNames(world.engine); + // Two `kernel:bootstrapped` handlers, in registration order: this backfill, + // then the row-only position backfill (ADR-0131 D3, C2 stage S7), which + // `position-write-through.test.ts` drives. Only the first runs here. const handlers = world.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers).toHaveLength(1); + expect(handlers).toHaveLength(2); const errorsBefore = world.pluginLogger.error.mock.calls.length; const warningsBefore = world.pluginLogger.warn.mock.calls.length; await handlers[0](); diff --git a/packages/plugins/plugin-security/src/position-environment-backfill.ts b/packages/plugins/plugin-security/src/position-environment-backfill.ts new file mode 100644 index 00000000000..64e98782319 --- /dev/null +++ b/packages/plugins/plugin-security/src/position-environment-backfill.ts @@ -0,0 +1,453 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one-time backfill of row-only positions into the environment ledger + * under `single` (ADR-0131 D3, C2 stage S7). + * + * ## What it does + * + * Before the position write-through (`position-write-through.ts`), a position + * created in Setup was a `sys_position` row and nothing else: no definition in + * the environment ledger, so the security catalog read + * (`createSecurityCatalogReader`, `@objectstack/core`) did not resolve it. The + * write-through gives every new Setup position its definition; this pass gives + * one to every position written before it — a ROW-ONLY position, one whose + * name the catalog read does not resolve — so the planned read switch finds + * every position a `single` deployment holds. + * + * For each name some `sys_position` row carries: + * + * 1. **The catalog read decides "row-only".** A name the environment ledger, a + * package or a built-in already declares resolves, and is left alone — + * its definition already has a home, and its rows are the seeders'. + * 2. **A name the metadata door does not accept is a final class.** The door + * refuses a name outside its item-name grammar or `PositionSchema.name`, so + * such a position can never have a definition; it is reported, by count and + * name, and never written (seat re-rule on the C2 card, Q1 = A). It stays a + * row the read switch will report. + * 3. **The rows of one name must agree.** The definition is read from the row + * (`{ name, label, description, delegatable }`); where two rows of one name + * (two organizations of one `single` deployment) disagree on it, nothing is + * written for that name and it is reported: which row's text the definition + * should carry is not this pass's to guess. + * 4. **Written through the metadata door at environment scope** + * (`saveMetaItem`, actor `system`), then verified through the catalog read. + * + * ## Once, and remembered + * + * The verdict is recorded in the deployment ledger `sys_migration` (row id + * {@link POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID}; `verified_at: null` and + * `blocking: 0`, the shape of the grant-name backfill beside it) only when the + * pass decided every name: each row-only name was given its definition, or is + * in the final refused-name class. A write that did not land, a definition the + * catalog read does not resolve afterwards, a name whose rows disagree, or a + * read that did not happen leaves the verdict unrecorded, and the next boot + * runs the pass again. A rerun writes nothing a previous run wrote: a name + * that has its definition resolves, and is left alone. + * + * ## When it runs + * + * At `kernel:bootstrapped`, from `SecurityPlugin.start`, beside the grant-name + * backfill: every `kernel:ready` handler has settled (the declared-position and + * built-in seeders among them) and every plugin has registered its + * declarations, so the catalog read sees every holder a name can have. The + * order is load-bearing: an environment definition minted under a name a + * package registers later is refused at the registry's item seam + * (`NAMESPACE_CONFLICT`), so a pass run ahead of the declarations would turn + * a package's registration into a refusal. Under a walled posture the pass + * does not run: an organization's row has no environment home (ADR-0131 D3). + */ + +import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import type { SecurityCatalogReader } from '@objectstack/core'; +import { + POSITION_METADATA_TYPE, + POSITION_OBJECT, + metadataDoorAcceptsPositionName, + positionBodyFromRow, + type PositionMetadataDoor, +} from './position-write-through.js'; + +/** Ledger row id of the one-time position backfill. */ +export const POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID = 'adr-0131-position-environment-backfill'; + +/** Rows per page of the position scan. */ +const SCAN_PAGE_SIZE = 500; + +/** Names printed per category; the count is always complete. */ +const LOGGED_NAME_LIMIT = 50; + +const SYSTEM_CTX = { isSystem: true } as const; + +/** The engine surface the backfill uses — ObjectQL satisfies it as it stands. */ +export interface PositionBackfillEngine { + getObject(name: string): unknown; + find(object: string, options: Record): Promise; + findOne(object: string, options: Record): Promise | null>; + insert(object: string, data: Record, options?: Record): Promise; +} + +/** The kernel logger, as this module uses it. */ +export interface PositionBackfillLogger { + info?: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + /** The kernel logger's shape: message, cause, meta. */ + error?: (message: string, cause?: Error, meta?: Record) => void; +} + +/** Why a pass stopped before it reached a verdict. */ +export type PositionBackfillStop = + /** No `sys_position` object in this composition. */ + | 'objects-absent' + /** No metadata door that can save. */ + | 'door-absent' + /** The position scan could not be read in full. */ + | 'scan-unreadable' + /** The security catalog read did not happen. */ + | 'catalog-unreadable'; + +/** What one pass did. Every list holds position names. */ +export interface PositionBackfillResult { + /** Distinct names the scan found. */ + names: number; + /** Names the catalog read did not resolve before this pass. */ + rowOnly: string[]; + /** Names this pass gave a definition, verified through the catalog read. */ + backfilled: string[]; + /** Names the metadata door does not accept — never written, final. */ + refusedName: string[]; + /** Names whose rows disagree on the definition — not written. */ + conflicting: string[]; + /** Names whose definition write did not land, or does not resolve after it. */ + failed: string[]; + /** Set when the pass stopped before it judged every name. */ + stopped?: PositionBackfillStop; +} + +export interface PositionBackfillDeps { + /** The tenancy posture in force; the pass runs under `single` only. */ + posture: TenancyPosture; + /** S1's security catalog read: "row-only" is a name it does not resolve. */ + catalog: SecurityCatalogReader; + /** The metadata door the definitions are written through. */ + door: unknown; + logger?: PositionBackfillLogger; +} + +/** Thrown inside the pass to stop it; never escapes the module. */ +class BackfillStop extends Error { + constructor(readonly stop: PositionBackfillStop, readonly reason: unknown) { + super(stop); + } +} + +const STOPPED_READING: Record = { + 'objects-absent': 'this composition', + 'door-absent': 'the metadata door', + 'scan-unreadable': POSITION_OBJECT, + 'catalog-unreadable': 'the security catalog', +}; + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function logError(logger: PositionBackfillLogger | undefined, message: string, meta: Record): void { + if (logger?.error) logger.error(message, undefined, meta); + else logger?.warn(message, meta); +} + +/** At most {@link LOGGED_NAME_LIMIT} names, and how many more there are. */ +function listed(names: readonly string[]): { names: string[]; more?: number } { + return names.length > LOGGED_NAME_LIMIT + ? { names: names.slice(0, LOGGED_NAME_LIMIT), more: names.length - LOGGED_NAME_LIMIT } + : { names: [...names] }; +} + +function canSave(door: unknown): door is Pick { + return !!door && typeof (door as { saveMetaItem?: unknown }).saveMetaItem === 'function'; +} + +/** Every `sys_position` row, grouped by name, read in full before anything is written. */ +async function scanRowsByName(engine: PositionBackfillEngine): Promise[]>> { + const byName = new Map[]>(); + for (let offset = 0; ; offset += SCAN_PAGE_SIZE) { + let page: unknown; + try { + page = await engine.find(POSITION_OBJECT, { + where: {}, + fields: ['id', 'name', 'label', 'description', 'delegatable'], + orderBy: [{ field: 'id', order: 'asc' }], + limit: SCAN_PAGE_SIZE, + offset, + context: SYSTEM_CTX, + }); + } catch (e) { + throw new BackfillStop('scan-unreadable', e); + } + const rows = Array.isArray(page) ? (page as Record[]) : []; + for (const row of rows) { + const name = row?.name; + if (typeof name !== 'string' || name === '') continue; + const list = byName.get(name); + if (list) list.push(row); + else byName.set(name, [row]); + } + if (rows.length < SCAN_PAGE_SIZE) break; + } + return byName; +} + +async function catalogResolves(catalog: SecurityCatalogReader, name: string): Promise { + try { + const entry = await catalog.resolve(POSITION_METADATA_TYPE, name); + return entry !== undefined && entry.name === name; + } catch (e) { + throw new BackfillStop('catalog-unreadable', e); + } +} + +/** Report what one pass did and could not do — once per category, never once per name. */ +function reportPass(result: PositionBackfillResult, logger?: PositionBackfillLogger): void { + if (result.backfilled.length > 0) { + logger?.info?.( + `[security] ${result.backfilled.length} row-only position(s) now have an environment definition (ADR-0131 D3): ` + + `each was read from its ${POSITION_OBJECT} row, saved through the metadata door at environment scope and ` + + 'resolved through the security catalog read', + { backfilled: listed(result.backfilled) }, + ); + } + if (result.refusedName.length > 0) { + logger?.warn( + `[security] ${result.refusedName.length} position(s) keep no environment definition: their names are not ` + + 'position names the metadata door accepts (lowercase snake_case, starting with a letter, at least two ' + + 'characters), so no definition can carry them. Each still grants from its row today, and the catalog read ' + + 'will not find it. Fix: rename each position in Setup to a lowercase snake_case name — the rename gives it ' + + 'its definition — and re-point the assignments that name it.', + { count: result.refusedName.length, positions: listed(result.refusedName) }, + ); + } + if (result.conflicting.length > 0) { + logError( + logger, + `[security] ${result.conflicting.length} row-only position name(s) were NOT given an environment definition: ` + + `the ${POSITION_OBJECT} rows carrying each name disagree on its label, description or delegatable, and ` + + 'one environment definition cannot carry both. They still grant from their rows today, and would not be ' + + 'found by the catalog read. Fix: make the rows agree (or rename one) in Setup — an edit gives the name its ' + + 'definition; the backfill runs again on the next boot.', + { count: result.conflicting.length, positions: listed(result.conflicting) }, + ); + } + if (result.failed.length > 0) { + logError( + logger, + `[security] ${result.failed.length} row-only position(s) were NOT given an environment definition: the ` + + 'metadata door refused or lost the write, or the catalog read did not resolve it afterwards, while every ' + + 'other line of this backfill reads clean. They still grant from their rows today. The backfill runs again ' + + 'on the next boot; if it fails again, check that the metadata store (sys_metadata) is writable.', + { count: result.failed.length, positions: listed(result.failed) }, + ); + } +} + +/** + * One pass: give every row-only position its environment definition, report + * the rest. A read that did not happen stops the pass; the stop is returned in + * `stopped` and reported, never thrown. + */ +export async function backfillRowOnlyPositions( + engine: PositionBackfillEngine, + deps: Pick, +): Promise { + const result: PositionBackfillResult = { + names: 0, rowOnly: [], backfilled: [], refusedName: [], conflicting: [], failed: [], + }; + const { catalog, door, logger } = deps; + if (!engine?.getObject?.(POSITION_OBJECT)) { + result.stopped = 'objects-absent'; + return result; + } + if (!canSave(door)) { + result.stopped = 'door-absent'; + logger?.warn( + '[security] the row-only position backfill did not run: no metadata door that can save is registered, so ' + + 'positions created in Setup before the environment write-through keep no environment definition. It runs ' + + 'again on the next boot.', + ); + return result; + } + + try { + const byName = await scanRowsByName(engine); + result.names = byName.size; + for (const [name, rows] of [...byName.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + if (await catalogResolves(catalog, name)) continue; + result.rowOnly.push(name); + if (!metadataDoorAcceptsPositionName(name)) { + result.refusedName.push(name); + continue; + } + const bodies = new Set(rows.map((row) => JSON.stringify(positionBodyFromRow(row)))); + if (bodies.size > 1) { + result.conflicting.push(name); + continue; + } + try { + await door.saveMetaItem({ + type: POSITION_METADATA_TYPE, + name, + item: positionBodyFromRow(rows[0]), + actor: 'system', + }); + } catch (e) { + result.failed.push(name); + if (result.failed.length === 1) { + logError( + logger, + `[security] the row-only position backfill could not save the environment definition of '${name}' ` + + '(ADR-0131 D3): the position keeps granting from its row, and the catalog read does not find it. ' + + 'The count of every such position follows when the pass ends; it runs again on the next boot.', + { name, error: errorText(e) }, + ); + } + continue; + } + if (await catalogResolves(catalog, name)) result.backfilled.push(name); + else result.failed.push(name); + } + } catch (e) { + if (!(e instanceof BackfillStop)) throw e; + result.stopped = e.stop; + logger?.warn( + `[security] the row-only position backfill stopped before it judged every position: ` + + `${STOPPED_READING[e.stop]} could not be read, and an unread answer is never taken for "already declared". ` + + 'No definition was written past that point; the backfill runs again on the next boot.', + { stop: e.stop, error: errorText(e.reason), backfilled: result.backfilled.length }, + ); + } + reportPass(result, logger); + return result; +} + +/** A pass's verdict is final when nothing a later pass could decide differently remains. */ +export function isRecordablePositionBackfillVerdict(result: PositionBackfillResult): boolean { + return result.stopped === undefined && result.failed.length === 0 && result.conflicting.length === 0; +} + +/** What the deployment ledger says about this backfill. */ +export type PositionBackfillLedgerReading = 'recorded' | 'absent' | 'unavailable' | 'unreadable'; + +/** Read the ledger row by its id. Never throws. */ +export async function readPositionBackfillLedger(engine: PositionBackfillEngine): Promise { + try { + if (!engine?.getObject?.(DATA_MIGRATION_FLAG_OBJECT)) return 'unavailable'; + const row = await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID }, + context: SYSTEM_CTX, + }); + return row?.id === POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID ? 'recorded' : 'absent'; + } catch { + return 'unreadable'; + } +} + +/** The ledger row for a decided pass — pure. */ +export function buildPositionBackfillRecord(result: PositionBackfillResult, now: string): DataMigrationFlag { + return { + id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID, + last_run_at: now, + applied_at: now, + verified_at: null, + blocking: 0, + details: JSON.stringify({ + names: result.names, + rowOnly: result.rowOnly.length, + backfilled: result.backfilled.length, + refusedName: result.refusedName.length, + }), + }; +} + +/** + * Insert the ledger row. THROWS on failure — the caller decides what a lost + * record means. + */ +async function persistPositionBackfillRecord(engine: PositionBackfillEngine, flag: DataMigrationFlag): Promise { + const now = flag.last_run_at; + await engine.insert(DATA_MIGRATION_FLAG_OBJECT, { ...flag, created_at: now, updated_at: now }, { context: SYSTEM_CTX }); +} + +export type PositionBackfillStatus = + /** A walled posture: the pass does not apply. */ + | 'not-applicable' + /** The pass decided, and its record landed. */ + | 'ran' + /** The pass decided, but its record did not land (or there is no ledger to land it in). */ + | 'ran-unrecorded' + /** The pass left something a later pass may decide differently — retried on the next boot. */ + | 'undecided' + /** The ledger already records the verdict — nothing was scanned or written. */ + | 'already-run'; + +export interface OneTimePositionBackfillResult { + status: PositionBackfillStatus; + ledger?: PositionBackfillLedgerReading; + /** The pass's own summary, present whenever it ran. */ + backfill?: PositionBackfillResult; +} + +/** + * Run the row-only position backfill under `single` unless the deployment + * ledger already records its verdict, and record the verdict when this pass + * reaches one. A read the pass could not make is reported and returned, never + * thrown; the boot hook that calls this still guards against anything + * unforeseen. + */ +export async function runOneTimePositionEnvironmentBackfill( + engine: PositionBackfillEngine, + deps: PositionBackfillDeps, +): Promise { + if (postureEnforcesWall(deps.posture)) return { status: 'not-applicable' }; + const { logger } = deps; + const ledger = await readPositionBackfillLedger(engine); + if (ledger === 'recorded') return { status: 'already-run', ledger }; + + const backfill = await backfillRowOnlyPositions(engine, deps); + if (!isRecordablePositionBackfillVerdict(backfill)) return { status: 'undecided', ledger, backfill }; + + if (ledger !== 'absent') { + logger?.warn( + `[security] the row-only position backfill reached its verdict but cannot record it: the deployment ledger ` + + `${DATA_MIGRATION_FLAG_OBJECT} is ${ledger === 'unavailable' ? 'not available on this kernel' : 'unreadable'}, ` + + 'so every boot scans the positions again. Nothing is written twice. Compose PlatformObjectsPlugin (it ' + + 'provisions the ledger) to let the verdict be remembered.', + { ledger }, + ); + return { status: 'ran-unrecorded', ledger, backfill }; + } + + const flag = buildPositionBackfillRecord(backfill, new Date().toISOString()); + try { + await persistPositionBackfillRecord(engine, flag); + } catch (e) { + // A concurrent boot that landed the same id first has recorded it. + if ((await readPositionBackfillLedger(engine)) === 'recorded') return { status: 'ran', ledger, backfill }; + // At `error`: the pass's writes stand and every line reads clean, while the + // record that makes it one-time is absent. + logError( + logger, + `[security] the row-only position backfill ran, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed ` + + `(${errorText(e)}). The next boot scans every position again and re-reports what it cannot give a ` + + `definition. Fix: make ${DATA_MIGRATION_FLAG_OBJECT} writable on this deployment (it is provisioned by ` + + `PlatformObjectsPlugin) and verify with SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = ` + + `'${POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID}'.`, + { error: errorText(e) }, + ); + return { status: 'ran-unrecorded', ledger, backfill }; + } + logger?.info?.( + `[security] the row-only position backfill is recorded in ${DATA_MIGRATION_FLAG_OBJECT} ` + + `(id '${POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID}') — later boots will not run it again`, + { id: flag.id, details: flag.details }, + ); + return { status: 'ran', ledger, backfill }; +} diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts new file mode 100644 index 00000000000..16e4161b0f4 --- /dev/null +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -0,0 +1,623 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, C2 stage S7] The `sys_position` data-door write-through under + * `single`, end to end inside one process: a real ObjectQL engine over the SQL + * driver, the real `SecurityPlugin` (so every write passes the security + * middleware the write-through is registered inside), and the REAL metadata + * door (`ObjectStackProtocolImplementation`, read from source by this package's + * vitest alias) over the same engine, writing real `sys_metadata` rows. + * + * What the pins hold, each against the door itself rather than a double of it: + * + * - a Setup create lands its row AND an environment definition that the + * security catalog read resolves; an assignment to it grants exactly what + * the same position granted as a row only; + * - an edit and a deactivation keep the row and the definition agreeing + * (`active` and `is_default` stay row-only); a rename moves the definition; + * a delete removes both; + * - a create, or a rename, into a name the metadata door refuses answers the + * door's own refusal and keeps nothing (seat re-rule Q1 = A); an edit of a + * row that already carries such a name still lands as a row write; + * - a name a package or a built-in holds stands the write-through down: the + * write is passed on and nothing reaches metadata (Q2 = A), pinned at the + * write-through's own level rather than at the data door's answer; + * - a package registering a name a Setup position holds in the environment + * ledger is refused `NAMESPACE_CONFLICT` (Q3 = A, within Q4 = A); + * - controls: a walled posture, a system write and a kernel without a metadata + * door write rows exactly as before. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataCommitObject, + SysMetadataAuditObject, +} from '@objectstack/metadata-core'; +import { createSecurityCatalogReader, resetPlatformAdminEmailMemo } from '@objectstack/core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { DATA_MIGRATION_FLAG_OBJECT } from '@objectstack/spec/system'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysMigration } from '@objectstack/platform-objects/system'; + +import { SecurityPlugin } from './security-plugin.js'; +import { buildContextForUser } from './explain-engine.js'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { createPositionWriteThrough } from './position-write-through.js'; +import { + POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID, + backfillRowOnlyPositions, + isRecordablePositionBackfillVerdict, + runOneTimePositionEnvironmentBackfill, +} from './position-environment-backfill.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_pw'; +/** A package that declares one position, the way a stack's `positions` reach the registry. */ +const PKG = 'com.example.pw'; +const PKG_POSITION = 'pw_field_lead'; + +/** The non-system administrator whose writes are the data door's (superuser wildcard). */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +interface Booted { + engine: ObjectQL; + protocol: any; + logger: { info: any; warn: any; error: any; debug: any }; + /** The data door's writer: the administrator, in the organization. */ + admin: Record; + catalogResolves(name: string): Promise; + envRows(name: string): Promise }>>; + rows(name: string): Promise; + /** Kernel lifecycle hooks the plugin registered, by event — collected, fired only by a test. */ + hooks: Map Promise>>; + catalog: ReturnType; +} + +async function boot(opts: { posture?: 'single' | 'isolated'; door?: boolean; ledger?: boolean } = {}): Promise { + const posture = opts.posture ?? 'single'; + const walled = posture !== 'single'; + if (walled) { + process.env[POSTURE_ENV] = posture; + process.env[OWNER_ENV] = 'admin@pw.example'; + } + resetPlatformAdminEmailMemo(); + + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.position-write-through', + name: 'Position write-through', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, + ...(opts.ledger === false ? [] : [SysMigration]), + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + + // A package-declared position: registered under its package, as the + // engine's stack collection loop registers a stack's `positions`, with the + // row the declared-position seeder writes for it (no provenance stamped). + (engine as any).registry.registerItem('position', { name: PKG_POSITION, label: 'Field lead' }, 'name', PKG); + await engine.insert('sys_position', { id: 'pos_pkg', name: PKG_POSITION, label: 'Field lead' }, { context: SYS } as any); + + const protocol = opts.door === false + ? null + : new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined); + const metadata = { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async (type: string) => (type === 'position' ? [] : [...defaultPermissionSets, QA_ADMIN]), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata, + ...(protocol ? { protocol } : {}), + ...(walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } : {}), + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const hooks = new Map Promise>>(); + const ctx: any = { + logger, + hook: (event: string, handler: () => Promise) => { + if (!hooks.has(event)) hooks.set(event, []); + hooks.get(event)!.push(handler); + }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + await engine.insert('sys_organization', { id: ORG, name: 'PW Org', slug: 'pw' }, { context: SYS } as any); + const catalog = createSecurityCatalogReader({ registry: (engine as any).registry, metadata }); + return { + engine, + protocol, + logger, + hooks, + catalog, + admin: { userId: 'usr_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] }, + async catalogResolves(name) { + const entry = await catalog.resolve('position', name); + return entry !== undefined && entry.name === name; + }, + async envRows(name) { + const rows = (await engine.find('sys_metadata', { where: { type: 'position', name }, context: SYS })) as any[]; + return rows.map((r) => ({ + organization_id: r.organization_id ?? null, + state: r.state, + body: typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata, + })); + }, + async rows(name) { + return (await engine.find('sys_position', { where: { name }, context: SYS })) as any[]; + }, + }; +} + + +/** The ADR-0112 envelope a refusal carries: its code and status. */ +const envelope = (e: any) => ({ code: e?.code, status: e?.status ?? e?.httpStatus }); +/** The refusal a write answered, or `null` when it was accepted. */ +const refusalOf = (p: Promise) => p.then(() => null, (e: unknown) => e); + +const create = (b: Booted, row: Record) => + b.engine.insert('sys_position', row, { context: b.admin } as any); +const patch = (b: Booted, id: string, data: Record) => + b.engine.update('sys_position', { id, ...data }, { context: b.admin } as any); +const remove = (b: Booted, id: string) => + b.engine.delete('sys_position', { where: { id }, context: b.admin } as any); + +/** What the explain engine answers for one principal: the grants a position carries. */ +async function grantsOf(engine: ObjectQL, userId: string): Promise> { + const ctx = await buildContextForUser(engine, userId, Date.now(), ORG); + return { + positions: [...(ctx.positions ?? [])].sort(), + permissions: [...(ctx.permissions ?? [])].sort(), + systemPermissions: [...(ctx.systemPermissions ?? [])].sort(), + }; +} + +describe('[ADR-0131 D3] position write-through under single — a Setup position gets its environment definition', () => { + it('a Setup create lands its row AND an environment definition that the security catalog read resolves', async () => { + const b = await boot(); + await create(b, { name: 'regional_lead', label: 'Regional lead', description: 'Leads a region', is_default: true }); + const [row] = await b.rows('regional_lead'); + expect({ label: row.label, is_default: !!row.is_default, active: !!row.active }).toEqual({ + label: 'Regional lead', is_default: true, active: true, + }); + // `is_default` and `active` are row state: the definition carries neither. + expect(await b.envRows('regional_lead')).toEqual([{ + organization_id: null, + state: 'active', + body: { name: 'regional_lead', label: 'Regional lead', description: 'Leads a region', delegatable: false }, + }]); + expect(await b.catalogResolves('regional_lead')).toBe(true); + }); + + it('a user assigned to a Setup-created position is granted exactly what the row-only position granted', async () => { + const grantsThrough = async (door: boolean) => { + const b = await boot({ door }); + await b.engine.insert('sys_user', { + id: 'usr_holder', email: 'holder@pw.example', name: 'holder', email_verified: true, + }, { context: SYS } as any); + await b.engine.insert('sys_member', { + id: 'm_holder', user_id: 'usr_holder', organization_id: ORG, role: 'member', + }, { context: SYS } as any); + await b.engine.insert('sys_permission_set', { + id: 'ps_pw_reports', + name: 'pw_reports', + label: 'Reports', + object_permissions: JSON.stringify({ sys_position: { allowRead: true } }), + system_permissions: JSON.stringify(['view_reports']), + }, { context: SYS } as any); + const created: any = await create(b, { name: 'report_reader', label: 'Report reader' }); + await b.engine.insert('sys_position_permission_set', { + id: 'pps_reports', position_id: created.id, permission_set_id: 'ps_pw_reports', + }, { context: SYS } as any); + await b.engine.insert('sys_user_position', { + id: 'up_holder', user_id: 'usr_holder', position: 'report_reader', organization_id: ORG, + }, { context: SYS } as any); + return { grants: await grantsOf(b.engine, 'usr_holder'), defined: (await b.envRows('report_reader')).length }; + }; + const before = await grantsThrough(false); // no metadata door: the row-only position, as before this stage + const after = await grantsThrough(true); + expect(before.defined).toBe(0); + expect(after.defined).toBe(1); + expect(after.grants).toEqual(before.grants); + expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']), permissions: ['pw_reports'] }); + }); + + it('an edit keeps the row and the definition agreeing; a deactivation and is_default stay on the row', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'shift_lead', label: 'Shift lead' }); + await patch(b, created.id, { label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }); + expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ + { name: 'shift_lead', label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }, + ]); + const saves = vi.spyOn(b.protocol, 'saveMetaItem'); + await patch(b, created.id, { active: false }); + await patch(b, created.id, { is_default: true }); + expect(saves, 'a row-state patch writes no definition').not.toHaveBeenCalled(); + const [row] = await b.rows('shift_lead'); + expect({ label: row.label, active: !!row.active, is_default: !!row.is_default }).toEqual({ + label: 'Shift supervisor', active: false, is_default: true, + }); + expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ + { name: 'shift_lead', label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }, + ]); + }); + + it('a rename saves the new name\'s definition and deletes the old one', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); + await patch(b, created.id, { name: 'evening_lead' }); + expect(await b.rows('night_lead')).toEqual([]); + expect((await b.rows('evening_lead')).map((r) => r.id)).toEqual([created.id]); + expect(await b.envRows('night_lead')).toEqual([]); + expect((await b.envRows('evening_lead')).map((r) => r.body)).toEqual([ + { name: 'evening_lead', label: 'Night lead', delegatable: false }, + ]); + expect(await b.catalogResolves('night_lead')).toBe(false); + expect(await b.catalogResolves('evening_lead')).toBe(true); + }); + + it('a delete removes the row, then the definition', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'temp_lead', label: 'Temp lead' }); + expect(await b.catalogResolves('temp_lead')).toBe(true); + await remove(b, created.id); + expect(await b.rows('temp_lead')).toEqual([]); + expect(await b.envRows('temp_lead')).toEqual([]); + expect(await b.catalogResolves('temp_lead')).toBe(false); + }); + + it('a definition delete that fails is reported at error, once, naming the remedy — the row stays deleted', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'sticky_lead', label: 'Sticky lead' }); + vi.spyOn(b.protocol, 'deleteMetaItem').mockRejectedValueOnce(new Error('store unavailable')); + await remove(b, created.id); + expect(await b.rows('sticky_lead')).toEqual([]); + const errors = b.logger.error.mock.calls.filter(([m]: [string]) => String(m).includes("'sticky_lead'")); + expect(errors).toHaveLength(1); + expect(String(errors[0][0])).toContain('DELETE /api/v1/meta/position/sticky_lead'); + }); +}); + +describe('[ADR-0131 D3] Q1 = A — a name the metadata door refuses is refused with the door\'s own answer', () => { + it.each([ + ['uppercase and a space', 'Regional Lead', { code: 'INVALID_REQUEST', status: 400 }], + ['a hyphen', 'regional-lead', { code: 'INVALID_REQUEST', status: 400 }], + ['a leading digit', '9lead', { code: 'INVALID_REQUEST', status: 400 }], + ['a leading underscore', '_lead', { code: 'INVALID_REQUEST', status: 400 }], + ['one character', 'r', { code: 'INVALID_METADATA', status: 422 }], + ['a dot', 'regional.lead', { code: 'INVALID_METADATA', status: 422 }], + ])('a create named with %s is refused and keeps nothing', async (_label, name, expected) => { + const b = await boot(); + const refusal = await refusalOf(create(b, { name, label: 'x' })); + expect(envelope(refusal)).toEqual(expected); + expect(await b.rows(name), 'the row write was undone').toEqual([]); + expect(await b.envRows(name)).toEqual([]); + }); + + it('a rename into such a name is refused, and the row and its definition keep the old name', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'audit_lead', label: 'Audit lead' }); + const refusal = await refusalOf(patch(b, created.id, { name: 'Audit Lead', label: 'Renamed' })); + expect(envelope(refusal)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + const [row] = await b.rows('audit_lead'); + expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Audit lead' }); + expect(await b.rows('Audit Lead')).toEqual([]); + expect((await b.envRows('audit_lead')).map((r) => r.body)).toEqual([ + { name: 'audit_lead', label: 'Audit lead', delegatable: false }, + ]); + }); + + it('control: an edit of a row that already carries such a name lands as a row write, with no definition', async () => { + const b = await boot(); + // A row written before this stage (a system write is never translated). + await b.engine.insert('sys_position', { id: 'pos_legacy', name: 'Legacy Lead', label: 'Legacy' }, { context: SYS } as any); + await patch(b, 'pos_legacy', { label: 'Legacy (renamed label)' }); + const [row] = await b.rows('Legacy Lead'); + expect(row?.label).toBe('Legacy (renamed label)'); + expect(await b.envRows('Legacy Lead')).toEqual([]); + }); + + it('a metadata refusal of a legal name undoes the create, and the refusal is the answer', async () => { + const b = await boot(); + const refused = Object.assign(new Error('store refused'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); + vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(refused); + const refusal = await refusalOf(create(b, { name: 'store_lead', label: 'Store lead' })); + expect(refusal).toBe(refused); + expect(await b.rows('store_lead')).toEqual([]); + expect(await b.envRows('store_lead')).toEqual([]); + }); + + it('a metadata refusal of an edit restores the row the edit changed', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'desk_lead', label: 'Desk lead' }); + const refused = Object.assign(new Error('store refused'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); + vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(refused); + expect(await refusalOf(patch(b, created.id, { label: 'Desk supervisor' }))).toBe(refused); + expect((await b.rows('desk_lead'))[0]?.label).toBe('Desk lead'); + expect((await b.envRows('desk_lead')).map((r) => r.body)).toEqual([ + { name: 'desk_lead', label: 'Desk lead', delegatable: false }, + ]); + }); +}); + +describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands the write-through down', () => { + // Pinned at the write-through's own level, never at the data door's answer: + // whether the data door admits an edit of a package-declared row is the + // system-row gate's call, decided on the row's provenance stamp, and that + // stamp is another change's. The write-through must stand down whatever the + // stamp says, because it reads the name's holder from the engine registry. + it('the write-through passes a write on a package-held or built-in name to the next step, and writes nothing to metadata', async () => { + const b = await boot(); + const door = { + saveMetaItem: vi.fn(async (_request: Record) => undefined), + deleteMetaItem: vi.fn(async (_request: Record) => undefined), + }; + const writeThrough = createPositionWriteThrough({ + ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, + }); + const passOn = async (opCtx: any, write: () => Promise) => { + const next = vi.fn(async () => { opCtx.result = await write(); }); + await writeThrough(opCtx, next); + return next.mock.calls.length; + }; + + // An edit of the package-declared row (the label and delegatable a Setup edit sends). + const edit = { object: 'sys_position', operation: 'update', data: { id: 'pos_pkg', label: 'Edited', delegatable: true }, context: b.admin }; + expect(await passOn(edit, () => b.engine.update('sys_position', { id: 'pos_pkg', label: 'Edited', delegatable: true }, { context: SYS } as any))) + .toBe(1); + // A create under a built-in name (the plugin registers the six under its own package). + const builtIn = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin }; + expect(await passOn(builtIn, async () => ({ id: 'pos_builtin', name: 'everyone' }))).toBe(1); + // A delete of the package-declared row. + const del = { object: 'sys_position', operation: 'delete', options: { where: { id: 'pos_pkg' } }, context: b.admin }; + expect(await passOn(del, () => b.engine.delete('sys_position', { where: { id: 'pos_pkg' }, context: SYS } as any))).toBe(1); + + expect(door.saveMetaItem).not.toHaveBeenCalled(); + expect(door.deleteMetaItem).not.toHaveBeenCalled(); + expect(await b.envRows(PKG_POSITION)).toEqual([]); + + // Control: the same write-through, the same door, a name only Setup holds — it writes the definition. + const setupOnly = { object: 'sys_position', operation: 'insert', data: { name: 'setup_lead', label: 'Setup lead' }, context: b.admin }; + expect(await passOn(setupOnly, () => b.engine.insert('sys_position', { name: 'setup_lead', label: 'Setup lead' }, { context: SYS } as any))) + .toBe(1); + expect(door.saveMetaItem).toHaveBeenCalledTimes(1); + expect(door.saveMetaItem.mock.calls[0][0]).toMatchObject({ type: 'position', name: 'setup_lead' }); + }); + + it('control: the metadata door itself refuses an environment save over that name (NOT_OVERRIDABLE)', async () => { + const b = await boot(); + const refusal = await refusalOf( + b.protocol.saveMetaItem({ type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'x' } }), + ); + expect(envelope(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); +}); + +describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup position holds in the environment ledger', () => { + it('the registry item seam refuses it 422 NAMESPACE_CONFLICT, naming both holders', async () => { + const b = await boot(); + await create(b, { name: 'claims_lead', label: 'Claims lead' }); + let thrown: any = null; + try { + (b.engine as any).registry.registerItem('position', { name: 'claims_lead', label: 'Pkg' }, 'name', 'com.example.other'); + } catch (e) { thrown = e; } + expect(envelope(thrown)).toEqual({ code: 'NAMESPACE_CONFLICT', status: 422 }); + expect(thrown?.existingHolder).toEqual({ kind: 'environment' }); + expect(String(thrown?.message)).toContain('com.example.other'); + }); +}); + +describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it was', () => { + it('a walled posture: an organization administrator\'s create lands its organization row, and no definition', async () => { + const b = await boot({ posture: 'isolated' }); + const saves = vi.spyOn(b.protocol, 'saveMetaItem'); + await create(b, { name: 'plant_lead', label: 'Plant lead' }); + expect((await b.rows('plant_lead')).map((r) => r.organization_id)).toEqual([ORG]); + expect(saves).not.toHaveBeenCalled(); + expect(await b.envRows('plant_lead')).toEqual([]); + }); + + it('a system write is never translated', async () => { + const b = await boot(); + await b.engine.insert('sys_position', { id: 'pos_sys', name: 'seeded_lead', label: 'Seeded' }, { context: SYS } as any); + expect((await b.rows('seeded_lead')).length).toBe(1); + expect(await b.envRows('seeded_lead')).toEqual([]); + }); + + it('a kernel with no metadata door writes the row exactly as before', async () => { + const b = await boot({ door: false }); + await create(b, { name: 'plain_lead', label: 'Plain lead' }); + expect((await b.rows('plain_lead')).length).toBe(1); + expect(await b.envRows('plain_lead')).toEqual([]); + }); + + it('a built-in position is still refused at the admin door, before anything is translated', async () => { + const b = await boot(); + await b.engine.insert('sys_position', { + id: 'pos_everyone', name: 'everyone', label: 'Everyone', managed_by: 'platform', + }, { context: SYS } as any); + const refusal: any = await refusalOf(patch(b, 'pos_everyone', { label: 'All' })); + expect(refusal?.name).toBe('PermissionDeniedError'); + expect(await b.envRows('everyone')).toEqual([]); + }); + + it('the permission-set write-through is unchanged: a data-door set create still becomes a permission definition', async () => { + const b = await boot(); + await b.engine.insert('sys_permission_set', { name: 'pw_viewers', label: 'Viewers' }, { context: b.admin } as any); + const rows = (await b.engine.find('sys_metadata', { where: { type: 'permission', name: 'pw_viewers' }, context: SYS })) as any[]; + expect(rows.map((r) => r.organization_id ?? null)).toEqual([null]); + expect(await b.envRows('pw_viewers')).toEqual([]); + }); +}); + +describe('[ADR-0131 D3] the row-only position backfill — once, under single, remembered in sys_migration', () => { + /** Rows written before the write-through existed: system writes, as the pre-stage data door left them. */ + const seedRowOnly = async (b: Booted) => { + await b.engine.insert('sys_position', [ + { id: 'pos_a', name: 'legacy_lead', label: 'Legacy lead', description: 'Before S7', delegatable: true }, + { id: 'pos_b', name: 'Legacy Lead', label: 'Illegal name' }, + { id: 'pos_c', name: 'twin_lead', label: 'Twin' }, + { id: 'pos_d', name: 'twin_lead', label: 'Twin', organization_id: ORG }, + ], { context: SYS } as any); + }; + const census = async (b: Booted) => { + const names = [...new Set(((await b.engine.find('sys_position', { context: SYS })) as any[]).map((r) => r.name))]; + const rowOnly: string[] = []; + for (const n of names) if (!(await b.catalogResolves(n))) rowOnly.push(n); + return rowOnly.sort(); + }; + const runBootstrapped = async (b: Booted) => { + for (const handler of b.hooks.get('kernel:bootstrapped') ?? []) await handler(); + }; + const ledgerRows = async (b: Booted) => + (await b.engine.find(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID }, context: SYS, + })) as any[]; + + it('SecurityPlugin runs it at kernel:bootstrapped: the row-only census is empty after it, save the refused-name class', async () => { + const b = await boot(); + await seedRowOnly(b); + expect(await census(b)).toEqual(['Legacy Lead', 'legacy_lead', 'twin_lead']); + await runBootstrapped(b); + expect(await census(b), 'only the final refused-name class stays row-only').toEqual(['Legacy Lead']); + expect((await b.envRows('legacy_lead')).map((r) => ({ org: r.organization_id, body: r.body }))).toEqual([ + { org: null, body: { name: 'legacy_lead', label: 'Legacy lead', description: 'Before S7', delegatable: true } }, + ]); + expect(await b.envRows(PKG_POSITION), 'a package-declared position is left alone').toEqual([]); + const [ledger] = await ledgerRows(b); + expect(JSON.parse(ledger.details)).toEqual({ names: 4, rowOnly: 3, backfilled: 2, refusedName: 1 }); + const warned = b.logger.warn.mock.calls.filter(([m]: [string]) => String(m).includes('keep no environment definition')); + expect(warned).toHaveLength(1); + expect(warned[0][1]).toEqual({ count: 1, positions: { names: ['Legacy Lead'] } }); + }); + + it('a rerun changes nothing: the recorded verdict skips the pass, and a fresh pass writes nothing', async () => { + const b = await boot(); + await seedRowOnly(b); + await runBootstrapped(b); + const saves = vi.spyOn(b.protocol, 'saveMetaItem'); + const again = await runOneTimePositionEnvironmentBackfill(b.engine as any, { + posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, + }); + expect(again.status).toBe('already-run'); + const pass = await backfillRowOnlyPositions(b.engine as any, { catalog: b.catalog, door: b.protocol, logger: b.logger }); + expect({ rowOnly: pass.rowOnly, backfilled: pass.backfilled, refusedName: pass.refusedName }).toEqual({ + rowOnly: ['Legacy Lead'], backfilled: [], refusedName: ['Legacy Lead'], + }); + expect(saves).not.toHaveBeenCalled(); + expect(await ledgerRows(b)).toHaveLength(1); + }); + + it('rows of one name that disagree are not guessed at: reported at error, and the verdict stays unrecorded', async () => { + const b = await boot(); + await b.engine.insert('sys_position', [ + { id: 'pos_x', name: 'split_lead', label: 'Split A' }, + { id: 'pos_y', name: 'split_lead', label: 'Split B', organization_id: ORG }, + ], { context: SYS } as any); + const out = await runOneTimePositionEnvironmentBackfill(b.engine as any, { + posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, + }); + expect(out.status).toBe('undecided'); + expect(out.backfill?.conflicting).toEqual(['split_lead']); + expect(await b.envRows('split_lead')).toEqual([]); + expect(await ledgerRows(b)).toEqual([]); + expect(b.logger.error.mock.calls.some(([m]: [string]) => String(m).includes('rows carrying each name disagree'))).toBe(true); + }); + + it('a definition write that does not land is loud, and the next boot retries', async () => { + const b = await boot(); + await seedRowOnly(b); + vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(new Error('store unavailable')); + const first = await runOneTimePositionEnvironmentBackfill(b.engine as any, { + posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, + }); + expect(first.status).toBe('undecided'); + expect(first.backfill?.failed).toEqual(['legacy_lead']); + expect(await ledgerRows(b)).toEqual([]); + expect(b.logger.error).toHaveBeenCalled(); + const second = await runOneTimePositionEnvironmentBackfill(b.engine as any, { + posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, + }); + expect(second.status).toBe('ran'); + expect(second.backfill?.backfilled).toEqual(['legacy_lead']); + expect(await ledgerRows(b)).toHaveLength(1); + }); + + it('a walled posture does not run it; neither does a kernel with no metadata door record a verdict', async () => { + const walled = await boot({ posture: 'isolated' }); + await seedRowOnly(walled); + expect((await runOneTimePositionEnvironmentBackfill(walled.engine as any, { + posture: 'isolated', catalog: walled.catalog, door: walled.protocol, logger: walled.logger, + })).status).toBe('not-applicable'); + expect(await walled.envRows('legacy_lead')).toEqual([]); + + const doorless = await boot({ door: false }); + await seedRowOnly(doorless); + const out = await runOneTimePositionEnvironmentBackfill(doorless.engine as any, { + posture: 'single', catalog: doorless.catalog, door: null, logger: doorless.logger, + }); + expect({ status: out.status, stopped: out.backfill?.stopped }).toEqual({ status: 'undecided', stopped: 'door-absent' }); + expect(await ledgerRows(doorless)).toEqual([]); + }); + + it('isRecordablePositionBackfillVerdict: the refused-name class is final; a failure, a conflict or a stop is not', () => { + const base = { names: 1, rowOnly: ['x'], backfilled: [], refusedName: ['X X'], conflicting: [], failed: [] }; + expect(isRecordablePositionBackfillVerdict(base)).toBe(true); + expect(isRecordablePositionBackfillVerdict({ ...base, failed: ['x'] })).toBe(false); + expect(isRecordablePositionBackfillVerdict({ ...base, conflicting: ['x'] })).toBe(false); + expect(isRecordablePositionBackfillVerdict({ ...base, stopped: 'scan-unreadable' })).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts new file mode 100644 index 00000000000..2eb46617da5 --- /dev/null +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -0,0 +1,431 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `sys_position` data-door write-through under `single` (ADR-0131 D3, C2 + * stage S7). + * + * ## What it does + * + * ADR-0131 D3 gives the security catalog one home, the environment registry, + * and says of the `single` posture: "an admin who creates or edits a position + * ... in Setup performs an environment metadata write". Before this module a + * Setup create or edit of a position wrote a `sys_position` row and nothing + * else, so the position had no registry home and the catalog read + * (`createSecurityCatalogReader`, `@objectstack/core`) did not resolve it. Every + * non-system data-door write on `sys_position` under `single` now also writes + * the position's DEFINITION through the metadata door, at environment scope: + * + * - **the row first.** The engine executes the row write as before, with every + * check it already makes (the required label, the reserved built-in names, + * one name per organization, the junction's `DELETE_RESTRICTED`). Only a row + * the engine accepted gets a definition. Writing the definition first would, + * for a duplicate name, overwrite the existing definition before the unique + * index refused the row; + * - **then the definition** `{ name, label, description, delegatable }`, read + * back from the written row, so the row and the definition agree by + * construction. `active` and `is_default` are row state (no `PositionSchema` + * key carries them; the standing ruling keeps `active` on the row), so a + * patch touching only them is a row write and nothing else; + * - **a refusal undoes the row.** When the metadata door refuses a create, or + * an update that renames a position, the row write is undone and the door's + * own refusal is the answer: nothing is kept. That is the refusal for a name + * the door does not accept (its item-name grammar, `PositionSchema.name`): + * the data door accepted such names before this stage, and a position named + * that way could never have a registry home (seat re-rule on the C2 card, + * Q1 = A); + * - **rename** saves the new name's definition and deletes the old name's; + * - **delete** deletes the row, then the definition. A definition delete that + * fails is reported at `error`: the declared-position seeder recreates a row + * for every definition the catalog lists at the next boot, so the deleted + * position would come back. + * + * ## Where it stands down — the write proceeds exactly as before + * + * - **a system write** (`isSystem`): the seeders and the package door write + * rows for definitions that already have their home; + * - **a walled posture**: under a wall a definition is the operator's + * (ADR-0131 D3), and an organization's row has no environment home to write + * (the walled half is a later stage's); + * - **a kernel whose metadata protocol cannot save and delete**: the legacy + * direct write, as the permission-set write-through does; + * - **a name a package or a built-in holds**: its definition is the package's + * (or the platform's), and the metadata door refuses an environment save over + * it (`403 NOT_OVERRIDABLE`, `allowOrgOverride: false`). The question is read + * from the engine registry's artifact provenance + * (`SchemaRegistry.getArtifactItem`), the same source the door decides + * `NOT_OVERRIDABLE` from (seat re-rule, Q2 = A); + * - **an edit of a row whose name the metadata door does not accept**, with the + * name unchanged: such a row predates this stage, has no definition and can + * have none, so the edit stays a row write (Q1 = A's control). The predicate + * ({@link metadataDoorAcceptsPositionName}) is read off the spec's own item + * name pattern and `PositionSchema.name`, never transcribed. + * + * ## Where it runs + * + * `SecurityPlugin` registers it on `sys_position` AFTER its security + * middleware, so it runs INSIDE it: `assertSystemRowWriteGate`, the + * engine-owned write guard and the CRUD/FLS checks have all passed before a + * write is translated. Unlike the permission-set write-through, the driver + * write is never skipped: every row reader keeps answering exactly as before, + * because the row is still the row the data door wrote. + */ + +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; +import { PositionSchema } from '@objectstack/spec/identity'; + +/** The catalog object this module is registered on. */ +export const POSITION_OBJECT = 'sys_position'; +/** The metadata type a position definition is saved as. */ +export const POSITION_METADATA_TYPE = 'position'; + +const SYSTEM_CTX = { isSystem: true } as const; + +/** + * The row columns that are row STATE, never part of a definition — the + * standing ruling keeps `active` on the row, and `is_default` has no key on + * `PositionSchema` either. Identity (`id`) and the readonly provenance and + * timestamp columns are not definition edits either. + */ +const ROW_ONLY_COLUMNS: ReadonlySet = new Set([ + 'id', 'active', 'is_default', 'managed_by', 'created_at', 'updated_at', 'organization_id', +]); + +/** The kernel logger, as this module uses it. */ +export interface PositionWriteThroughLogger { + info?: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + /** The kernel logger's shape: message, cause, meta. */ + error?: (message: string, cause?: Error, meta?: Record) => void; +} + +/** The metadata door, as this module uses it. */ +export interface PositionMetadataDoor { + saveMetaItem(request: { type: string; name: string; item: Record; actor?: string }): Promise; + deleteMetaItem(request: { type: string; name: string; actor?: string }): Promise; +} + +export interface PositionWriteThroughDeps { + /** ObjectQL engine handle (row reads, the undo writes, the registry). */ + ql: any; + /** Lazy protocol handle — the protocol service may register after start(). */ + getProtocol: () => unknown; + /** The tenancy posture in force — read per write. */ + getPosture: () => TenancyPosture; + logger?: PositionWriteThroughLogger; +} + +/** + * Is `protocol` a metadata door this module can write through? Both verbs are + * required: a door that can save but not delete would leave a renamed or + * deleted position's definition behind. + */ +export function isPositionMetadataDoor(protocol: unknown): protocol is PositionMetadataDoor { + const p = protocol as Partial | null | undefined; + return !!p && typeof p.saveMetaItem === 'function' && typeof p.deleteMetaItem === 'function'; +} + +/** + * Does a package or a built-in hold this position name? Read from the engine + * registry's artifact provenance — `getArtifactItem` answers only an item a + * code package registered (an environment definition hydrated from + * `sys_metadata` carries no package), which is the lookup the metadata door + * arms `NOT_OVERRIDABLE` from. A registry without that read falls back to the + * by-name read with the same provenance filter the door applies to one. + */ +export function packageHoldsPosition(ql: any, name: string): boolean { + const registry = ql?.registry; + if (!registry) return false; + try { + if (typeof registry.getArtifactItem === 'function') { + return registry.getArtifactItem(POSITION_METADATA_TYPE, name) !== undefined; + } + if (typeof registry.getItem === 'function') { + const item = registry.getItem(POSITION_METADATA_TYPE, name); + const pkg = (item as { _packageId?: unknown } | null | undefined)?._packageId; + return typeof pkg === 'string' && pkg !== '' && pkg !== 'sys_metadata'; + } + } catch { + // An unreadable registry holds nothing we can see; the metadata door + // still refuses a save over a packaged name itself. + } + return false; +} + +let cachedPositionNameSchema: { safeParse(v: unknown): { success: boolean } } | null = null; + +/** + * Would the metadata door accept `name` as a position name? Both of the door's + * name checks, each read from its own declaration: the item-name grammar + * (`METADATA_ITEM_NAME_PATTERN`, enforced at `saveMetaItem`) and + * `PositionSchema.name` (the spec validation that runs inside it). Derived, + * never transcribed, so a grammar change in the spec moves this answer with + * it. Resolved on first use: `PositionSchema` is a lazy schema. + */ +export function metadataDoorAcceptsPositionName(name: unknown): boolean { + if (typeof name !== 'string' || !METADATA_ITEM_NAME_PATTERN.test(name)) return false; + cachedPositionNameSchema ??= ((PositionSchema as unknown as { shape?: Record }).shape?.name ?? null); + return cachedPositionNameSchema ? cachedPositionNameSchema.safeParse(name).success : true; +} + +/** + * The position DEFINITION a row carries: the four `PositionSchema` keys the + * row has columns for. `active` and `is_default` are row state and never + * travel; a `null` or blank description is absent, never `null` (the schema + * declares it optional, not nullable). + */ +export function positionBodyFromRow(row: Record): Record { + const body: Record = { + name: row.name, + label: typeof row.label === 'string' && row.label !== '' ? row.label : row.name, + }; + if (typeof row.description === 'string' && row.description !== '') body.description = row.description; + body.delegatable = row.delegatable === true || row.delegatable === 1 || row.delegatable === '1' || row.delegatable === 'true'; + return body; +} + +/** Does this update payload touch the definition at all? */ +function touchesDefinition(patch: Record): boolean { + return Object.keys(patch).some((key) => !ROW_ONLY_COLUMNS.has(key)); +} + +const scalarId = (v: unknown): v is string | number => + (typeof v === 'string' && v !== '') || (typeof v === 'number' && Number.isFinite(v)); + +/** The rows a data-door update or delete targets, read before the write (by id, or by the write's filter). */ +async function readTargetRows(ql: any, opCtx: any): Promise[]> { + const data = opCtx?.data; + const where = opCtx?.options?.where; + let filter: unknown; + if (data && typeof data === 'object' && !Array.isArray(data) && scalarId((data as any).id)) filter = { id: (data as any).id }; + else if (where && typeof where === 'object' && scalarId((where as any).id)) filter = { id: (where as any).id }; + else if (where && typeof where === 'object') filter = where; + else return []; + const rows = await ql.find(POSITION_OBJECT, { where: filter, limit: 1000, context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; +} + +async function readRowById(ql: any, id: unknown): Promise | null> { + const rows = await ql.find(POSITION_OBJECT, { where: { id }, limit: 1, context: SYSTEM_CTX }); + return Array.isArray(rows) && rows[0] ? rows[0] : null; +} + +/** Does any row still carry `name` (another organization's, or a declared one)? */ +async function nameStillCarried(ql: any, name: string): Promise { + const rows = await ql.find(POSITION_OBJECT, { where: { name }, limit: 1, context: SYSTEM_CTX }); + return Array.isArray(rows) && rows.length > 0; +} + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function logError(logger: PositionWriteThroughLogger | undefined, message: string, cause: unknown, meta: Record): void { + if (logger?.error) logger.error(message, cause instanceof Error ? cause : undefined, meta); + else logger?.warn?.(message, { ...meta, error: errorText(cause) }); +} + +/** + * Engine middleware: under `single`, write every non-system data-door create, + * edit, rename and delete of a position through to its environment definition. + * See the module note for the order, the undo and where it stands down. + */ +export function createPositionWriteThrough( + deps: PositionWriteThroughDeps, +): (opCtx: any, next: () => Promise) => Promise { + const { ql, logger } = deps; + + /** Which of a name's possible definitions this module may write: neither a package's nor the platform's. */ + const environmentOwns = (name: unknown): name is string => + typeof name === 'string' && name !== '' && !packageHoldsPosition(ql, name); + + const saveDefinition = (door: PositionMetadataDoor, row: Record, actor?: string) => + door.saveMetaItem({ + type: POSITION_METADATA_TYPE, + name: String(row.name), + item: positionBodyFromRow(row), + ...(actor ? { actor } : {}), + }); + + /** + * Delete `name`'s environment definition once no row carries the name any + * more. A definition the delete leaves behind is reported at `error`: the + * row is already gone and its caller was told so, and the next boot's + * declared-position seeder recreates a row from the definition. + */ + const deleteDefinitionOf = async (door: PositionMetadataDoor, name: string, actor?: string): Promise => { + if (!environmentOwns(name) || !metadataDoorAcceptsPositionName(name)) return; + try { + if (await nameStillCarried(ql, name)) return; + await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name, ...(actor ? { actor } : {}) }); + } catch (e) { + logError( + logger, + `[security] the position '${name}' was removed from ${POSITION_OBJECT}, but its environment definition was ` + + 'NOT deleted (ADR-0131 D3). Nothing looks wrong now: the row is gone and the write answered success. At the ' + + 'next boot the declared-position seeder recreates a row for every definition the catalog lists, so the ' + + 'position comes back and every assignment naming it grants again. Fix: delete the definition through the ' + + `metadata door (DELETE /api/v1/meta/position/${name}) before the next restart.`, + e, + { name }, + ); + } + }; + + /** Undo an insert: the rows this write created go again, under the system context. */ + const undoInsert = async (rows: Record[]): Promise => { + for (const row of rows) { + try { + await ql.delete(POSITION_OBJECT, { where: { id: row.id }, context: SYSTEM_CTX }); + } catch (e) { + logError( + logger, + `[security] a ${POSITION_OBJECT} create was refused by the metadata door, and undoing its row FAILED: the ` + + `position '${String(row.name)}' exists as a row with no environment definition while the caller was told ` + + 'the create was refused. Fix: delete the row by hand, then create the position again under a name the ' + + 'metadata door accepts (lowercase snake_case).', + e, + { id: row.id, name: row.name }, + ); + } + } + }; + + /** Undo an update: every target row gets back the columns the patch wrote. */ + const undoUpdate = async (targets: Record[], patch: Record): Promise => { + for (const pre of targets) { + const restore: Record = { id: pre.id }; + for (const key of Object.keys(patch)) { + if (key !== 'id' && key in pre) restore[key] = pre[key]; + } + try { + await ql.update(POSITION_OBJECT, restore, { context: SYSTEM_CTX }); + } catch (e) { + logError( + logger, + `[security] a ${POSITION_OBJECT} edit was refused by the metadata door, and restoring the row FAILED: the ` + + `row '${String(pre.name)}' keeps the refused edit while the caller was told it was refused, and the row ` + + 'and its environment definition now disagree. Fix: re-apply the previous values to the row by hand.', + e, + { id: pre.id, name: pre.name }, + ); + } + } + }; + + return async (opCtx: any, next: () => Promise): Promise => { + if (opCtx?.object !== POSITION_OBJECT) return next(); + if (opCtx?.context?.isSystem) return next(); + const op = opCtx?.operation; + if (op !== 'insert' && op !== 'update' && op !== 'delete') return next(); + if (postureEnforcesWall(deps.getPosture())) return next(); + const door = deps.getProtocol(); + if (!isPositionMetadataDoor(door)) return next(); + const actor = opCtx?.context?.userId ? String(opCtx.context.userId) : undefined; + + if (op === 'insert') { + await next(); + const result = opCtx.result; + const written = (Array.isArray(result) ? result : [result]) + .filter((r: unknown): r is Record => !!r && typeof r === 'object' && scalarId((r as any).id)); + const saved: Record[] = []; + for (const created of written) { + const row = (await readRowById(ql, created.id)) ?? created; + if (!environmentOwns(row.name)) continue; + try { + await saveDefinition(door, row, actor); + saved.push(row); + } catch (e) { + // The definitions this write already saved go with their rows. + for (const done of saved) await deleteDefinitionOfInsert(door, done, actor); + await undoInsert(written); + throw e; + } + } + return; + } + + // An update or a delete: the targets are read before the write. + const targets = await readTargetRows(ql, opCtx); + await next(); + if (targets.length === 0) return; + + if (op === 'delete') { + for (const pre of targets) { + if (await readRowById(ql, pre.id)) continue; // the row survived: nothing was deleted + await deleteDefinitionOf(door, String(pre.name), actor); + } + return; + } + + const patch = opCtx.data && typeof opCtx.data === 'object' && !Array.isArray(opCtx.data) ? opCtx.data : null; + if (!patch || !touchesDefinition(patch)) return; + const posts: Array<{ pre: Record; post: Record }> = []; + for (const pre of targets) { + const post = await readRowById(ql, pre.id); + if (post) posts.push({ pre, post }); + } + + // Every new definition first; the old names' definitions go only once all landed. + const saved: Array<{ pre: Record; post: Record }> = []; + for (const pair of posts) { + const { pre, post } = pair; + const renamed = pre.name !== post.name; + if (!environmentOwns(post.name)) continue; + // An unchanged name the door does not accept: a row that predates this + // stage and can have no definition — the edit stays a row write. + if (!renamed && !metadataDoorAcceptsPositionName(post.name)) continue; + try { + await saveDefinition(door, post, actor); + saved.push(pair); + } catch (e) { + await undoUpdate(targets, patch); + // The definitions saved before this one return to their pre-image. + for (const done of saved) await restoreDefinitionAfterUndo(door, done, actor); + throw e; + } + } + for (const { pre, post } of posts) { + if (pre.name !== post.name) await deleteDefinitionOf(door, String(pre.name), actor); + } + }; + + /** Undo of an insert's saved definition: the row is about to go, so its definition goes too. */ + async function deleteDefinitionOfInsert(door: PositionMetadataDoor, row: Record, actor?: string): Promise { + try { + await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name: String(row.name), ...(actor ? { actor } : {}) }); + } catch (e) { + logError( + logger, + `[security] a ${POSITION_OBJECT} create was refused, and undoing the environment definition already saved for ` + + `'${String(row.name)}' FAILED: the definition stays with no row, and the next boot's declared-position ` + + `seeder creates a row for it. Fix: DELETE /api/v1/meta/position/${String(row.name)}.`, + e, + { name: row.name }, + ); + } + } + + /** Undo of an update's saved definition: back to the pre-image, and a renamed one's new name goes. */ + async function restoreDefinitionAfterUndo( + door: PositionMetadataDoor, + pair: { pre: Record; post: Record }, + actor?: string, + ): Promise { + const { pre, post } = pair; + try { + if (pre.name !== post.name) { + await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name: String(post.name), ...(actor ? { actor } : {}) }); + } else { + await saveDefinition(door, pre, actor); + } + } catch (e) { + logError( + logger, + `[security] a ${POSITION_OBJECT} edit was refused, and returning the environment definition of ` + + `'${String(post.name)}' to its previous state FAILED: the row is back as it was and the definition is not. ` + + 'Fix: re-save the position in Setup.', + e, + { name: post.name }, + ); + } + } +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 05177cb0b61..a218573f496 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -41,6 +41,8 @@ import { unregisterGrantPermissionSetNameHooks, } from './grant-permission-set-name.js'; import { runOneTimeGrantPermissionSetNameBackfill } from './grant-permission-set-name-backfill.js'; +import { createPositionWriteThrough } from './position-write-through.js'; +import { runOneTimePositionEnvironmentBackfill } from './position-environment-backfill.js'; import { explainAccess, buildContextForUser, @@ -4530,6 +4532,25 @@ export class SecurityPlugin implements Plugin { { object: 'sys_permission_set' }, ); + // [ADR-0131 D3, C2 stage S7] Under `single`, a non-system create, edit, + // rename or delete of a position also writes its DEFINITION through the + // metadata door at environment scope — the row first, then the + // definition; a refused definition undoes the row. Registered AFTER the + // security middleware, so it runs INSIDE it. Stands down for a walled + // posture, a name a package or built-in holds, and a kernel without a + // capable metadata protocol. See `position-write-through.ts`. + ql.registerMiddleware( + createPositionWriteThrough({ + ql, + getProtocol: () => { + try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } + }, + getPosture: () => this.tenancyPosture, + logger: ctx.logger, + }), + { object: 'sys_position' }, + ); + // Defer platform admin bootstrap until all plugins finish starting — // sys_user / sys_permission_set objects must be registered (by // plugin-auth and platform-objects respectively) before we can @@ -5023,6 +5044,37 @@ export class SecurityPlugin implements Plugin { void runGrantNameBackfill(); } + // [ADR-0131 D3, C2 stage S7] Give every row-only position — written in + // Setup before the write-through above existed — its environment + // definition, once per `single` deployment, recorded in `sys_migration`. + // At `kernel:bootstrapped` for the reason the grant-name backfill gives, + // and one more: an environment definition minted ahead of a package's + // declaration of the same name would refuse that package. See + // `position-environment-backfill.ts`. + const runPositionBackfill = async (): Promise => { + try { + let door: unknown = null; + try { door = (ctx as any).getService?.('protocol') ?? null; } catch { door = null; } + await runOneTimePositionEnvironmentBackfill(ql as any, { + posture: this.tenancyPosture, + catalog: createSecurityCatalogReader({ registry: (ql as any).registry, metadata: this.metadata }), + door, + logger: ctx.logger, + }); + } catch (e) { + ctx.logger.warn( + '[security] the row-only position backfill did not run — positions created in Setup before the ' + + 'environment write-through keep no environment definition until a later boot runs it', + { error: (e as Error)?.message }, + ); + } + }; + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:bootstrapped', runPositionBackfill); + } else { + void runPositionBackfill(); + } + // ── Project the permission sets of a package that arrives AFTER the boot ── // // [#21322, ADR-0086 D5 — a package's sets are seeded ON INSTALL] The diff --git a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts b/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts new file mode 100644 index 00000000000..1d6a8c381db --- /dev/null +++ b/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3, C2 stage S7] Under `single`, a position created or edited in +// Setup is written through to the environment ledger, and the positions Setup +// wrote before that are backfilled into it once — over the real showcase +// composition, through the real data door and the real metadata door. +// +// ## What was missing +// +// Measured on `origin/main` before this stage: `POST /api/v1/data/sys_position` +// answered 201 and wrote a row and nothing else — no `sys_metadata` row, and the +// security catalog read (`createSecurityCatalogReader`) did not resolve the name. +// A metadata-door save of a position, conversely, got its row only at the next +// boot. A Setup position had no registry home, so the planned read switch +// (stage S8) would stop it granting. +// +// ## What the pins hold +// +// - a Setup create: 201, its row, an environment-scoped `sys_metadata` row, +// and the catalog read resolves it; a rename moves the definition; a delete +// removes both; +// - a create named outside the metadata door's grammar is refused with the +// door's own answer (400 INVALID_REQUEST, 422 INVALID_METADATA) and keeps no +// row (seat re-rule Q1 = A); +// - a package registering a name a Setup position now holds in the +// environment ledger is refused 422 NAMESPACE_CONFLICT (Q3 = A); +// - the backfill: on a deployment upgraded from before the stage (row-only +// positions, no verdict in `sys_migration`), the next boot gives each +// row-only position its definition and records the verdict; a name the +// metadata door refuses stays row-only as the final reported class; a third +// boot changes nothing. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { createSecurityCatalogReader } from '@objectstack/core'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const SYS = { context: { isSystem: true } } as const; +/** The backfill's ledger row (`position-environment-backfill.ts`). */ +const LEDGER_ID = 'adr-0131-position-environment-backfill'; + +describe('[ADR-0131 D3] Setup positions reach the environment ledger under single (showcase)', () => { + let prevCwd: string; + let dir: string; + let db: string; + let stack: VerifyStack | undefined; + let token: string; + let ql: any; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack!.apiAs(token, method, path, body); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, code: json?.code ?? json?.error?.code ?? null, json }; + }; + const rows = async (name: string) => ql.find('sys_position', { where: { name }, limit: 10 }, SYS); + const envRows = async (name: string) => + ((await ql.find('sys_metadata', { where: { type: 'position', name }, limit: 10 }, SYS)) as any[]) + .map((r) => ({ organization_id: r.organization_id ?? null, state: r.state })); + const resolves = async (name: string) => { + const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack!.kernel.getService('metadata') }); + const entry = await reader.resolve('position', name); + return entry !== undefined && entry.name === name; + }; + const start = async () => { + stack = await bootStack(showcaseStack, { databaseFile: db }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-15196-s7-')); + db = join(dir, 'showcase.db'); + await start(); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('PRECONDITION: the boot runs the single posture', () => { + expect(stack!.tenancy().requestedPosture).toBe('single'); + }); + + it('a Setup create lands its row and an environment definition the catalog read resolves', async () => { + const created = await call('POST', '/data/sys_position', { + name: 's7_regional_lead', label: 'Regional lead', description: 'Leads a region', + }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + expect((await rows('s7_regional_lead')).length).toBe(1); + expect(await envRows('s7_regional_lead')).toEqual([{ organization_id: null, state: 'active' }]); + expect(await resolves('s7_regional_lead')).toBe(true); + }); + + it('a rename moves the definition, and a delete removes the row and the definition', async () => { + await call('POST', '/data/sys_position', { name: 's7_night_lead', label: 'Night lead' }); + const [row] = await rows('s7_night_lead'); + const renamed = await call('PATCH', `/data/sys_position/${row.id}`, { name: 's7_evening_lead' }); + expect(renamed.status, JSON.stringify(renamed.json)).toBe(200); + expect(await envRows('s7_night_lead')).toEqual([]); + expect(await envRows('s7_evening_lead')).toEqual([{ organization_id: null, state: 'active' }]); + expect({ old: await resolves('s7_night_lead'), neu: await resolves('s7_evening_lead') }).toEqual({ old: false, neu: true }); + + const deleted = await call('DELETE', `/data/sys_position/${row.id}`); + expect(deleted.status, JSON.stringify(deleted.json)).toBe(200); + expect(await rows('s7_evening_lead')).toEqual([]); + expect(await envRows('s7_evening_lead')).toEqual([]); + expect(await resolves('s7_evening_lead')).toBe(false); + }); + + it('Q1: a create named outside the metadata door grammar answers the door\'s refusal and keeps no row', async () => { + const spaced = await call('POST', '/data/sys_position', { name: 'S7 Lead', label: 'x' }); + expect({ status: spaced.status, code: spaced.code }).toEqual({ status: 400, code: 'INVALID_REQUEST' }); + const dotted = await call('POST', '/data/sys_position', { name: 's7.lead', label: 'x' }); + expect({ status: dotted.status, code: dotted.code }).toEqual({ status: 422, code: 'INVALID_METADATA' }); + expect(await rows('S7 Lead')).toEqual([]); + expect(await rows('s7.lead')).toEqual([]); + }); + + it('Q3: a package registering a name a Setup position holds is refused 422 NAMESPACE_CONFLICT', async () => { + let thrown: any = null; + try { + ql.registry.registerItem('position', { name: 's7_regional_lead', label: 'Pkg' }, 'name', 'com.dogfood.s7'); + } catch (e) { thrown = e; } + expect({ code: thrown?.code, status: thrown?.status }).toEqual({ code: 'NAMESPACE_CONFLICT', status: 422 }); + }); + + it('the backfill: an upgraded deployment\'s row-only positions get their definitions on the next boot, once', async () => { + // The upgraded shape: rows the pre-stage data door wrote (system writes are + // never translated), and no backfill verdict in the ledger. + await ql.insert('sys_position', [ + { id: 'pos_s7_legacy', name: 's7_legacy_lead', label: 'Legacy lead' }, + { id: 'pos_s7_illegal', name: 'S7 Legacy', label: 'Illegal name' }, + ], SYS); + await ql.delete('sys_migration', { where: { id: LEDGER_ID }, ...SYS }); + expect({ legacy: await resolves('s7_legacy_lead'), illegal: await resolves('S7 Legacy') }) + .toEqual({ legacy: false, illegal: false }); + + await stack!.stop(); + await start(); + expect(await envRows('s7_legacy_lead')).toEqual([{ organization_id: null, state: 'active' }]); + expect(await resolves('s7_legacy_lead')).toBe(true); + expect(await resolves('S7 Legacy'), 'a name the door refuses stays row-only, reported').toBe(false); + const ledger = (await ql.find('sys_migration', { where: { id: LEDGER_ID }, limit: 2 }, SYS)) as any[]; + expect(ledger.length).toBe(1); + expect(JSON.parse(ledger[0].details)).toMatchObject({ backfilled: 1, refusedName: 1 }); + + await stack!.stop(); + await start(); + expect(await envRows('s7_legacy_lead'), 'a third boot writes nothing').toEqual([{ organization_id: null, state: 'active' }]); + expect(((await ql.find('sys_migration', { where: { id: LEDGER_ID }, limit: 2 }, SYS)) as any[]).length).toBe(1); + }, 300_000); +}); diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 3c5115949e8..1f3bd68d61c 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -408,6 +408,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistGrantNameBackfillRecord', "The one-time grant-name backfill (ADR-0131 D4) named the grants it could, but its `sys_migration` verdict row was never written. Every log line reads clean and the names it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole grant table again and re-reports the grants it cannot name.", ], + [ + 'persistPositionBackfillRecord', + "The one-time row-only position backfill (ADR-0131 D3) gave the positions it could their environment definitions, but its `sys_migration` verdict row was never written. Every log line reads clean and the definitions it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole position table again and re-reports the positions it cannot give a definition.", + ], [ 'runWideningAlters', "The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).", diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 2d0ebcacf5b..9524289c1c5 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -330,6 +330,7 @@ const WRITE_SHAPED_CALLEES = new Map([ ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], ['persistLedgerDecisionRow', 'gate-vocabulary'], ['persistGrantNameBackfillRecord', 'gate-vocabulary'], + ['persistPositionBackfillRecord', 'gate-vocabulary'], ['recordLog', 'gate-vocabulary'], ['runWideningAlters', 'gate-vocabulary'], ['applyConfigPatch', 'gate-vocabulary'],