Skip to content

Commit a2863de

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23910-validation-previous-insert
2 parents ee0ed28 + 68ffa5d commit a2863de

20 files changed

Lines changed: 439 additions & 93 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(client): `client.automation.getRuntimeStatus()` types its rows as the declared contract `FlowRuntimeState` (#23859)
8+
9+
**What was wrong.** The method declared its answer as `{ flows: Array<{ name, enabled, bound }>; total }`. `GET /automation/_status` serves the automation engine's `getFlowRuntimeStates()` rows unchanged, and each row is the contract `FlowRuntimeState` from `@objectstack/spec/contracts`. The SDK's type therefore hid five optional fields the wire already carries: `status`, `triggerType`, `object`, `reason` (one sentence saying why an enabled, triggered flow is not armed) and `unboundCause` (which of the closed `FLOW_UNBOUND_CAUSES` that sentence describes). A caller that read `row.reason` or `row.unboundCause` got a type error and either cast past it or concluded the field did not exist.
10+
11+
**What changes.** The return type is now `Promise<{ flows: FlowRuntimeState[]; total: number }>`. `name`, `enabled` and `bound` stay required, so existing callers compile unchanged. Read `row.unboundCause` to classify an unarmed flow, and render `row.reason` without parsing it. The request, the route and the values on the wire are unchanged.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate organization-ownership` plans a v17 database that still holds the retired `sys_scim_provider` table, listing it as fate 4 instead of refusing it (ADR-0131 D10)
6+
7+
Clause-②: no
8+
9+
`sys_scim_provider` was retired from the platform before the ceremony's inventory was taken, so the inventory had no row for it. Its ADR-0087 entry (`scim-provider-object-retired`) leaves the table in place, untouched, by ruling, so every database that booted a release from before the retirement still holds it, `organization_id` column included. The plan refused such a database with `uninventoried-platform-table` (`sys_scim_provider carries organization_id but the ADR-0131 D10 inventory gives it no fate`), and so did `--apply`, so the ceremony could not run there at all and `os dev` / `os serve` stayed refused.
10+
11+
- **Now** the inventory lists `sys_scim_provider` with fate 4 (`report`), under a new census population, `retired-before-census`. The plan reports its rows and its NULL-organization rows by id. It never attributes them, not even to the Default Organization under `single`, and never deletes them.
12+
- **NOT NULL:** the table is exempt, so the plan reports it as not receiving NOT NULL. The object is retired, no declaration anchors tenancy on the column, and a SCIM connection scoped to no organization was a legitimate row.
13+
- The plan's `inventoryDigest` changes with the new row. A plan file written by an earlier CLI no longer matches, so `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that.

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
/**
44
* The ADR-0131 D10 inventory's enumeration pin: every object of both #13564
5-
* censuses, of the platform registry as it stands, and of the cloud supplement
6-
* carries exactly one fate and a citation. A platform object that lands
5+
* censuses, of the platform registry as it stands, of the objects retired
6+
* before either census, and of the cloud supplement carries exactly one fate
7+
* and a citation. A platform object that lands
78
* without an inventory row turns this red by name — the plan would refuse its
89
* table on a real database, and this says so before any database does.
910
*/
@@ -18,6 +19,7 @@ import {
1819
PLATFORM_OBJECTS_BY_PACKAGE,
1920
type OrganizationOwnershipSupplementEntry,
2021
} from '@objectstack/spec/system';
22+
import { MIGRATIONS_BY_MAJOR } from '@objectstack/spec/migrations';
2123
import { securityDefaultPermissionSets } from '@objectstack/plugin-security';
2224
import {
2325
HostFateSupplementRefusal,
@@ -81,6 +83,15 @@ const CENSUS_2026_08_31_EXAMPLES = [
8183
'showcase_task', 'showcase_team', 'todo_task',
8284
] as const;
8385

86+
/**
87+
* Platform objects retired from the tree before the first census was
88+
* measured: neither census counted them and the registry no longer names
89+
* them, yet a database that booted an earlier release still holds the table,
90+
* `organization_id` included. Frozen: a record of what was retired, keyed to
91+
* the ADR-0087 entry each row's citation names.
92+
*/
93+
const RETIRED_BEFORE_CENSUS = { sys_scim_provider: 'scim-provider-object-retired' } as const;
94+
8495
/**
8596
* #14570 and #15086, read in: each population's table has its own fate, citing
8697
* the record that read it in (a runtime string carries no tracker number).
@@ -107,6 +118,31 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci
107118
expect(unlisted(registered)).toEqual([]);
108119
});
109120

121+
it('retired before either census: listed with fate 4, citing its ADR-0087 entry and no tracker number', () => {
122+
const retired = Object.keys(RETIRED_BEFORE_CENSUS);
123+
const counted = [...CENSUS_2026_08_31_PLATFORM_WITH_COLUMN, ...CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN] as readonly string[];
124+
const registered = new Set(Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat());
125+
expect(unlisted(retired)).toEqual([]);
126+
for (const [object, adr0087Entry] of Object.entries(RETIRED_BEFORE_CENSUS)) {
127+
// The population's premise: no census counted it and the tree no longer declares it.
128+
expect(counted, object).not.toContain(object);
129+
expect(registered.has(object), object).toBe(false);
130+
// The entry the citation names exists, and its ruling is what licenses fate 4: the table stays untouched.
131+
const semantic = Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic).find((s) => s.id === adr0087Entry);
132+
expect(semantic?.acceptanceCriteria, adr0087Entry).toContain('left in place untouched');
133+
const entry = inventoryEntryFor(object)!;
134+
expect(entry, object).toMatchObject({ census: ['retired-before-census'], fate: 'report' });
135+
expect(entry.citation, object).toContain(adr0087Entry);
136+
// A runtime string carries no tracker number.
137+
expect(entry.citation, object).not.toMatch(/#\d/);
138+
expect(entry.notNullExempt, object).toBeTruthy();
139+
}
140+
// Control: no other row claims the population.
141+
expect(
142+
ORGANIZATION_OWNERSHIP_INVENTORY.filter((e) => e.census.includes('retired-before-census')).map((e) => e.object).sort(),
143+
).toEqual([...retired].sort());
144+
});
145+
110146
it('the cloud supplement: listed as cloud-carried, fate 4, never a guessed fate', () => {
111147
expect(CLOUD_PROVIDED_OBJECT_NAMES.length).toBeGreaterThan(0);
112148
expect(unlisted(CLOUD_PROVIDED_OBJECT_NAMES)).toEqual([]);

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
* - `platform-census-main` — objects the gated census on `main` lists and
2525
* the first census did not (`sys_comment_reaction`, `sys_flow_credential`,
2626
* `sys_platform_setting`).
27+
* - `retired-before-census` — platform objects retired from the tree before
28+
* the first census was measured, so neither census counted them, whose
29+
* physical tables a database that booted an earlier release still holds,
30+
* `organization_id` included (`sys_scim_provider`, whose ADR-0087 entry
31+
* leaves the table in place untouched). Without a row the plan would
32+
* refuse every such database as carrying an uninventoried platform table.
33+
* Each row is listed with fate 4 and cites the retirement's ADR-0087 entry.
2734
* - `example-census-2026-08-31` — the non-platform objects the same census
2835
* counted as "28" (it counted the 28 FILES; they declare 33 objects, the
2936
* same 33 on `main` today).
@@ -94,6 +101,7 @@ export const ORGANIZATION_OWNERSHIP_FATES: readonly OrganizationOwnershipFate[]
94101
export type InventoryCensus =
95102
| 'platform-census-2026-08-31'
96103
| 'platform-census-main'
104+
| 'retired-before-census'
97105
| 'example-census-2026-08-31'
98106
| 'cloud-supplement'
99107
| 'host-supplement';
@@ -191,6 +199,7 @@ const EXAMPLE =
191199
const P1: readonly InventoryCensus[] = ['platform-census-2026-08-31', 'platform-census-main'];
192200
const P1_ONLY: readonly InventoryCensus[] = ['platform-census-2026-08-31'];
193201
const PMAIN: readonly InventoryCensus[] = ['platform-census-main'];
202+
const RETIRED: readonly InventoryCensus[] = ['retired-before-census'];
194203
const EX: readonly InventoryCensus[] = ['example-census-2026-08-31'];
195204

196205
/** Rows a package or the platform seeded — the D10 fate-2 mirror selector on the catalog objects. */
@@ -439,6 +448,22 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
439448
citation: `${CENSUS_1}; removed from the tree since (re-verification record 6018712820). A physical table left by an older release is listed, its NULL rows reported`,
440449
},
441450

451+
// ── Platform: retired before either census (fate 4) ──
452+
{
453+
object: 'sys_scim_provider',
454+
census: RETIRED,
455+
fate: 'report',
456+
citation:
457+
'ADR-0087 semantic entry scim-provider-object-retired (packages/spec/src/migrations/entries/semantic/' +
458+
'18.scim-provider-object-retired.ts): the rc.1-era @better-auth/scim connection object, retired before the ' +
459+
'first census with no data-migration path owed (maintainer ruling 2026-08-24), its tables in deployed ' +
460+
'databases left in place untouched by ruling. A physical table left by an older release is listed, its NULL ' +
461+
'rows reported, never attributed and never deleted',
462+
notNullExempt:
463+
'the object is retired and its table is left in place untouched, by ruling: no declaration anchors tenancy on ' +
464+
'the column, and a connection scoped to no organization was a legitimate row',
465+
},
466+
442467
// ── Platform: attribution through a parent anchor (fate 3) ──
443468
{
444469
object: 'sys_file',

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type PlanReader,
2222
type TablePlan,
2323
} from './organization-ownership-plan.js';
24+
import { ORGANIZATION_OWNERSHIP_INVENTORY } from './organization-ownership-inventory.js';
2425

2526
let dir: string;
2627
let file: string;
@@ -222,6 +223,48 @@ describe('ADR-0131 D10 preflight — the per-table plan', () => {
222223
expect(table(p, 'crm_deal').unattributable).toEqual([{ id: 'deal_2', reason: expect.stringContaining('no Default Organization') }]);
223224
});
224225

226+
it('a v17 database still holding the retired sys_scim_provider plans, listing it as fate 4', async () => {
227+
// The rc.1-era table every database that booted before the retirement still
228+
// holds, in its last declared shape. Neither census counted the object.
229+
db.exec(`
230+
CREATE TABLE sys_scim_provider (
231+
id TEXT PRIMARY KEY, provider_id TEXT, provider_key TEXT, scim_token TEXT,
232+
organization_id TEXT, user_id TEXT, created_at TEXT, updated_at TEXT
233+
);
234+
INSERT INTO sys_scim_provider VALUES
235+
('scim_1', 'okta-scim', 'org_b:okta-scim', 'hash_1', 'org_b', 'usr_1', '2026-07-01', '2026-07-01'),
236+
('scim_2', 'entra-scim', NULL, 'hash_2', NULL, NULL, '2026-07-02', '2026-07-02');
237+
`);
238+
239+
for (const posture of ['isolated', 'single']) {
240+
const scim = table(await plan(posture), 'sys_scim_provider');
241+
expect(scim, posture).toMatchObject({
242+
source: 'inventory',
243+
fate: 'report',
244+
physical: 'present',
245+
organizationColumn: 'present',
246+
rows: { total: 2, organizationNull: 1, stamped: 1 },
247+
fateCounts: { reportedRows: 1 },
248+
// Reported, never attributed — not even to the Default Organization under single.
249+
unattributable: [{ id: 'scim_2', reason: 'fate 4: reported, never guessed' }],
250+
notNull: { willReceive: false, reason: expect.stringContaining('left in place untouched') },
251+
});
252+
expect(scim.census, posture).toEqual(['retired-before-census']);
253+
expect(scim.citation, posture).toContain('scim-provider-object-retired');
254+
}
255+
256+
// Control: without its row the same database is refused, by name.
257+
const r = await refusal(
258+
buildOrganizationOwnershipPlan({
259+
reader: reader(),
260+
posture: 'isolated',
261+
database: file,
262+
inventory: ORGANIZATION_OWNERSHIP_INVENTORY.filter((e) => e.object !== 'sys_scim_provider'),
263+
}),
264+
);
265+
expect(r).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_scim_provider' });
266+
});
267+
225268
it('under single with no Default Organization, nothing is assigned and the reason says why', async () => {
226269
db.exec("UPDATE sys_organization SET slug = 'renamed' WHERE id = 'org_default'");
227270
const p = await plan('single');

‎packages/client/src/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ import type {
163163
AudienceBindingSuggestionSync,
164164
AutomationResult,
165165
DelegableScope,
166+
FlowRuntimeState,
166167
ImportObjectResult,
167168
ObjectDraft,
168169
RecordShare,
@@ -5664,8 +5665,13 @@ export class ObjectStackClient {
56645665
/**
56655666
* Runtime enable/bound state for every flow — engine state, not
56665667
* persisted metadata (backs the Studio's Automations status badges).
5668+
*
5669+
* [#23859] Each row is the declared contract `FlowRuntimeState`: the
5670+
* route serves `getFlowRuntimeStates()` rows unchanged, so the optional
5671+
* `status` / `triggerType` / `object` / `reason` / `unboundCause` reach
5672+
* the caller. A hand-typed `{ name, enabled, bound }` hid them.
56675673
*/
5668-
getRuntimeStatus: async (): Promise<{ flows: Array<{ name: string; enabled: boolean; bound: boolean }>; total: number }> => {
5674+
getRuntimeStatus: async (): Promise<{ flows: FlowRuntimeState[]; total: number }> => {
56695675
const route = this.getRoute('automation');
56705676
const res = await this.fetch(`${this.baseUrl}${route}/_status`);
56715677
return this.unwrapResponse(res);

‎packages/client/src/return-type-precision.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ import type {
8484
AnalyticsResult,
8585
AutomationResult,
8686
DelegableScope,
87+
FlowRuntimeState,
88+
FlowUnboundCause,
8789
ImportObjectResult,
8890
RecordShare,
8991
RemoteTable,
@@ -1418,6 +1420,45 @@ export function metaResetResponseDeclaresTheWireReceipt(): void {
14181420
void metaResetBody.projectionApplied;
14191421
}
14201422

1423+
/**
1424+
* [#23859] `automation.getRuntimeStatus()` hand-typed its rows as
1425+
* `{ name, enabled, bound }`. `GET /automation/_status` serves the engine's
1426+
* `getFlowRuntimeStates()` rows unchanged (that pass-through is pinned in
1427+
* `packages/runtime/src/domain-handler-registry.test.ts`), and those rows are
1428+
* the declared contract `FlowRuntimeState`. The narrower annotation hid
1429+
* `status`, `triggerType`, `object`, `reason` and `unboundCause` from every SDK
1430+
* caller, who then cast past it or concluded the fields did not exist.
1431+
*
1432+
* Type-level for the reason this file's header gives: the runtime pin in
1433+
* `meta-automation-descriptors.test.ts` is green against either annotation.
1434+
*/
1435+
type RuntimeStatusRow = Awaited<ReturnType<ObjectStackClient['automation']['getRuntimeStatus']>>['flows'][number];
1436+
1437+
export async function returnTypePrecisionPins23859(): Promise<void> {
1438+
// ── the answer IS the contract, envelope and element ──────────────────
1439+
// RED BEFORE: the hand-typed element is not `FlowRuntimeState`.
1440+
expectTypeOf(await client.automation.getRuntimeStatus()).toEqualTypeOf<{
1441+
flows: FlowRuntimeState[];
1442+
total: number;
1443+
}>();
1444+
1445+
// ── the optional fields a caller can now read, with the contract's own
1446+
// optionality and vocabulary ──────────────────────────────────────
1447+
// RED BEFORE: each indexed access is TS2339 on the hand-typed row.
1448+
expectTypeOf<RuntimeStatusRow['reason']>().toEqualTypeOf<string | undefined>();
1449+
expectTypeOf<RuntimeStatusRow['unboundCause']>().toEqualTypeOf<FlowUnboundCause | undefined>();
1450+
expectTypeOf<RuntimeStatusRow['status']>().toEqualTypeOf<string | undefined>();
1451+
expectTypeOf<RuntimeStatusRow['triggerType']>().toEqualTypeOf<string | undefined>();
1452+
expectTypeOf<RuntimeStatusRow['object']>().toEqualTypeOf<string | undefined>();
1453+
1454+
// ⚠️ GREEN IN BOTH STATES, a compatibility guard rather than evidence: the
1455+
// three fields the old annotation named stay REQUIRED, so a caller that
1456+
// read them still compiles.
1457+
expectTypeOf<RuntimeStatusRow['name']>().toEqualTypeOf<string>();
1458+
expectTypeOf<RuntimeStatusRow['enabled']>().toEqualTypeOf<boolean>();
1459+
expectTypeOf<RuntimeStatusRow['bound']>().toEqualTypeOf<boolean>();
1460+
}
1461+
14211462
describe('client SDK return-type precision (#8140)', () => {
14221463
it('exposes the type-level pins to tsc without executing a request', () => {
14231464
// The assertions above are evaluated by `tsc` under
@@ -1439,6 +1480,7 @@ describe('client SDK return-type precision (#8140)', () => {
14391480
expect(typeof returnTypePrecisionPins13023).toBe('function');
14401481
expect(typeof deleteDataResponseIsNotTheMetaResetShape).toBe('function');
14411482
expect(typeof metaResetResponseDeclaresTheWireReceipt).toBe('function');
1483+
expect(typeof returnTypePrecisionPins23859).toBe('function');
14421484
expect(typeof commitRollbackResponseIsNotTheVersionRollbackShape).toBe('function');
14431485
// `environmentIsNotTheCloudWireRow` left with its subject (#16325): the
14441486
// guard is now the `@ts-expect-error`'d import at the top of the file.

‎packages/qa/dogfood/test/authz-conformance.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1630,7 +1630,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
16301630
// [#23050 slice 6] Length 6 → 7 (`security-routes.ts`), still 0 over it.
16311631
// [#23050 slice 7] Length 7 → 8 (`discovery-routes.ts`), still 0 over it.
16321632
// [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it.
1633-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
1633+
// [#23050 slice 9] Length 9 → 10 (`search-routes.ts`), still 0 over it.
1634+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(10);
16341635
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
16351636
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
16361637
for (const probe of [MECHANICS, ROUTES]) {

0 commit comments

Comments
 (0)