From 03a904ec01eafa7d9c203df1f9a659be027fb53d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:11:06 +0000 Subject: [PATCH 1/5] wip: secret reference union --- .../src/utils/secret-reference-union.test.ts | 508 ++++++++++++++++ .../cli/src/utils/secret-reference-union.ts | 554 ++++++++++++++++++ 2 files changed, 1062 insertions(+) create mode 100644 packages/cli/src/utils/secret-reference-union.test.ts create mode 100644 packages/cli/src/utils/secret-reference-union.ts diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts new file mode 100644 index 0000000000..c29d41e912 --- /dev/null +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -0,0 +1,508 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #12663 — pins for the cross-producer `sys_secret` reference union. + * + * Everything below runs against a REAL `ObjectQL` engine, the REAL + * `LocalCryptoProvider` (test mode: ephemeral key, no disk), the REAL + * datasource secret binder and the REAL shipped settings classifier, over a + * minimal in-memory driver double. The three `sys_secret` rows the fixtures + * classify are not hand-written: each is minted by the producer that actually + * writes it, so the ref spellings under test (`sec_…`, `secret:`, + * `sys_secret:`) come from the producers rather than from this file. + * + * ## The acceptance condition these pins encode + * + * An incomplete union is strictly worse than no union, so "the union builds" is + * not the bar. The bar is that **each producer family is separately covered**: + * ablate one family's enumeration and a NAMED pin below must go red. The three + * `family N` describes are exactly those named pins, one per family, and each + * asserts a handle that ONLY that family holds. A family whose removal left + * everything green would be a family these tests do not cover. + * + * Family 2 carries an extra pin, because it is the one family that cannot be + * precomputed: its holders are every `secret`-typed field on every REGISTERED + * object, tenant-authored ones included. `registers a new secret field at + * runtime` proves the enumeration is a runtime walk of the metadata registry + * and not a fixture list — it registers an object AFTER the first union is + * built and shows the new handle arriving with no code change, with the + * before-state asserted so the pin cannot pass vacuously. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { createDatasourceSecretBinder } from '@objectstack/service-datasource'; +import { + classifySysSecretRows, + collectEncryptedSpecifierRefs, + LocalCryptoProvider, +} from '@objectstack/service-settings'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { + SECRET_REFERENCE_FAMILIES, + assertSecretReferenceUnionComplete, + buildSecretReferenceUnion, + collectDatasourceSecretReferences, + collectObjectFieldSecretReferences, + collectSecretReferenceUnion, + collectSettingsSecretReferences, + IncompleteSecretReferenceUnionError, + type SecretReferenceEngineLike, +} from './secret-reference-union.js'; + +// --------------------------------------------------------------------------- +// Minimal in-memory driver double. Read verbs only plus `create` — the engine's +// insert path is the only WRITE these fixtures use, and the union itself only +// ever reads. Nothing here declares `delete`, `update` or `findOne`. +// --------------------------------------------------------------------------- + +type Row = Record; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (object: string) => { + let s = stores.get(object); + if (!s) { s = new Map(); stores.set(object, s); } + return s; + }; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) continue; + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + // Rows leave as COPIES, like a real driver's: handing out the live object + // lets the engine's read-path mask stamp over the stored `secret:` ref, which + // would read exactly like the union failing to find it. + const copy = (r: Row): Row => ({ ...r }); + let n = 0; + /** Set when a read should throw, to drive the gap paths. */ + let throwOnFind: { object: string; error: Error } | undefined; + + const driver = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast?: Row) { + if (throwOnFind?.object === object) throw throwOnFind.error; + return Array.from(storeFor(object).values()) + .filter((r) => matches(r, ast?.where)) + .map(copy); + }, + async create(object: string, data: Row) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return copy(row); + }, + async count(object: string, ast?: Row) { + return (await this.find(object, ast)).length; + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + + return { + driver, + /** Seed a row without going through the engine (fixture setup only). */ + seed(object: string, row: Row) { storeFor(object).set(String(row.id), { ...row }); }, + rowsOf(object: string) { return Array.from(storeFor(object).values()).map(copy); }, + failReadsOf(object: string, error: Error) { throwOnFind = { object, error }; }, + }; +} + +const textField = (name: string) => ({ name, label: name, type: 'text' as const }); + +const sysSecretObject = { + name: 'sys_secret', + label: 'Secret', + fields: { + ...Object.fromEntries( + ['id', 'namespace', 'key', 'kms_key_id', 'alg', 'ciphertext', 'created_at', 'rotated_at'] + .map((f) => [f, textField(f)]), + ), + version: { name: 'version', label: 'version', type: 'number' as const }, + }, +}; + +const sysSettingObject = { + name: 'sys_setting', + label: 'Setting', + fields: Object.fromEntries( + ['id', 'namespace', 'key', 'scope', 'user_id', 'value', 'value_enc'] + .map((f) => [f, textField(f)]), + ), +}; + +const sysMetadataObject = { + name: 'sys_metadata', + label: 'Metadata', + fields: Object.fromEntries( + ['id', 'name', 'type', 'scope', 'metadata', 'state'].map((f) => [f, textField(f)]), + ), +}; + +/** + * The business object family 2 holds its handle on. `smtp` / `password` is + * chosen so `(namespace, key)` COLLIDES with a declared encrypted settings + * specifier — that collision is what the premise repro turns on, and it is not + * contrived: the engine records `namespace = `, `key = ` (`encryptSecretFields`), while settings records its own namespace and + * specifier key, and nothing keeps the two vocabularies apart. + */ +const smtpObject = { + name: 'smtp', + label: 'SMTP account', + fields: { + id: textField('id'), + host: textField('host'), + password: { name: 'password', label: 'password', type: 'secret' as const }, + }, +}; + +const settingsManifests = [ + { + namespace: 'smtp', + specifiers: [ + { key: 'password', type: 'string', encrypted: true }, + { key: 'host', type: 'string' }, + ], + }, +] as unknown as SettingsManifest[]; + +async function buildRuntime() { + const store = makeDriver(); + const engine = new ObjectQL(); + engine.registerDriver(store.driver as never, true); + await engine.init(); + engine.registry.registerObject(sysSecretObject as never); + engine.registry.registerObject(sysSettingObject as never); + engine.registry.registerObject(sysMetadataObject as never); + engine.registry.registerObject(smtpObject as never); + + const crypto = new LocalCryptoProvider({ mode: 'test' }); + engine.setCryptoProvider(crypto as never); + + // --- family 1: a settings handle, minted by the real provider ------------- + const settingsHandle = await crypto.encrypt('smtp-app-password', { + namespace: 'smtp', + key: 'password', + }); + store.seed('sys_secret', { + id: settingsHandle.id, + namespace: 'smtp', + key: 'password', + kms_key_id: settingsHandle.kmsKeyId, + alg: settingsHandle.alg, + version: settingsHandle.version, + ciphertext: settingsHandle.ciphertext, + }); + store.seed('sys_setting', { + id: 'set_1', + namespace: 'smtp', + key: 'password', + scope: 'tenant', + user_id: null, + value_enc: settingsHandle.id, + }); + + // --- family 2: the engine's own secret-field channel ---------------------- + // A REAL engine insert: `encryptSecretFields` mints the sys_secret row + // (namespace='smtp', key='password') and rewrites the column to `secret:`. + await engine.insert('smtp', { id: 'rec_1', host: 'mail.example.com', password: 'hunter2' }); + const objectFieldHandle = String(store.rowsOf('smtp')[0].password).slice('secret:'.length); + + // --- family 3: the REAL datasource credential binder ---------------------- + const binder = createDatasourceSecretBinder({ + engine: engine as never, + cryptoProvider: crypto as never, + }); + const credentialsRef = await binder.bind({ value: 'pg-password' }, { name: 'main' }); + store.seed('sys_metadata', { + id: 'meta_1', + name: 'main', + type: 'datasource', + scope: 'platform', + state: 'active', + metadata: JSON.stringify({ + name: 'main', + driver: 'postgres', + external: { credentialsRef }, + }), + }); + + return { + engine: engine as unknown as SecretReferenceEngineLike, + realEngine: engine, + store, + crypto, + settingsHandleId: settingsHandle.id, + objectFieldHandleId: objectFieldHandle, + datasourceHandleId: credentialsRef.slice('sys_secret:'.length), + }; +} + +type Runtime = Awaited>; + +const collect = (rt: Runtime, declared: Parameters[0]['declaredDatasources'] = []) => + collectSecretReferenceUnion({ engine: rt.engine, declaredDatasources: declared }); + +describe('sys_secret reference union — fixtures come from the real producers', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('mints three DISTINCT handles, one per producer family', () => { + const ids = [rt.settingsHandleId, rt.objectFieldHandleId, rt.datasourceHandleId]; + expect(new Set(ids).size).toBe(3); + // The producers' own spellings, not this file's: a settings handle is bare, + // the engine wraps it `secret:`, the binder wraps it `sys_secret:`. + for (const id of ids) expect(id.startsWith('sec_')).toBe(true); + expect(rt.store.rowsOf('smtp')[0].password).toBe(`secret:${rt.objectFieldHandleId}`); + expect(rt.store.rowsOf('sys_secret')).toHaveLength(3); + }); +}); + +/** + * Ruling 5's reproduction. This is the measurement the whole card rests on: a + * LIVE, engine-owned credential is classified `orphaned` by the SHIPPED + * settings-scoped classifier, because attribution by `(namespace, key)` is a + * name match and not ownership. + */ +describe('the premise: the shipped settings-scoped classifier calls a LIVE credential `orphaned`', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('classifies the engine-owned handle `orphaned` while a business row still references it', () => { + const report = classifySysSecretRows({ + secrets: rt.store.rowsOf('sys_secret') as never, + settingRows: rt.store.rowsOf('sys_setting') as never, + attributableTo: collectEncryptedSpecifierRefs(settingsManifests), + }); + + const engineOwned = report.rows.find((r) => r.id === rt.objectFieldHandleId); + expect(engineOwned?.verdict).toBe('orphaned'); + + // …and it is live: the business row's column still names it. + expect(rt.store.rowsOf('smtp')[0].password).toBe(`secret:${rt.objectFieldHandleId}`); + + // The datasource handle escapes only because `(datasource, main)` happens + // not to collide with a declared specifier — a name match, not ownership. + expect(report.rows.find((r) => r.id === rt.datasourceHandleId)?.verdict).toBe('unattributable'); + }); + + it('the union names every one of those handles, which is what makes deletion decidable', async () => { + const union = await collect(rt); + assertSecretReferenceUnionComplete(union); + expect(union.handleIds.has(rt.settingsHandleId)).toBe(true); + expect(union.handleIds.has(rt.objectFieldHandleId)).toBe(true); + expect(union.handleIds.has(rt.datasourceHandleId)).toBe(true); + }); +}); + +describe('family 1 — settings (`sys_setting.value_enc`)', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('names the handle held ONLY by sys_setting.value_enc, with its holder coordinates', async () => { + const union = await collect(rt); + expect(union.handleIds.has(rt.settingsHandleId)).toBe(true); + const ref = union.references.find((r) => r.handleId === rt.settingsHandleId); + expect(ref?.family).toBe('settings'); + expect(ref?.holder).toContain('sys_setting(namespace=smtp,key=password'); + // No other family holds it — so removing family 1's enumeration reds this. + expect(union.references.filter((r) => r.handleId === rt.settingsHandleId)).toHaveLength(1); + }); + + it('a LEGACY INLINE value_enc contributes no handle, and does not gap the family', async () => { + rt.store.seed('sys_setting', { + id: 'set_legacy', namespace: 'smtp', key: 'legacy', scope: 'tenant', + value_enc: 'AQIDBAUGBwgJCg==', // inline ciphertext, not a `sec_` handle + }); + const result = await collectSettingsSecretReferences(rt.engine); + expect(result.status).toBe('enumerated'); + expect(result.references.map((r) => r.handleId)).toEqual([rt.settingsHandleId]); + }); + + it('an unreadable sys_setting is a GAP, never an empty answer', async () => { + rt.store.failReadsOf('sys_setting', new Error('connection reset')); + const result = await collectSettingsSecretReferences(rt.engine); + expect(result.status).toBe('gap'); + expect(result.status === 'gap' && result.reason).toContain('connection reset'); + + const union = await collect(rt); + expect(union.complete).toBe(false); + expect(union.gaps.map((g) => g.family)).toContain('settings'); + }); +}); + +describe('family 2 — the engine secret-field channel (`secret:` on a business row)', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('names the handle held ONLY by a business row, with its holder coordinates', async () => { + const union = await collect(rt); + expect(union.handleIds.has(rt.objectFieldHandleId)).toBe(true); + const ref = union.references.find((r) => r.handleId === rt.objectFieldHandleId); + expect(ref?.family).toBe('object-field'); + expect(ref?.holder).toBe('smtp.password#rec_1'); + expect(union.references.filter((r) => r.handleId === rt.objectFieldHandleId)).toHaveLength(1); + }); + + it('registers a new secret field at runtime: a NEWLY registered object is in the union with no code change', async () => { + const before = await collect(rt); + assertSecretReferenceUnionComplete(before); + + // A tenant authors an object this file never mentions, and the engine mints + // its handle through the same producer path. + rt.realEngine.registry.registerObject({ + name: 'tenant_api_integration', + label: 'Tenant integration', + fields: { + id: textField('id'), + label: textField('label'), + api_token: { name: 'api_token', label: 'api_token', type: 'secret' as const }, + }, + } as never); + await rt.realEngine.insert('tenant_api_integration', { + id: 'rec_t1', label: 'crm', api_token: 'tok-live-42', + }); + const newHandleId = String(rt.store.rowsOf('tenant_api_integration')[0].api_token) + .slice('secret:'.length); + + // Anti-vacuity: the handle did not exist when the first union was built. + expect(before.handleIds.has(newHandleId)).toBe(false); + + const after = await collect(rt); + assertSecretReferenceUnionComplete(after); + expect(after.handleIds.has(newHandleId)).toBe(true); + expect(after.references.find((r) => r.handleId === newHandleId)?.holder) + .toBe('tenant_api_integration.api_token#rec_t1'); + }); + + it('an unreadable secret-declaring object GAPS the family rather than dropping it', async () => { + rt.store.failReadsOf('smtp', new Error('table is locked')); + const result = await collectObjectFieldSecretReferences(rt.engine); + expect(result.status).toBe('gap'); + expect(result.status === 'gap' && result.reason).toContain('table is locked'); + }); + + it('an object with no secret field is never read at all', async () => { + // `sys_setting`/`sys_metadata`/`sys_secret` declare no `secret` field, so + // family 2 must not attribute any handle to them. + const result = await collectObjectFieldSecretReferences(rt.engine); + expect(result.status).toBe('enumerated'); + expect(result.references.every((r) => r.holder.startsWith('smtp.'))).toBe(true); + }); +}); + +describe('family 3 — datasource artefacts (`external.credentialsRef`)', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('names the handle held ONLY by a datasource artefact, with its holder coordinates', async () => { + const union = await collect(rt); + expect(union.handleIds.has(rt.datasourceHandleId)).toBe(true); + const ref = union.references.find((r) => r.handleId === rt.datasourceHandleId); + expect(ref?.family).toBe('datasource'); + expect(ref?.holder).toBe('datasource(main).external.credentialsRef'); + expect(union.references.filter((r) => r.handleId === rt.datasourceHandleId)).toHaveLength(1); + }); + + it('reads a code-defined artefact the host declares, which sys_metadata never sees', async () => { + const result = collectDatasourceSecretReferences([ + { name: 'analytics', external: { credentialsRef: 'sys_secret:sec_declared_1' } }, + ]); + expect(result.references).toEqual([ + { handleId: 'sec_declared_1', family: 'datasource', holder: 'datasource(analytics).external.credentialsRef' }, + ]); + }); + + it('an INACTIVE artefact still contributes its handle', async () => { + rt.store.seed('sys_metadata', { + id: 'meta_2', name: 'retired', type: 'datasource', state: 'inactive', + metadata: JSON.stringify({ name: 'retired', external: { credentialsRef: 'sys_secret:sec_retired_1' } }), + }); + const union = await collect(rt); + expect(union.handleIds.has('sec_retired_1')).toBe(true); + }); + + it('a ref shape this producer never minted contributes nothing', () => { + const result = collectDatasourceSecretReferences([ + { name: 'weird', external: { credentialsRef: 'vault://kv/data/pg#password' } }, + ]); + expect(result.references).toEqual([]); + }); + + it('an undeclared code-defined set is a GAP; an EMPTY one is an answer', async () => { + const undeclared = await collect(rt, undefined); + expect(undeclared.complete).toBe(false); + expect(undeclared.gaps.map((g) => g.family)).toEqual(['datasource']); + expect(undeclared.gaps[0].reason).toContain('declaredDatasources'); + // The partial references survive — they are real, they just cannot complete. + expect(undeclared.handleIds.has(rt.datasourceHandleId)).toBe(true); + + const declaredEmpty = await collect(rt, []); + expect(declaredEmpty.complete).toBe(true); + }); + + it('an unparseable sys_metadata artefact is a GAP, never a skipped row', async () => { + rt.store.seed('sys_metadata', { + id: 'meta_bad', name: 'corrupt', type: 'datasource', state: 'active', metadata: '{not json', + }); + const union = await collect(rt); + expect(union.complete).toBe(false); + expect(union.gaps[0].reason).toContain('credentialsRef is unknown, not absent'); + }); +}); + +describe('completeness is the contract', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('reports one result for every member of the closed family set', async () => { + const union = await collect(rt); + expect([...SECRET_REFERENCE_FAMILIES]).toEqual(['settings', 'object-field', 'datasource']); + expect(Object.keys(union.families).sort()).toEqual([...SECRET_REFERENCE_FAMILIES].sort()); + for (const family of SECRET_REFERENCE_FAMILIES) { + expect(union.families[family].family).toBe(family); + } + }); + + it('refuses an incomplete union with the ADR-0112 envelope, naming the missing family', () => { + const union = buildSecretReferenceUnion({ + settings: { family: 'settings', status: 'enumerated', references: [] }, + 'object-field': { + family: 'object-field', status: 'gap', references: [], + reason: 'no driver resolves for `tenant_thing`', + }, + datasource: { family: 'datasource', status: 'enumerated', references: [] }, + }); + expect(union.complete).toBe(false); + + let thrown: unknown; + try { assertSecretReferenceUnionComplete(union); } catch (err) { thrown = err; } + expect(thrown).toBeInstanceOf(IncompleteSecretReferenceUnionError); + expect((thrown as IncompleteSecretReferenceUnionError).code).toBe('PRECONDITION_REQUIRED'); + expect((thrown as IncompleteSecretReferenceUnionError).status).toBe(428); + expect((thrown as Error).message).toContain('object-field'); + expect((thrown as Error).message).toContain('tenant_thing'); + }); + + it('accepts a union in which every family enumerated', () => { + const union = buildSecretReferenceUnion({ + settings: { family: 'settings', status: 'enumerated', references: [] }, + 'object-field': { family: 'object-field', status: 'enumerated', references: [] }, + datasource: { family: 'datasource', status: 'enumerated', references: [] }, + }); + expect(union.complete).toBe(true); + expect(() => assertSecretReferenceUnionComplete(union)).not.toThrow(); + }); +}); diff --git a/packages/cli/src/utils/secret-reference-union.ts b/packages/cli/src/utils/secret-reference-union.ts new file mode 100644 index 0000000000..057dc10262 --- /dev/null +++ b/packages/cli/src/utils/secret-reference-union.ts @@ -0,0 +1,554 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #12663 — the cross-producer `sys_secret` REFERENCE UNION. + * + * `sys_secret` is written by **three** privileged producers, and each keeps its + * handle in a holder column of its own (`sys_secret.object.ts` says so in the + * `id` field's own description): + * + * 1. **settings** — `SettingsService` stores a bare `sec_…` handle in + * `sys_setting.value_enc`; + * 2. **object-field** — the engine's `secret`-typed field channel stores + * `secret:` on an arbitrary business row, on every `secret` field of + * every REGISTERED object, tenant-authored ones included; + * 3. **datasource** — the credential binder stores `sys_secret:` at a + * datasource artefact's `external.credentialsRef`. + * + * This module builds the union of those three reference sets. It is the + * precondition #8103's deletion half is blocked on: the only sound deletion + * predicate is "attributable AND unreferenced by the COMPLETE union". + * + * ## Why completeness is the whole contract + * + * An INCOMPLETE union is strictly worse than no union at all. The shipped + * settings-scoped classifier (`classifySysSecretRows` in + * `@objectstack/service-settings`) is honest about its own blindness — it + * reports a row it cannot attribute as `unattributable`, never `orphaned` — but + * its attribution test is `(namespace, key)` membership in the settings + * manifests' encrypted specifiers, and that is a **name match, not ownership**. + * `sys_secret` carries no producer column, and the three producers write the + * two columns with three different meanings (settings namespace/specifier key · + * object name/field name · `datasource`/datasource name). So a LIVE credential + * from producer 2 or 3 whose `(namespace, key)` happens to collide with a + * declared encrypted specifier classifies `orphaned` today. That collision is + * reproduced against real code in this module's test file, and it is the entire + * reason this card exists. + * + * Downstream of an erroneous delete there is no recovery and no forensics: the + * settings audit trail records **digests, not handles** (`old_hash`/`new_hash` + * are content digests), so nothing can name afterwards which handle was + * destroyed. + * + * Hence the two structural devices below, neither of which is decoration: + * + * - **The family set is CLOSED and the assembler demands every member.** + * {@link buildSecretReferenceUnion} takes a `Record` keyed by + * {@link SecretReferenceFamily}, so forgetting a family is a *type* error + * rather than a union that is silently one producer short. + * - **A read that did not happen is a GAP, never an empty answer.** Every + * collector returns {@link FamilyGap} when it could not enumerate — a + * missing driver, an unregistered holder object, a throwing read, a host + * that did not declare its code-defined datasources. A gap makes the union + * `complete: false`, and {@link assertSecretReferenceUnionComplete} refuses + * it. Returning `[]` there would be the read-invention defect AGENTS.md + * names, with a credential delete on the other end of it. + * + * ## Read-only by construction, and UNSCOPED on purpose + * + * Nothing here writes, deletes or decrypts. Handle ids and holder coordinates + * are the only things collected — never ciphertext, never plaintext, mirroring + * the report-only classifier's `SecretRowSnapshot` discipline. + * + * Reads go to the **driver**, through the engine's public + * `getDriverForObject()`, for two reasons that point the same way: + * + * - the `secret:` ref only EXISTS at driver level — `maskSecretFields` + * replaces it with the mask on every `find`/`findOne`, unconditionally + * (which is why the engine's own privileged verbs read at driver level too); + * - a scoped read would silently UNDER-report. Tenant scoping, sharing, + * field-level security and soft-delete filters all subtract rows, and every + * row subtracted here is a live handle that #8103 would then read as + * unreferenced. Under-reporting is the direction that deletes live + * credentials, so the union deliberately declines every filter. + * + * The three producer surfaces are consumed **read-only, through their own + * published predicates** — `isSecretHandle` (service-settings), + * `collectSecretFields`/`parseSecretRef` (objectql), `parseCredentialsRef` + * (service-datasource). No producer needed a change to build this, and none of + * the three ref spellings is restated here: a restated prefix is a second + * de-facto contract that drifts silently, and the failure it produces is a + * handle missing from the union. + * + * ⛔ This module contains no deletion, no sweep and no classification, and must + * not grow one. The deletion command, its dry-run default, its mandatory + * pre-delete export and the rule that `unattributable` is never deleted all + * belong to #8103. + */ + +import { collectSecretFields, parseSecretRef } from '@objectstack/objectql'; +import { parseCredentialsRef } from '@objectstack/service-datasource'; +import { isSecretHandle } from '@objectstack/service-settings'; +import type { ServiceObject } from '@objectstack/spec/data'; + +/** + * The closed set of producer families that can hold a `sys_secret` reference. + * + * Closed on purpose: {@link buildSecretReferenceUnion} keys a `Record` on this + * union, so a fourth producer cannot be added to the platform and quietly + * omitted here — the assembler stops compiling until the new family has a + * collector. That is the only mechanical defence there is against the silent + * incompleteness this module exists to prevent. + */ +export const SECRET_REFERENCE_FAMILIES = ['settings', 'object-field', 'datasource'] as const; + +/** One producer/holder family. See {@link SECRET_REFERENCE_FAMILIES}. */ +export type SecretReferenceFamily = (typeof SECRET_REFERENCE_FAMILIES)[number]; + +/** + * One reference to a `sys_secret` handle, with the coordinates of the column + * that holds it. + * + * ⛔ Deliberately carries no cipher material and no plaintext — the same + * typing discipline as the report-only classifier's `SecretRowSnapshot`. The + * holder coordinates are what an operator needs and what the digest-only audit + * trail can never reconstruct after a delete. + */ +export interface SecretReference { + /** The `sys_secret.id` this reference names. */ + handleId: string; + /** Which producer family holds it. */ + family: SecretReferenceFamily; + /** + * Where the reference lives, safe to print — e.g. + * `sys_setting(namespace=mail,key=smtp_password)`, + * `smtp_account.password#rec_7`, + * `datasource(main).external.credentialsRef`. + */ + holder: string; +} + +/** A family whose references were fully enumerated. */ +export interface FamilyEnumeration { + family: SecretReferenceFamily; + status: 'enumerated'; + references: SecretReference[]; +} + +/** + * A family whose references could NOT be fully enumerated. + * + * The distinction from `references: []` is the whole safety property: "there + * are none" and "the read did not happen" are different facts, and only the + * first one is safe to feed a deletion predicate. + */ +export interface FamilyGap { + family: SecretReferenceFamily; + status: 'gap'; + /** Why enumeration could not complete, safe to print. */ + reason: string; + /** + * References gathered before the gap opened. Kept because they are real — + * a partial set still proves those handles are LIVE — but they can never + * make the union complete. + */ + references: SecretReference[]; +} + +/** Per-family outcome: either a complete enumeration or a declared gap. */ +export type FamilyResult = FamilyEnumeration | FamilyGap; + +/** The union. */ +export interface SecretReferenceUnion { + /** + * Every handle id named by any family. `true` membership means the handle is + * LIVE. Absence means "not named by what was enumerated" — which is only + * "unreferenced" when {@link SecretReferenceUnion.complete} is true. + */ + handleIds: ReadonlySet; + /** Every reference, with holder coordinates. Order follows family order. */ + references: readonly SecretReference[]; + /** Per-family outcome, one entry for every member of the closed set. */ + families: Readonly>; + /** True only when every family enumerated. */ + complete: boolean; + /** The declared gaps, empty when `complete`. */ + gaps: ReadonlyArray<{ family: SecretReferenceFamily; reason: string }>; +} + +/** + * Refusal to use an incomplete union as if it were complete. + * + * Carries the ADR-0112 pair as fields so a consumer branches on `code`/`status` + * rather than message text. `PRECONDITION_REQUIRED` (428) is the standard + * catalog's "request is missing a required precondition" — no ledger entry is + * needed, and the precondition here is literal: the complete union IS the + * precondition #8103 is blocked on. + */ +export class IncompleteSecretReferenceUnionError extends Error { + readonly code = 'PRECONDITION_REQUIRED'; + readonly status = 428; + readonly gaps: ReadonlyArray<{ family: SecretReferenceFamily; reason: string }>; + constructor(gaps: ReadonlyArray<{ family: SecretReferenceFamily; reason: string }>) { + super( + 'Refusing to treat an incomplete sys_secret reference union as complete: ' + + `${gaps.length} of ${SECRET_REFERENCE_FAMILIES.length} producer families could not be ` + + `enumerated (${gaps.map((g) => `${g.family}: ${g.reason}`).join('; ')}). ` + + 'A handle absent from a partial union is not thereby unreferenced — the missing family ' + + 'may hold it, and the sys_secret audit trail records digests, not handles, so an ' + + 'erroneous delete cannot be named afterwards. Fix the gap and re-collect.', + ); + this.name = 'IncompleteSecretReferenceUnionError'; + this.gaps = gaps; + } +} + +/** + * Throw unless every family enumerated. + * + * The one guard every consumer of this union owes. Reading + * `union.handleIds.has(id) === false` off an incomplete union and acting on it + * is the defect this whole module exists to prevent. + */ +export function assertSecretReferenceUnionComplete( + union: SecretReferenceUnion, +): asserts union is SecretReferenceUnion & { complete: true } { + if (!union.complete) throw new IncompleteSecretReferenceUnionError(union.gaps); +} + +/** + * Assemble the union from one result per family. + * + * Pure. The `Record` over the closed family union is the assembler's whole + * defence: a caller that forgets a family does not get a smaller union, it + * fails to compile. + */ +export function buildSecretReferenceUnion( + families: Record, +): SecretReferenceUnion { + const references: SecretReference[] = []; + const handleIds = new Set(); + const gaps: Array<{ family: SecretReferenceFamily; reason: string }> = []; + + for (const family of SECRET_REFERENCE_FAMILIES) { + const result = families[family]; + for (const ref of result.references) { + references.push(ref); + handleIds.add(ref.handleId); + } + if (result.status === 'gap') gaps.push({ family, reason: result.reason }); + } + + return { handleIds, references, families, complete: gaps.length === 0, gaps }; +} + +// --------------------------------------------------------------------------- +// Runtime ports — the smallest read-only slice of a booted runtime this needs. +// Structural rather than nominal so no producer package has to grow an export +// for the consumer's benefit (the contract-first split this card was fenced +// against). `ObjectQL` satisfies both today: `getConfigs()` and +// `getDriverForObject()` are public members of it. +// --------------------------------------------------------------------------- + +/** The driver read this module uses. Matches `IDataDriver.find`. */ +export interface SecretReferenceDriverLike { + find(object: string, query: Record, options?: unknown): Promise; +} + +/** The engine slice this module uses. */ +export interface SecretReferenceEngineLike { + /** Every REGISTERED object, name → schema. Family 2's enumeration source. */ + getConfigs(): Record; + /** The driver serving an object, or `undefined` when none resolves. */ + getDriverForObject(objectName: string): SecretReferenceDriverLike | undefined; +} + +/** A datasource artefact, as far as this module reads it. */ +export interface DatasourceArtefactLike { + name?: string; + external?: { credentialsRef?: unknown } | null; +} + +/** Normalise a driver result (`T[]` or `{ data: T[] }` or a single row). */ +function rowsOf(result: unknown): Array> { + if (!result) return []; + const list = Array.isArray(result) + ? result + : Array.isArray((result as { data?: unknown }).data) + ? ((result as { data: unknown[] }).data) + : [result]; + return list.filter((r): r is Record => !!r && typeof r === 'object'); +} + +const describeCause = (err: unknown): string => + err instanceof Error ? `${err.name}: ${err.message}` : String(err); + +/** + * Family 1 — handles held in `sys_setting.value_enc`. + * + * `value_enc` also carries LEGACY INLINE ciphertext on rows written before the + * Phase-3 split, and such a row references no `sys_secret` row at all. The + * discriminator is service-settings' own `isSecretHandle`, imported rather than + * restated: treating inline ciphertext as a handle would inject a phantom id + * into the union, and restating the `sec_` prefix is how the two spellings + * would drift apart later. + */ +export async function collectSettingsSecretReferences( + engine: SecretReferenceEngineLike, +): Promise { + const family: SecretReferenceFamily = 'settings'; + const references: SecretReference[] = []; + + const driver = engine.getDriverForObject('sys_setting'); + if (!driver) { + return { + family, + status: 'gap', + reason: + 'no driver resolves for `sys_setting`, so the settings producer\'s holder column could ' + + 'not be read (is the settings subsystem registered on this runtime?)', + references, + }; + } + + let result: unknown; + try { + result = await driver.find('sys_setting', { fields: ['namespace', 'key', 'scope', 'user_id', 'value_enc'] }); + } catch (err) { + return { + family, + status: 'gap', + reason: `reading \`sys_setting\` threw — ${describeCause(err)}`, + references, + }; + } + + for (const row of rowsOf(result)) { + const value = row.value_enc; + if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext + references.push({ + handleId: value, + family, + holder: `sys_setting(namespace=${String(row.namespace)},key=${String(row.key)}` + + `${row.scope == null ? '' : `,scope=${String(row.scope)}`}` + + `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`, + }); + } + + return { family, status: 'enumerated', references }; +} + +/** + * Family 2 — `secret:` refs on business rows. + * + * **Instance-specific and runtime-enumerated, by necessity.** The holders are + * every `secret`-typed field on every REGISTERED object, tenant-authored ones + * included, so no list of them can be precomputed, checked in, or written into + * a fixture. The enumeration therefore walks `engine.getConfigs()` on each + * call: an object registered a moment ago is in the union with no code change, + * which is pinned in the test file. + * + * Each object's read is guarded separately, and a failure gaps the WHOLE + * family rather than dropping that object: one unreadable object is one set of + * live handles the union would otherwise be missing, and the union has no way + * to be "mostly" complete. + */ +export async function collectObjectFieldSecretReferences( + engine: SecretReferenceEngineLike, +): Promise { + const family: SecretReferenceFamily = 'object-field'; + const references: SecretReference[] = []; + + let configs: Record; + try { + configs = engine.getConfigs() ?? {}; + } catch (err) { + return { + family, + status: 'gap', + reason: `enumerating registered objects threw — ${describeCause(err)}`, + references, + }; + } + + for (const [objectName, schema] of Object.entries(configs)) { + const secretFields = collectSecretFields(schema); + if (secretFields.length === 0) continue; + + const driver = engine.getDriverForObject(objectName); + if (!driver) { + return { + family, + status: 'gap', + reason: + `object \`${objectName}\` declares secret field(s) ${secretFields.join(', ')} but no ` + + 'driver resolves for it, so its holders could not be read', + references, + }; + } + + let result: unknown; + try { + result = await driver.find(objectName, { fields: ['id', ...secretFields] }); + } catch (err) { + return { + family, + status: 'gap', + reason: `reading secret field(s) of \`${objectName}\` threw — ${describeCause(err)}`, + references, + }; + } + + for (const row of rowsOf(result)) { + for (const field of secretFields) { + const handleId = parseSecretRef(row[field]); + if (handleId === null) continue; // unset, cleared, or already masked-out + references.push({ + handleId, + family, + holder: `${objectName}.${field}#${String(row.id)}`, + }); + } + } + } + + return { family, status: 'enumerated', references }; +} + +/** + * Family 3 — handles held at a datasource artefact's `external.credentialsRef`. + * + * Pure over the artefacts the caller supplies, because the engine cannot answer + * this one: `registerDatasourceDef` keeps only `schemaMode` and + * `external.allowWrites`, dropping `credentialsRef` on the way in. Artefacts + * come from the metadata channel instead — {@link readStoredDatasourceArtefacts} + * for the persisted ones, plus whatever the host declared in code. + */ +export function collectDatasourceSecretReferences( + artefacts: readonly DatasourceArtefactLike[], +): FamilyResult { + const family: SecretReferenceFamily = 'datasource'; + const references: SecretReference[] = []; + + for (const artefact of artefacts) { + const ref = artefact?.external?.credentialsRef; + if (typeof ref !== 'string' || ref === '') continue; + const handleId = parseCredentialsRef(ref); + if (handleId === undefined) continue; // a ref shape this producer did not mint + references.push({ + handleId, + family, + holder: `datasource(${String(artefact.name)}).external.credentialsRef`, + }); + } + + return { family, status: 'enumerated', references }; +} + +/** + * Read the PERSISTED datasource artefacts — `sys_metadata` rows of type + * `datasource`, the durable store the admin plugin writes and rehydrates from. + * + * Read at driver level and deliberately UNFILTERED by state: an artefact + * carried as `inactive` still holds its `credentialsRef`, and a handle held by + * a disabled datasource is a handle that must not be collected. Corrupt JSON + * is surfaced as a gap rather than skipped — a row this cannot parse is a row + * whose `credentialsRef` is unknown, not absent. + */ +export async function readStoredDatasourceArtefacts( + engine: SecretReferenceEngineLike, +): Promise<{ artefacts: DatasourceArtefactLike[]; gap?: string }> { + const artefacts: DatasourceArtefactLike[] = []; + + const driver = engine.getDriverForObject('sys_metadata'); + if (!driver) { + return { + artefacts, + gap: 'no driver resolves for `sys_metadata`, so persisted datasource artefacts could not be read', + }; + } + + let result: unknown; + try { + result = await driver.find('sys_metadata', { where: { type: 'datasource' } }); + } catch (err) { + return { artefacts, gap: `reading \`sys_metadata\` threw — ${describeCause(err)}` }; + } + + for (const row of rowsOf(result)) { + if (row.type !== 'datasource') continue; // a driver that ignored `where` + const raw = row.metadata; + try { + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (parsed && typeof parsed === 'object') artefacts.push(parsed as DatasourceArtefactLike); + } catch (err) { + return { + artefacts, + gap: + `sys_metadata row \`${String(row.id)}\` (name=${String(row.name)}) does not parse as a ` + + `datasource artefact — ${describeCause(err)}; its credentialsRef is unknown, not absent`, + }; + } + } + + return { artefacts }; +} + +/** Input to {@link collectSecretReferenceUnion}. */ +export interface SecretReferenceUnionInput { + /** A booted runtime's ObjectQL engine. */ + engine: SecretReferenceEngineLike; + /** + * The datasource artefacts the HOST declared in code (`defineStack`, an app + * manifest, a config file) — the ones that never reach `sys_metadata`. + * + * **Required, and `undefined` is not the same as `[]`.** The engine drops + * `credentialsRef` from the definitions it keeps, so this module cannot + * discover code-defined artefacts and will not pretend to: `undefined` says + * "nobody answered", which opens a declared gap, while `[]` is the host + * stating it has none. Collapsing the two would be exactly the silent + * incompleteness this union exists to refuse. + */ + declaredDatasources: readonly DatasourceArtefactLike[] | undefined; +} + +/** + * Collect the complete union from a booted runtime. + * + * Runs all three families. Each is a separate exported collector so a consumer + * can enumerate one family alone — and so that ablating any single family's + * enumeration is a real, isolated experiment rather than a rewrite. + */ +export async function collectSecretReferenceUnion( + input: SecretReferenceUnionInput, +): Promise { + const { engine, declaredDatasources } = input; + + const settings = await collectSettingsSecretReferences(engine); + const objectField = await collectObjectFieldSecretReferences(engine); + + const stored = await readStoredDatasourceArtefacts(engine); + const datasource = collectDatasourceSecretReferences([ + ...stored.artefacts, + ...(declaredDatasources ?? []), + ]); + + const datasourceGaps: string[] = []; + if (stored.gap) datasourceGaps.push(stored.gap); + if (declaredDatasources === undefined) { + datasourceGaps.push( + 'the host did not declare its code-defined datasource artefacts (`declaredDatasources` was ' + + 'undefined), and the engine drops `external.credentialsRef` from the definitions it keeps, ' + + 'so a code-defined credential could not be seen at all — pass `[]` to state there are none', + ); + } + + return buildSecretReferenceUnion({ + settings, + 'object-field': objectField, + datasource: + datasourceGaps.length === 0 + ? datasource + : { family: 'datasource', status: 'gap', reason: datasourceGaps.join('; '), references: datasource.references }, + }); +} From a066e5496a5e2cfb746485e319b83885d5a2cce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:13:53 +0000 Subject: [PATCH 2/5] wip: fix default-parameter trap in the undeclared-datasources pin --- packages/cli/src/utils/secret-reference-union.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts index c29d41e912..3df7d75ced 100644 --- a/packages/cli/src/utils/secret-reference-union.test.ts +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -442,7 +442,12 @@ describe('family 3 — datasource artefacts (`external.credentialsRef`)', () => }); it('an undeclared code-defined set is a GAP; an EMPTY one is an answer', async () => { - const undeclared = await collect(rt, undefined); + // Called directly, not through `collect` — a default parameter would swallow + // the `undefined` this case is about (measured: it did, first run). + const undeclared = await collectSecretReferenceUnion({ + engine: rt.engine, + declaredDatasources: undefined, + }); expect(undeclared.complete).toBe(false); expect(undeclared.gaps.map((g) => g.family)).toEqual(['datasource']); expect(undeclared.gaps[0].reason).toContain('declaredDatasources'); From 422978fcf547ca49d7107f7e8ebdc65a0659ce20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:20:05 +0000 Subject: [PATCH 3/5] wip: mask positive control + changeset --- ...s-secret-cross-producer-reference-union.md | 52 +++++++++++++++++++ .../src/utils/secret-reference-union.test.ts | 15 +++++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .changeset/sys-secret-cross-producer-reference-union.md diff --git a/.changeset/sys-secret-cross-producer-reference-union.md b/.changeset/sys-secret-cross-producer-reference-union.md new file mode 100644 index 0000000000..1ea2b8b221 --- /dev/null +++ b/.changeset/sys-secret-cross-producer-reference-union.md @@ -0,0 +1,52 @@ +--- +"@objectstack/cli": patch +--- + +feat(cli): build the cross-producer `sys_secret` reference union — the primitive a safe orphan sweep needs (#12663) + +`sys_secret` has three privileged producers, each holding its handle in a +column of its own: `SettingsService` (a bare `sec_…` in +`sys_setting.value_enc`), the engine's `secret`-typed field channel +(`secret:` on an arbitrary business row) and the datasource credential +binder (`sys_secret:` at a datasource artefact's +`external.credentialsRef`). Nothing enumerated all three, so the only sound +deletion predicate — "attributable AND unreferenced by the COMPLETE union" — +had no union to stand on. `packages/cli/src/utils/secret-reference-union.ts` +is that union, read-only across all three surfaces. + +Why the shipped report-only classifier is not enough, reproduced against real +code in the new test file: `classifySysSecretRows` attributes a row by +`(namespace, key)` membership in the settings manifests' encrypted specifiers, +and that is a **name match, not ownership** — `sys_secret` carries no producer +column and the three producers write those two columns with three different +meanings. A live, engine-owned credential on an object named like a settings +namespace, with a field named like a specifier key, classifies `orphaned` +today. There is no recovery from acting on that: the audit trail records +digests, not handles, so nothing can name the destroyed handle afterwards. + +Completeness is therefore the whole contract, and it is structural rather than +asserted: + +- the family set is closed and the assembler takes a `Record` over it, so + omitting a producer is a type error, not a smaller union; +- a read that did not happen is a declared **gap**, never an empty answer — a + missing driver, a throwing read, an unparseable artefact, or a host that did + not declare its code-defined datasources all make the union + `complete: false`, and `assertSecretReferenceUnionComplete` refuses it with + the ADR-0112 pair `PRECONDITION_REQUIRED` / 428; +- family 2 is enumerated from the metadata registry on every call, because its + holders are every `secret`-typed field on every registered object, + tenant-authored ones included — a newly registered secret field is in the + union with no code change. + +Reads go to the driver through the engine's public `getDriverForObject()`: the +`secret:` ref only exists at that level (the read path masks it +unconditionally), and any scoped read would silently under-report — the +direction that deletes live credentials. + +`patch` rather than `minor`: this adds no surface to the package's entry +barrel and no command or flag. It is an internal primitive whose named reader +is the deletion half of #8103, in this same package; publishing it as an +external API is a separate decision with its own changeset. Read-only by +construction — nothing here writes, deletes or decrypts, and it contains no +deletion command, dry-run or sweep. diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts index 3df7d75ced..2b65bbe4cd 100644 --- a/packages/cli/src/utils/secret-reference-union.test.ts +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -30,7 +30,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; +import { ObjectQL, SECRET_MASK } from '@objectstack/objectql'; import { createDatasourceSecretBinder } from '@objectstack/service-datasource'; import { classifySysSecretRows, @@ -394,6 +394,19 @@ describe('family 2 — the engine secret-field channel (`secret:` on a busin expect(result.status === 'gap' && result.reason).toContain('table is locked'); }); + it('the driver-level read is load-bearing: the generic engine read cannot see the ref at all', async () => { + // Positive control on the module's central design decision. `maskSecretFields` + // replaces the stored `secret:` with the mask on every find/findOne, + // unconditionally — so a union built on `engine.find` would enumerate the + // family and collect NOTHING, silently, on a real runtime. + const throughEngine = await rt.realEngine.find('smtp', {}) as Array>; + expect(throughEngine[0].password).toBe(SECRET_MASK); + expect(String(throughEngine[0].password).startsWith('secret:')).toBe(false); + + const union = await collect(rt); + expect(union.handleIds.has(rt.objectFieldHandleId)).toBe(true); + }); + it('an object with no secret field is never read at all', async () => { // `sys_setting`/`sys_metadata`/`sys_secret` declare no `secret` field, so // family 2 must not attribute any handle to them. From a9a175ab206012b8cf200c7799b740bb650d94ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:35:50 +0000 Subject: [PATCH 4/5] wip: registerObject packageId for the built declaration --- .../cli/src/utils/secret-reference-union.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts index 2b65bbe4cd..2737339663 100644 --- a/packages/cli/src/utils/secret-reference-union.test.ts +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -119,6 +119,8 @@ function makeDriver() { }; } +const TEST_PACKAGE_ID = 'com.objectstack.test.12663'; + const textField = (name: string) => ({ name, label: name, type: 'text' as const }); const sysSecretObject = { @@ -183,10 +185,12 @@ async function buildRuntime() { const engine = new ObjectQL(); engine.registerDriver(store.driver as never, true); await engine.init(); - engine.registry.registerObject(sysSecretObject as never); - engine.registry.registerObject(sysSettingObject as never); - engine.registry.registerObject(sysMetadataObject as never); - engine.registry.registerObject(smtpObject as never); + // `packageId` is required by the built declaration this package resolves + // (`registerObject(schema, packageId, …)`); the engine's own in-package tests + // reach a source signature that defaults it. + for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, smtpObject]) { + engine.registry.registerObject(object as never, TEST_PACKAGE_ID); + } const crypto = new LocalCryptoProvider({ mode: 'test' }); engine.setCryptoProvider(crypto as never); @@ -370,7 +374,7 @@ describe('family 2 — the engine secret-field channel (`secret:` on a busin label: textField('label'), api_token: { name: 'api_token', label: 'api_token', type: 'secret' as const }, }, - } as never); + } as never, TEST_PACKAGE_ID); await rt.realEngine.insert('tenant_api_integration', { id: 'rec_t1', label: 'crm', api_token: 'tok-live-42', }); From 1f2acaeb3b3e2198e1535fb50cf022062b760179 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:59:41 +0000 Subject: [PATCH 5/5] wip: find double holds the caller's bound; no-limit note on the union reads --- .../cli/src/utils/secret-reference-union.test.ts | 12 +++++++++--- packages/cli/src/utils/secret-reference-union.ts | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts index 2737339663..e628d95a42 100644 --- a/packages/cli/src/utils/secret-reference-union.test.ts +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -91,9 +91,15 @@ function makeDriver() { async execute() { return null; }, async find(object: string, ast?: Row) { if (throwOnFind?.object === object) throw throwOnFind.error; - return Array.from(storeFor(object).values()) - .filter((r) => matches(r, ast?.where)) - .map(copy); + const matched = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // Hold the caller's bound by PRESENCE, AFTER the filter and BEFORE the + // row-touching copy — the shape `check:objectql-double-limit` requires of + // a `find` double, in that order (a copy applied first reads rows outside + // the bound). The union itself never passes a bound (see the module + // header); this arm keeps the double honest rather than serving a call + // site here. + const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; + return page.map(copy); }, async create(object: string, data: Row) { n += 1; diff --git a/packages/cli/src/utils/secret-reference-union.ts b/packages/cli/src/utils/secret-reference-union.ts index 057dc10262..f18697ad52 100644 --- a/packages/cli/src/utils/secret-reference-union.ts +++ b/packages/cli/src/utils/secret-reference-union.ts @@ -72,6 +72,11 @@ * unreferenced. Under-reporting is the direction that deletes live * credentials, so the union deliberately declines every filter. * + * The reads also carry **no `limit`**, deliberately. Every driver in this tree + * bounds a result only when `query.limit` is present, so an unbounded read + * returns the whole holder set; a page size introduced here would truncate the + * union silently on exactly the large tables where an orphan sweep matters. + * * The three producer surfaces are consumed **read-only, through their own * published predicates** — `isSecretHandle` (service-settings), * `collectSecretFields`/`parseSecretRef` (objectql), `parseCredentialsRef`