Skip to content

Commit 107a7a9

Browse files
committed
Merge origin/main into claude/issue-23558-ceremony-dialect-resolver
Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
2 parents f9f69be + edac218 commit 107a7a9

22 files changed

Lines changed: 1086 additions & 74 deletions

‎.changeset/23430-grant-permission-set-id-retired.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ FROM → TO:
1818

1919
- `sys_user_permission_set.permission_set_id` → `permission_set`, the machine name of the set the grant holds. A seed row or data-door write that still carries `permission_set_id` is refused with `400 INVALID_FIELD`. Fix: delete the key and name the set, `{ user_id, permission_set: '<set name>', organization_id }`.
2020
- The unique index (`user_id`, `permission_set_id`, `organization_id`) and the (`permission_set_id`) index → one unique index on (`user_id`, `permission_set`, `organization_id`), declared `unique: 'organization'`: its organization key part is NULL-safe (ADR-0120 D3), so it refuses a second grant of one set to one user in one organization AND a second platform-wide grant (organization NULL), for every writer and every write path — batches, multi-row updates, concurrent writes and raw SQL included. The data doors answer `409 UNIQUE_VIOLATION` (the engine throws `DuplicateRecordError`, `DUPLICATE_RECORD`). Fix: keep the grant that exists; to change it, update that row.
21-
- The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → `os migrate organization-ownership --apply`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then.
21+
- The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → the v18 ceremony: plan it with `os migrate organization-ownership --out plan.json`, review the file it writes, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then.
2222

2323
On a 17.x database every grant names nothing until `--apply` names it, so between the first v18 boot and `--apply` no grant confers anything, the platform administrator's included.
2424

2525
What else changes:
2626

27-
- `os migrate organization-ownership --plan` reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL):
27+
- The plan run, `os migrate organization-ownership --out plan.json`, reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL):
2828
- `duplicates`: groups of named grants holding one set for one user in one organization. `--apply`'s `column-drops` step does not drop `permission_set_id` while one group remains.
2929
- `rewriteCollisions`: legacy grants whose derived name their user already holds in the same organization. `--apply`'s rewrite names none of them and blocks, naming both ids.
3030
- On a database without the `permission_set` column it counts zero and names the remedy (boot this release once), instead of refusing the plan.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(plugin-security,spec): the unnamed-grant boot line and the `ups-permission-set-id-column-retired` upgrade entry name the reviewed-plan ceremony, `--out plan.json` then `--apply --backup-taken --plan-file plan.json`, instead of a bare `--apply` the CLI refuses (ADR-0131 D10)
7+
8+
Clause-②: no
9+
10+
`os migrate organization-ownership --apply` refuses a run without `--plan-file`, the file a plan run wrote and the operator reviewed. Two remedy texts still told the operator to run `--apply` alone, which answers `plan-file-required` and writes nothing:
11+
12+
- `@objectstack/plugin-security`: the `kernel:bootstrapped` catalog reference report's error line for grants that name no permission set (`permission_set` empty).
13+
- `@objectstack/spec`: the `replacement` of the ADR-0087 entry `ups-permission-set-id-column-retired`, the row the upgrade guide prints for the retired `sys_user_permission_set.permission_set_id`.
14+
15+
Both now name the two steps: plan first, `os migrate organization-ownership --out plan.json`, review the file it writes, then `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, which executes that plan; restart afterwards. The ceremony itself is unchanged: its rewrite names each legacy grant from the retired id, and its column-drops step drops the two retired indexes and then the column. Only the text changes; the line's level, its fields and the entry's `id` are as they were.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): a production `os serve` of a host config that resolves no storage says so before any plugin starts, and refuses to boot when its auth service needs a driver
6+
7+
Clause-②: no
8+
9+
A host config (one whose `plugins` hold plugin instances) served in production with no recognized `OS_DATABASE_URL`, no `--dev` and no plugin named like a driver declares no default datasource. Nothing said so.
10+
11+
- **Before.** The boot went on and died when `AuthPlugin` started: `✗ Plugin com.objectstack.auth failed to start - rollback complete: [ObjectQL] No driver available for object 'sys_organization'`. Before that line, objectql logged `ERROR sys_metadata could NOT be read at boot`, whose fix read `check the datasource behind sys_metadata — connection, credentials, and that the table exists`, for a datasource the operator never declared. No line named `OS_DATABASE_URL` or `--dev`. `examples/app-showcase` under a bare `os serve` took this path.
12+
- **After, when the auth service runs under the `single` tenancy posture (the default) and no plugin registered a driver by the end of plugin init.** The boot is refused before any plugin starts, exit 1, with `✖ This production serve has no storage: …` and three fixes: set `OS_DATABASE_URL` (for example `file:./data/app.db`, or a `postgres://` or `mysql://` URL), mount a storage driver in the stack's `plugins` (for example `new DriverPlugin(driver)` from `@objectstack/runtime`), or run with `--dev`. Neither of the two lines above prints. Every configuration refused here failed to boot before this change.
13+
- **After, when no driver is registered and the auth service does not need one at start** (no auth composed, as under `--no-server --preset minimal`, or a walled tenancy posture): one `⚠ No storage: …` line names the same fixes, and the boot goes on as before. A plugin that registers a driver in its `start()`, such as a module's `onEnable`, still works.
14+
- **When a plugin registers a driver during init**, under any name, nothing is printed and nothing changes.
15+
16+
`os start`, a standalone (non-host) config, `--dev` and any recognized `OS_DATABASE_URL` take other paths and are unchanged.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): the inbox's referenced-record titles (`payload_display`) and the record-lock refusal's title are served only from fields the caller may read
6+
7+
Clause-②: no
8+
9+
- **Referenced-record titles follow the reader's field-level security on the referenced object.** `listRequests` and `getRequest` now pick each `payload_display` title from the referenced record as served to the reading caller: the same answer the payload snapshot and `record_title` are narrowed by (readable, and not masked for that caller), asked for the referenced object, once per object per read. A display field the reader may not read on the referenced object, or is served masked, gives that key no display value, so the snapshot's stored id stands, as it already does for a deleted referenced record. A title-eligible field the reader may read still names the record, and a reader who may read the display field gets the same title as before.
10+
- **The `RECORD_LOCKED` refusal names the record only from what the writer may read.** The per-record refusal sentence takes its title from the record as served to the refused writer, asked with the writer's own execution context. When the writer may not read the display field, or is served it masked, the sentence names the object's label alone (`This Opportunity is locked …`), as it already does for a record with no title; it never names the id. A writer who may read the field is told the title as before. The code (`RECORD_LOCKED`) and status (409) are unchanged.
11+
12+
A patch: a narrowing of served values only. Nothing is granted, and no schema, route, error code, translation key or export moves. With no field-visibility source wired, every title is served as before.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3089,6 +3089,13 @@ export default class Serve extends Command {
30893089
// discards as already-registered.
30903090
// [#15006] Same seam, same reason — see the ObjectQL gate above. The
30913091
// driver-provider duck-typing moved into it with the read it guards.
3092+
//
3093+
// [#23573] Whether this step declared NOTHING (production, no URL, no
3094+
// driver plugin by name). Only recorded here: this step cannot tell a
3095+
// boot that fails anyway from one whose own plugin registers a driver
3096+
// the name test misses, so the verdict waits for the no-storage boot
3097+
// guard at the end of Phase 1 (registered last, before runtime.start()).
3098+
let productionResolvedNoStorage = false;
30923099
if (shouldAutoRegisterStorageDriver(config, plugins)) {
30933100
const databaseUrl = process.env.OS_DATABASE_URL;
30943101
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);
@@ -3136,6 +3143,8 @@ export default class Serve extends Command {
31363143
// every other kind AND the dev-default `:memory:` store). The ONE
31373144
// provision, shared with the standalone boot below.
31383145
await provisionTelemetry(resolution.sqliteFilePath);
3146+
} else {
3147+
productionResolvedNoStorage = true;
31393148
}
31403149
} catch (e: any) {
31413150
// "declared ≠ enforced" guard (#3276-class): a selection the CLI
@@ -5108,6 +5117,68 @@ export default class Serve extends Command {
51085117
// published banner surface on every boot to report a thing an operator
51095118
// cannot act on.
51105119

5120+
// ── No-storage boot guard (#23573) ─────────────────────────────
5121+
// Step 2 recorded that this production boot declared no storage. Whether
5122+
// that is fatal is only known once every plugin's init() has run, since a
5123+
// stack plugin the name test missed may register a driver there. This is
5124+
// the LAST plugin registered, so its init() runs after every other init()
5125+
// and before any start() (resolvePluginOrder keeps insertion order; no
5126+
// plugin names this one as a dependency). It reads the one map ObjectQL
5127+
// adopts `driver.*` services from at its start(), plus any driver the
5128+
// engine already holds.
5129+
// • a driver is there ⇒ nothing to say.
5130+
// • none, and the auth service runs under the 'single' posture ⇒ refuse
5131+
// before any start(). AuthPlugin.start() creates the Default
5132+
// Organization there, which needs a driver, and no driver can arrive
5133+
// before it: AppPlugin orders ObjectQL and auth ahead of every stack
5134+
// plugin, so a driver registered in any start() comes too late.
5135+
// Without this, the boot died there with a driver error, after an
5136+
// objectql ERROR naming a datasource nobody declared.
5137+
// • none otherwise ⇒ ONE line, and the boot goes on.
5138+
// Worded as of the end of init, because a start() (a module's
5139+
// onEnable) may still register a driver and some such stacks boot.
5140+
// ⛔ Not `trackPlugin`ed, for the announcer's reason above.
5141+
if (productionResolvedNoStorage) {
5142+
// ONE list, rendered as the refusal's bullets and as the line's sentence.
5143+
const noStorageRemedies = [
5144+
'set OS_DATABASE_URL (e.g. file:./data/app.db, or a postgres:// or mysql:// URL)',
5145+
"mount a storage driver in the stack's plugins (e.g. new DriverPlugin(driver) from @objectstack/runtime)",
5146+
'run with --dev (an in-memory SQLite store, for development)',
5147+
];
5148+
await kernel.use({
5149+
name: 'com.objectstack.cli.no-storage-boot-guard',
5150+
version: '1.0.0',
5151+
init: async (ctx: any) => {
5152+
const services: Map<string, any> = ctx.getServices();
5153+
const engine = services.get('objectql');
5154+
const hasDriver = [...services.keys()].some((name) => name.startsWith('driver.'))
5155+
|| (typeof engine?.getDefaultDriverName === 'function'
5156+
&& engine.getDefaultDriverName() !== undefined);
5157+
if (hasDriver) return;
5158+
if (services.has('auth') && services.get('tenancy')?.posture === 'single') {
5159+
restoreOutput();
5160+
console.error(chalk.red(
5161+
'\n ✖ This production serve has no storage: no recognized OS_DATABASE_URL declared a\n'
5162+
+ ' default datasource, and no plugin registered a storage driver by the end of plugin\n'
5163+
+ " init. The auth service runs under the 'single' tenancy posture, and its start()\n"
5164+
+ ' creates the Default Organization, which needs a driver.\n\n'
5165+
+ ' Fix one of:\n'
5166+
+ noStorageRemedies.map((remedy) => ` • ${remedy}\n`).join(''),
5167+
));
5168+
throw new Error(
5169+
'Refusing to boot: this production serve has no storage, and its auth service needs a '
5170+
+ 'driver when it starts. Set OS_DATABASE_URL, mount a storage driver plugin, or run with --dev.',
5171+
);
5172+
}
5173+
console.warn(chalk.yellow(
5174+
' ⚠ No storage: no recognized OS_DATABASE_URL, and no plugin registered a storage driver by '
5175+
+ 'the end of plugin init, so objects have no store unless a plugin registers one when it '
5176+
+ `starts. Fix: ${noStorageRemedies[0]}; ${noStorageRemedies[1]}; or ${noStorageRemedies[2]}.`,
5177+
));
5178+
},
5179+
} as any);
5180+
}
5181+
51115182
// Boot the runtime
51125183
await runtime.start();
51135184

‎packages/cli/src/utils/organization-ownership-apply.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1980,7 +1980,7 @@ describe('ADR-0131 D10 --apply — the grant\'s retired id column (C7b, seat ans
19801980
expect(idColumn()).toEqual([]);
19811981
});
19821982

1983-
it('a second platform-wide grant (organization NULL) is a duplicate too: GROUP BY keys NULLs together, unlike the index', async () => {
1983+
it('a second platform-wide grant (organization NULL) is a duplicate too: GROUP BY keys NULLs together, as the index\'s NULL-safe organization key part does', async () => {
19841984
await onlyTemplates(`('tpl_none', NULL, 'crm.none', 'en-US', 'x', 0, 'package')`, async () => {
19851985
db.exec(`
19861986
CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT,

‎packages/cli/src/utils/organization-ownership-grants.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ export interface GrantRewritePlan {
5959
reasons: Record<UnnamedReason, string[]>;
6060
}
6161

62-
/** The unique key of a grant; a NULL organization is one key, as the write door keys it. */
62+
/**
63+
* The unique key of a grant; a NULL organization is one key, as the grant's
64+
* `unique: 'organization'` index keys it (its organization key part is
65+
* NULL-safe, ADR-0120 D3).
66+
*/
6367
export function grantKey(userId: string | null, name: string, organizationId: string | null): string {
6468
return JSON.stringify([userId, name, organizationId]);
6569
}

‎packages/cli/src/utils/organization-ownership-plan.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,9 @@ export interface MetadataConflict {
188188
/**
189189
* `sys_user_permission_set` (C7b): the populations the unique index on
190190
* (`user_id`, `permission_set`, `organization_id`) refuses, which the ceremony
191-
* reports and never deletes. A NULL organization is keyed with NULL, as the
192-
* write door keys it.
191+
* reports and never deletes. A NULL organization is keyed with NULL, as that
192+
* index keys it: it is declared `unique: 'organization'`, so its organization
193+
* key part is NULL-safe (ADR-0120 D3).
193194
*/
194195
export interface GrantKeyPlan {
195196
/** `absent` on a 17.x database this release has not booted against yet: nothing can be counted. */

0 commit comments

Comments
 (0)