diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 42d371fe..4ecbd8ea 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -16,11 +16,18 @@ Secret 后续必须在同一事务中绑定到经 Automation adoption ledger 证明的全部 Legacy Task 新修订,并同步追加指向新 Task revision 的 Trigger/dispatch 修订。disabled 行逐行加密保全但不激活;非法/保留名称、异常 status/ordering、单值或总字节超限、部分组失败均进入 manual,不能静默丢行。 - 第一切片已在既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/` 落地 content-free inspection 与精确私有 subpath,没有新增 package、 + inspection 已在既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/` 落地 content-free inspection 与精确私有 subpath,没有新增 package、 dependency、daemon 或 `src` 根平铺。Edge/Standalone 行数上限分别为 10,000/100,000,disabled preservation 为 128/512;共同受 256 个 active binding、单值 16 KiB 与总 effective 64 KiB 限制。实现逐行扫描,active 在途 value 有固定内存上限,disabled 以第二遍逐项交付;inventory/row diagnostics 不含 Env name/value/row body。absent、unsupported schema、Edge over-budget、旧顺序、同名连接、disabled、保留 `QL3_`、异常状态与 - overflow 均已覆盖,Local Admin 完整测试 `95/95`。 + overflow 均已覆盖。第二切片在既有 Local Owner reconciliation application 子目录增加私有 NDJSON row plan:Edge/Standalone 文件上限为 8/32 MiB, + 单行上限 64 KiB;active/disabled candidate 使用不同 `legacy-db-env-*` 命名空间,目标 Secret 占用只记录 envelope 元数据组合摘要并强制 + `review_skip_conflict`。plan/receipt 绑定 application、独立 review authorization、sealed bundle、prepared head、row/candidate set 与文件摘要,且不含原 + Env name/value、目标 ciphertext/key ID 或 row body。Local Admin 完整测试 `96/96`,Local Owner 完整测试 `277/270/7/0`;受限沙箱中的 3 个 + loopback `EPERM` 用例已在沙箱外对应测试文件 `15/15` 通过。后端完整门 `1563/1561/2/0`,18-package clean build/test + `2924/2902/22/0`;package boundary、Cluster dependency、Edge import 与十四档 Local artifact audit 全部 compatible。远程第一切片 x64/arm64 + backend 失败的共因是新增 Local Admin 嵌套文件后结构快照仍为 47/46,现已同步为 48/47;Local Owner 同步为 176/175,workspace 仍为 18 packages、 + `singleSourcePackages=[]`、`shallowSourcePackages=[]`,且只允许 exact Secret/Config row planner 导入 inspection subpath。 D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual, 不猜字段。后续切片必须完成独立 signed decision、Secret envelope + audit + Task/Trigger/dispatch + receipt ledger 的单事务发布、prepared/apply/rollback diff --git a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md index 7414266f..f32f92ef 100644 --- a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md +++ b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md @@ -1,6 +1,6 @@ # ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定 -- 状态:Proposed(D-397 第一切片已实现 Legacy Env inspection,原子 application 尚未完成) +- 状态:Proposed(D-397 已实现 Legacy Env inspection 与私有有界 row plan,原子 application 尚未完成) - 日期:2026-08-23 - 决策:D-397 - 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490 @@ -52,7 +52,7 @@ id ASC ### 3. Edge 与 Standalone 预算 -第一切片固定: +当前 inspection 与 row-plan 切片固定: | 预算 | Edge | Standalone | | --- | ---: | ---: | @@ -64,6 +64,8 @@ id ASC 实现逐行读取,不把整张 `Envs` 或全部停用值加载到内存;active value 的在途内存由 64 KiB 合同封顶,停用值通过第二次有界扫描逐个交付。它位于既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/`,不新增 workspace package、production dependency、daemon、timer、watcher、listener、socket、数据库连接池或 `src` 根平铺文件。 +Local Owner 使用私有 NDJSON row plan 记录 header、逐行 content-free disposition、逐 candidate 目标冲突投影与 footer。Edge/Standalone plan 文件分别限制为 8 MiB/32 MiB,单行不超过 64 KiB;超过预算立即失败关闭。公开 plan/receipt 不保存原 Env name/value、目标 ciphertext、key ID 或原始 row body。active 与 disabled candidate 分别使用 `legacy-db-env-*` 和 `legacy-db-env-disabled-*` 命名空间;目标已经存在时只记录 envelope 元数据的组合摘要并进入 `review_skip_conflict`,不得读取明文、覆盖或自动改名。plan 绑定 application、独立 review authorization、sealed bundle、target projection 与 prepared head,并产生可重新计算的 row-set、candidate-set、plan-file 和 receipt digest。 + ### 4. 原子 application 必须同时完成 custody 与行为绑定 后续 D-397 application 必须在一个 `BEGIN IMMEDIATE` 事务内完成: @@ -129,6 +131,6 @@ Cluster 不得把 Legacy Env 明文写入 PostgreSQL、ConfigMap、Job command ## 当前验证与后续门禁 -D-397 第一切片已经实现并测试:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest 与 content-free diagnostics。Local Admin 完整测试为 95/95。 +D-397 当前两切片已经实现并测试:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest、content-free diagnostics、私有有界 row plan、目标 Secret 冲突、no-effect/manual outcome、plan/receipt drift 与 plan 字节预算。调用方 visitor 的预算异常保持原始类型,不再被误报为 SQLite 读取失败。Local Admin 完整测试为 96/96;Local Owner 完整测试为 277/270/7/0;后端完整门为 1563/1561/2/0,18-package clean build/test 为 2924/2902/22/0。package boundary、Cluster dependency、Edge import 与十四档 Local artifact audit 全部 compatible;workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。第一切片远程 x64/arm64 backend 失败已定位为新增嵌套 Local Admin 文件后审阅计数仍停留在 47/46,本切片已同步 Local Admin 48/47、Local Owner 176/175,并以精确文件 + subpath 规则允许 Secret/Config planner 读取 inspection;相邻文件继续被依赖隔离门拒绝。 -转为 Accepted 前仍必须完成:私有 row plan 与 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、完整 Local Owner/18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。 +转为 Accepted 前仍必须完成:独立 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。 diff --git a/docs/adr/README.md b/docs/adr/README.md index ef0d628a..3152296d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -494,7 +494,7 @@ | [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted | | [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted | | [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted | -| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 第一切片) | +| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 inspection + row plan) | ## 规则 diff --git a/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/environmentInspection.ts b/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/environmentInspection.ts index 4587c07c..493c8e44 100644 --- a/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/environmentInspection.ts +++ b/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/environmentInspection.ts @@ -232,6 +232,31 @@ function selectSql(schema: ReadonlySet): string { ORDER BY ${pinned} DESC, ${position} DESC, ${createdAt} ASC, "id" ASC`; } +function* iterateRows( + client: DatabaseSync, + schema: ReadonlySet, +): Iterable { + let iterator: Iterator>; + try { + iterator = client + .prepare(selectSql(schema)) + .iterate() + [Symbol.iterator]() as Iterator>; + } catch (error) { + throw new LegacyEnvironmentInspectionError('rows are unavailable', error); + } + for (;;) { + let next: IteratorResult>; + try { + next = iterator.next(); + } catch (error) { + throw new LegacyEnvironmentInspectionError('rows cannot be read', error); + } + if (next.done) return; + yield next.value as LegacyRow; + } +} + function reasons(row: LegacyRow): readonly LegacyEnvironmentRowReason[] { const selected: LegacyEnvironmentRowReason[] = []; if (!Number.isSafeInteger(row.id) || (row.id as number) < 1) { @@ -361,86 +386,76 @@ export function visitLegacyEnvironmentAdoption( let preservationReadyCount = 0; let activeValueBytes = 0; - try { - for (const raw of client - .prepare(selectSql(schema)) - .iterate() as Iterable) { - rowOrdinal += 1; - const digestValue = sourceDigest(raw); - const rowReasons = reasons(raw); - let disposition: LegacyEnvironmentRowDisposition = 'manual_required'; - if (rowReasons.length === 0 && raw.status === 0) { - disposition = 'active_member'; - activeRowCount += 1; - } else if (rowReasons.length === 0 && raw.status === 1) { - disposition = 'preserve_disabled'; - disabledRowCount += 1; - preservationReadyCount += 1; - } else { - manualRowCount += 1; - if (raw.status === 0) activeRowCount += 1; - if (raw.status === 1) disabledRowCount += 1; - } - const inspection = Object.freeze({ - rowOrdinal, - sourceDigest: digestValue, - disposition, - reasons: rowReasons, - }); - options.visitRow?.(inspection); - inventoryHash.update('\0row\0').update(JSON.stringify(inspection)); + for (const raw of iterateRows(client, schema)) { + rowOrdinal += 1; + const digestValue = sourceDigest(raw); + const rowReasons = reasons(raw); + let disposition: LegacyEnvironmentRowDisposition = 'manual_required'; + if (rowReasons.length === 0 && raw.status === 0) { + disposition = 'active_member'; + activeRowCount += 1; + } else if (rowReasons.length === 0 && raw.status === 1) { + disposition = 'preserve_disabled'; + disabledRowCount += 1; + preservationReadyCount += 1; + } else { + manualRowCount += 1; + if (raw.status === 0) activeRowCount += 1; + if (raw.status === 1) disabledRowCount += 1; + } + const inspection = Object.freeze({ + rowOrdinal, + sourceDigest: digestValue, + disposition, + reasons: rowReasons, + }); + options.visitRow?.(inspection); + inventoryHash.update('\0row\0').update(JSON.stringify(inspection)); - if (raw.status !== 0) continue; - if ( - typeof raw.name !== 'string' || - !ENVIRONMENT_NAME.test(raw.name) || - raw.name.startsWith('QL3_') - ) { - invalidActiveGroupDigests.add( - digest( - 'qinglong3.legacy-environment-invalid-name.v1', - scalarEvidence(raw.name), - ), - ); - continue; - } - let group = groups.get(raw.name); - if (!group) { - group = { - name: raw.name, - rowDigests: [], - values: [], - valueBytes: 0, - valid: true, - }; - groups.set(raw.name, group); - } - group.rowDigests.push(digestValue); - if (rowReasons.length > 0 || typeof raw.value !== 'string') { - group.valid = false; - group.values.length = 0; - continue; - } - const valueBytes = Buffer.byteLength(raw.value, 'utf8'); - const separatorBytes = group.values.length === 0 ? 0 : 1; - group.valueBytes += valueBytes + separatorBytes; - activeValueBytes += valueBytes + separatorBytes; - if ( - group.valueBytes > MAX_LEGACY_ENVIRONMENT_VALUE_BYTES || - activeValueBytes > MAX_LEGACY_ENVIRONMENT_EFFECTIVE_BYTES - ) { - group.valid = false; - group.values.length = 0; - } else if (group.valid) { - group.values.push(raw.value); - } + if (raw.status !== 0) continue; + if ( + typeof raw.name !== 'string' || + !ENVIRONMENT_NAME.test(raw.name) || + raw.name.startsWith('QL3_') + ) { + invalidActiveGroupDigests.add( + digest( + 'qinglong3.legacy-environment-invalid-name.v1', + scalarEvidence(raw.name), + ), + ); + continue; + } + let group = groups.get(raw.name); + if (!group) { + group = { + name: raw.name, + rowDigests: [], + values: [], + valueBytes: 0, + valid: true, + }; + groups.set(raw.name, group); + } + group.rowDigests.push(digestValue); + if (rowReasons.length > 0 || typeof raw.value !== 'string') { + group.valid = false; + group.values.length = 0; + continue; + } + const valueBytes = Buffer.byteLength(raw.value, 'utf8'); + const separatorBytes = group.values.length === 0 ? 0 : 1; + group.valueBytes += valueBytes + separatorBytes; + activeValueBytes += valueBytes + separatorBytes; + if ( + group.valueBytes > MAX_LEGACY_ENVIRONMENT_VALUE_BYTES || + activeValueBytes > MAX_LEGACY_ENVIRONMENT_EFFECTIVE_BYTES + ) { + group.valid = false; + group.values.length = 0; + } else if (group.valid) { + group.values.push(raw.value); } - } catch (error) { - if (error instanceof LegacyEnvironmentInspectionError) throw error; - throw new LegacyEnvironmentInspectionError( - 'rows cannot be inspected', - error, - ); } if (rowOrdinal !== count) { throw new LegacyEnvironmentInspectionError('row count drifted'); @@ -488,9 +503,7 @@ export function visitLegacyEnvironmentAdoption( if (!globalBudgetExceeded && preservationReadyCount > 0) { rowOrdinal = 0; - for (const raw of client - .prepare(selectSql(schema)) - .iterate() as Iterable) { + for (const raw of iterateRows(client, schema)) { rowOrdinal += 1; if (raw.status !== 1 || reasons(raw).length !== 0) continue; const digestValue = sourceDigest(raw); diff --git a/packages/ql3-local-admin/test/legacyEnvironmentInspection.test.cjs b/packages/ql3-local-admin/test/legacyEnvironmentInspection.test.cjs index 98abea5a..48d1592a 100644 --- a/packages/ql3-local-admin/test/legacyEnvironmentInspection.test.cjs +++ b/packages/ql3-local-admin/test/legacyEnvironmentInspection.test.cjs @@ -212,3 +212,34 @@ test('rejects unsupported schemas and over-budget Edge tables without scanning r overBudget.close(); } }); + +test('preserves a visitor failure instead of disguising it as a SQLite read error', () => { + const database = memoryDatabase(` + CREATE TABLE "Envs" ( + id INTEGER PRIMARY KEY, + name TEXT, + value TEXT, + status INTEGER, + position REAL, + "isPinned" INTEGER, + "createdAt" TEXT + ); + INSERT INTO "Envs" VALUES + (1, 'TOKEN', 'private-value', 0, 1, 0, '2026-01-01'); + `); + const expected = new Error('caller byte budget exceeded'); + try { + assert.throws( + () => + visitLegacyEnvironmentAdoption(database, { + profile: 'edge', + visitRow() { + throw expected; + }, + }), + (error) => error === expected, + ); + } finally { + database.close(); + } +}); diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts new file mode 100644 index 00000000..8708d571 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts @@ -0,0 +1,597 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import type { DatabaseSync } from 'node:sqlite'; + +import { + visitLegacyEnvironmentAdoption, + type LegacyEnvironmentCandidate, + type LegacyEnvironmentInventory, + type LegacyEnvironmentRowInspection, +} from '@qinglong/local-admin/reconciliation-secret-and-config-inspection'; + +import { LocalDeploymentConfigurationError } from '../../../foundation/error'; +import { cutoverDigest } from '../../../cutover/targetEvidence'; + +const HEADER_KIND = 'qinglong3-local-reconciliation-secret-config-plan-header'; +const ROW_KIND = 'qinglong3-local-reconciliation-secret-config-plan-row'; +const CANDIDATE_KIND = + 'qinglong3-local-reconciliation-secret-config-plan-candidate'; +const FOOTER_KIND = 'qinglong3-local-reconciliation-secret-config-plan-footer'; +const RECEIPT_SCHEMA = + 'qinglong3-local-reconciliation-secret-config-plan-receipt'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const MAX_LINE_BYTES = 64 * 1024; +const HASH_BUFFER_BYTES = 64 * 1024; +export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES = + 8 * 1024 * 1024; +export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES = + 32 * 1024 * 1024; + +export interface LocalReconciliationSecretConfigPlanHeader { + readonly schemaVersion: 1; + readonly kind: typeof HEADER_KIND; + readonly secretConfigId: string; + readonly applicationId: string; + readonly applicationPlanDigest: string; + readonly reviewDigest: string; + readonly reviewAuthorizationDigest: string; + readonly reviewDecisionSetDigest: string; + readonly reviewDecisionFileDigest: string; + readonly bundleDigest: string; + readonly bundleFingerprintDigest: string; + readonly profile: 'edge' | 'standalone'; + readonly projectId: string; + readonly tableDisposition: 'adopt_legacy' | 'retain_both'; + readonly preparedHeadDigest: string; + readonly preparedAtMs: number; + readonly headerDigest: string; +} + +export interface LocalReconciliationSecretConfigPlanRow { + readonly schemaVersion: 1; + readonly kind: typeof ROW_KIND; + readonly rowOrdinal: number; + readonly sourceDigest: string; + readonly disposition: LegacyEnvironmentRowInspection['disposition']; + readonly reasons: LegacyEnvironmentRowInspection['reasons']; + readonly rowPlanDigest: string; +} + +export type LocalReconciliationSecretConfigCandidateRequirement = + | 'review_apply_binding' + | 'review_preserve_disabled' + | 'review_skip_conflict'; + +export type LocalReconciliationSecretConfigTarget = + | Readonly<{ state: 'absent' }> + | Readonly<{ + state: 'occupied'; + version: number; + contentDigest: string; + }>; + +export interface LocalReconciliationSecretConfigPlanCandidate { + readonly schemaVersion: 1; + readonly kind: typeof CANDIDATE_KIND; + readonly candidateOrdinal: number; + readonly candidateType: LegacyEnvironmentCandidate['kind']; + readonly candidateDigest: string; + readonly sourceRowCount: number; + readonly sourceSetDigest: string; + readonly proposedSecretName: string; + readonly target: LocalReconciliationSecretConfigTarget; + readonly requirement: LocalReconciliationSecretConfigCandidateRequirement; + readonly candidatePlanDigest: string; +} + +export interface LocalReconciliationSecretConfigPlanSummary { + readonly tableState: LegacyEnvironmentInventory['tableState']; + readonly rowCount: number; + readonly activeRowCount: number; + readonly disabledRowCount: number; + readonly manualRowCount: number; + readonly activeGroupCount: number; + readonly bindingReadyCount: number; + readonly preservationReadyCount: number; + readonly manualGroupCount: number; + readonly eligibleBindingCount: number; + readonly eligiblePreservationCount: number; + readonly targetConflictCount: number; + readonly outcome: 'ready' | 'manual_required' | 'no_effect'; +} + +export interface LocalReconciliationSecretConfigPlanFooter + extends LocalReconciliationSecretConfigPlanSummary { + readonly schemaVersion: 1; + readonly kind: typeof FOOTER_KIND; + readonly secretConfigId: string; + readonly legacyInventoryDigest: string; + readonly rowSetDigest: string; + readonly candidateSetDigest: string; + readonly secretConfigPlanDigest: string; +} + +export interface LocalReconciliationSecretConfigPlanReceipt + extends LocalReconciliationSecretConfigPlanSummary { + readonly schema: typeof RECEIPT_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_secret_config_planned'; + readonly secretConfigId: string; + readonly applicationId: string; + readonly applicationPlanDigest: string; + readonly preparedHeadDigest: string; + readonly legacyInventoryDigest: string; + readonly rowSetDigest: string; + readonly candidateSetDigest: string; + readonly secretConfigPlanDigest: string; + readonly planFileBytes: number; + readonly planFileDigest: string; + readonly preparedAtMs: number; + readonly receiptDigest: string; +} + +export interface WriteLocalReconciliationSecretConfigPlanOptions { + readonly descriptor: number; + readonly maxBytes: number; + readonly header: Omit< + LocalReconciliationSecretConfigPlanHeader, + 'headerDigest' + >; + readonly legacy: DatabaseSync; + readonly target: DatabaseSync; +} + +function fail(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation secret config row plan ${message}`, + { cause }, + ); +} + +function exact( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be an object`); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + fail(`${label} shape is invalid`); + } + return record; +} + +function line(value: unknown): Buffer { + const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'); + if (bytes.byteLength < 3 || bytes.byteLength > MAX_LINE_BYTES + 1) { + bytes.fill(0); + fail('record exceeds its line bound'); + } + return bytes; +} + +function writeAll(descriptor: number, bytes: Buffer): void { + let offset = 0; + while (offset < bytes.byteLength) { + const written = fs.writeSync( + descriptor, + bytes, + offset, + bytes.byteLength - offset, + ); + if (written < 1) fail('write stalled'); + offset += written; + } +} + +function bytesDigest(value: unknown, length: number, label: string): string { + if (!(value instanceof Uint8Array) || value.byteLength !== length) { + fail(`target ${label} is invalid`); + } + return createHash('sha256').update(value).digest('hex'); +} + +function targetSecret( + target: DatabaseSync, + projectId: string, + secretName: string, +): LocalReconciliationSecretConfigTarget { + let row: Readonly> | undefined; + try { + row = target + .prepare( + `SELECT "version", "mutation_id" AS "mutationId", + "key_id" AS "keyId", "algorithm", "nonce", "ciphertext", + "auth_tag" AS "authTag", "created_at_ms" AS "createdAtMs" + FROM "QingLong3LocalSecretEnvelopes" + WHERE "project_id" = ? AND "secret_name" = ? + ORDER BY "version" DESC LIMIT 1`, + ) + .get(projectId, secretName) as + | Readonly> + | undefined; + } catch (error) { + return fail('target Secret projection is unavailable', error); + } + if (!row) return Object.freeze({ state: 'absent' as const }); + if ( + !Number.isSafeInteger(row.version) || + (row.version as number) < 1 || + typeof row.mutationId !== 'string' || + row.mutationId.length < 1 || + row.mutationId.length > 64 || + typeof row.keyId !== 'string' || + row.keyId.length < 1 || + row.keyId.length > 128 || + row.algorithm !== 'aes-256-gcm' || + !Number.isSafeInteger(row.createdAtMs) || + (row.createdAtMs as number) < 0 + ) { + fail('target Secret projection drifted'); + } + if ( + !(row.ciphertext instanceof Uint8Array) || + row.ciphertext.byteLength > 16 * 1024 + ) { + fail('target ciphertext is invalid'); + } + const contentDigest = cutoverDigest({ + projectId, + secretName, + version: row.version, + mutationId: row.mutationId, + keyId: row.keyId, + algorithm: row.algorithm, + nonceDigest: bytesDigest(row.nonce, 12, 'nonce'), + ciphertextDigest: bytesDigest( + row.ciphertext, + row.ciphertext.byteLength, + 'ciphertext', + ), + authTagDigest: bytesDigest(row.authTag, 16, 'auth tag'), + createdAtMs: row.createdAtMs, + }); + return Object.freeze({ + state: 'occupied' as const, + version: row.version as number, + contentDigest, + }); +} + +function secretName(candidate: Readonly): string { + const source = + candidate.kind === 'active_binding' + ? candidate.environmentName + : `${candidate.environmentName}\0${candidate.sourceDigest}`; + const suffix = createHash('sha256').update(source).digest('hex').slice(0, 32); + return candidate.kind === 'active_binding' + ? `legacy-db-env-${suffix}` + : `legacy-db-env-disabled-${suffix}`; +} + +function planRow( + value: Readonly, +): Readonly { + const payload = Object.freeze({ + schemaVersion: 1 as const, + kind: ROW_KIND, + rowOrdinal: value.rowOrdinal, + sourceDigest: value.sourceDigest, + disposition: value.disposition, + reasons: value.reasons, + }); + return Object.freeze({ ...payload, rowPlanDigest: cutoverDigest(payload) }); +} + +function planCandidate( + value: Readonly, + candidateOrdinal: number, + target: DatabaseSync, + projectId: string, +): Readonly { + const proposedSecretName = secretName(value); + const selectedTarget = targetSecret(target, projectId, proposedSecretName); + const sourceRowCount = + value.kind === 'active_binding' ? value.sourceRowCount : 1; + const sourceSetDigest = + value.kind === 'active_binding' + ? value.sourceSetDigest + : value.sourceDigest; + const requirement: LocalReconciliationSecretConfigCandidateRequirement = + selectedTarget.state === 'occupied' + ? 'review_skip_conflict' + : value.kind === 'active_binding' + ? 'review_apply_binding' + : 'review_preserve_disabled'; + const payload = Object.freeze({ + schemaVersion: 1 as const, + kind: CANDIDATE_KIND, + candidateOrdinal, + candidateType: value.kind, + candidateDigest: value.candidateDigest, + sourceRowCount, + sourceSetDigest, + proposedSecretName, + target: selectedTarget, + requirement, + }); + return Object.freeze({ + ...payload, + candidatePlanDigest: cutoverDigest(payload), + }); +} + +export function writeLocalReconciliationSecretConfigPlan( + options: Readonly, +): Readonly<{ + header: Readonly; + footer: Readonly; + fileBytes: number; + fileDigest: string; +}> { + if ( + !Number.isSafeInteger(options.maxBytes) || + options.maxBytes < MAX_LINE_BYTES + ) { + fail('byte budget is invalid'); + } + const header = Object.freeze({ + ...options.header, + headerDigest: cutoverDigest(options.header), + }); + const fileHash = createHash('sha256'); + const rowHash = createHash('sha256').update( + 'qinglong3.local-reconciliation-secret-config-row-set.v1\0', + ); + const candidateHash = createHash('sha256').update( + 'qinglong3.local-reconciliation-secret-config-candidate-set.v1\0', + ); + let fileBytes = 0; + const append = ( + value: unknown, + set: 'none' | 'row' | 'candidate' = 'none', + ): void => { + const bytes = line(value); + try { + if (fileBytes + bytes.byteLength > options.maxBytes) { + fail('exceeds profile byte budget'); + } + writeAll(options.descriptor, bytes); + fileHash.update(bytes); + if (set === 'row') rowHash.update(bytes); + if (set === 'candidate') candidateHash.update(bytes); + fileBytes += bytes.byteLength; + } finally { + bytes.fill(0); + } + }; + append(header); + let candidateOrdinal = 0; + let eligibleBindingCount = 0; + let eligiblePreservationCount = 0; + let targetConflictCount = 0; + const inventory = visitLegacyEnvironmentAdoption(options.legacy, { + profile: header.profile, + visitRow(value) { + append(planRow(value), 'row'); + }, + visitCandidate(value) { + candidateOrdinal += 1; + const candidate = planCandidate( + value, + candidateOrdinal, + options.target, + header.projectId, + ); + if (candidate.requirement === 'review_apply_binding') { + eligibleBindingCount += 1; + } else if (candidate.requirement === 'review_preserve_disabled') { + eligiblePreservationCount += 1; + } else { + targetConflictCount += 1; + } + append(candidate, 'candidate'); + }, + }); + const summary: LocalReconciliationSecretConfigPlanSummary = Object.freeze({ + tableState: inventory.tableState, + rowCount: inventory.rowCount, + activeRowCount: inventory.activeRowCount, + disabledRowCount: inventory.disabledRowCount, + manualRowCount: inventory.manualRowCount, + activeGroupCount: inventory.activeGroupCount, + bindingReadyCount: inventory.bindingReadyCount, + preservationReadyCount: inventory.preservationReadyCount, + manualGroupCount: inventory.manualGroupCount, + eligibleBindingCount, + eligiblePreservationCount, + targetConflictCount, + outcome: + inventory.tableState === 'absent' || inventory.rowCount === 0 + ? ('no_effect' as const) + : !inventory.mutationReady || targetConflictCount > 0 + ? ('manual_required' as const) + : ('ready' as const), + }); + const footerPayload = Object.freeze({ + schemaVersion: 1 as const, + kind: FOOTER_KIND, + secretConfigId: header.secretConfigId, + ...summary, + legacyInventoryDigest: inventory.inventoryDigest, + rowSetDigest: rowHash.digest('hex'), + candidateSetDigest: candidateHash.digest('hex'), + }); + const footer = Object.freeze({ + ...footerPayload, + secretConfigPlanDigest: cutoverDigest({ + headerDigest: header.headerDigest, + ...footerPayload, + }), + }); + append(footer); + return Object.freeze({ + header, + footer, + fileBytes, + fileDigest: fileHash.digest('hex'), + }); +} + +export function buildLocalReconciliationSecretConfigPlanReceipt( + header: Readonly, + footer: Readonly, + planFileBytes: number, + planFileDigest: string, +): Readonly { + const payload = Object.freeze({ + schema: RECEIPT_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_secret_config_planned' as const, + secretConfigId: header.secretConfigId, + applicationId: header.applicationId, + applicationPlanDigest: header.applicationPlanDigest, + preparedHeadDigest: header.preparedHeadDigest, + legacyInventoryDigest: footer.legacyInventoryDigest, + rowSetDigest: footer.rowSetDigest, + candidateSetDigest: footer.candidateSetDigest, + secretConfigPlanDigest: footer.secretConfigPlanDigest, + planFileBytes, + planFileDigest, + tableState: footer.tableState, + rowCount: footer.rowCount, + activeRowCount: footer.activeRowCount, + disabledRowCount: footer.disabledRowCount, + manualRowCount: footer.manualRowCount, + activeGroupCount: footer.activeGroupCount, + bindingReadyCount: footer.bindingReadyCount, + preservationReadyCount: footer.preservationReadyCount, + manualGroupCount: footer.manualGroupCount, + eligibleBindingCount: footer.eligibleBindingCount, + eligiblePreservationCount: footer.eligiblePreservationCount, + targetConflictCount: footer.targetConflictCount, + outcome: footer.outcome, + preparedAtMs: header.preparedAtMs, + }); + return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) }); +} + +export function normalizeLocalReconciliationSecretConfigPlanReceipt( + value: unknown, +): Readonly { + const receipt = exact( + value, + [ + 'activeGroupCount', + 'activeRowCount', + 'applicationId', + 'applicationPlanDigest', + 'bindingReadyCount', + 'candidateSetDigest', + 'disabledRowCount', + 'eligibleBindingCount', + 'eligiblePreservationCount', + 'legacyInventoryDigest', + 'manualGroupCount', + 'manualRowCount', + 'outcome', + 'planFileBytes', + 'planFileDigest', + 'preparedAtMs', + 'preparedHeadDigest', + 'preservationReadyCount', + 'receiptDigest', + 'rowCount', + 'rowSetDigest', + 'schema', + 'schemaVersion', + 'secretConfigId', + 'secretConfigPlanDigest', + 'state', + 'tableState', + 'targetConflictCount', + ], + 'receipt', + ); + const { receiptDigest, ...payload } = receipt; + if ( + receipt.schema !== RECEIPT_SCHEMA || + receipt.schemaVersion !== 1 || + receipt.state !== 'reconciliation_secret_config_planned' || + typeof receipt.secretConfigId !== 'string' || + !UUID_V4_PATTERN.test(receipt.secretConfigId) || + typeof receipt.applicationId !== 'string' || + !UUID_V4_PATTERN.test(receipt.applicationId) || + ![ + receipt.applicationPlanDigest, + receipt.preparedHeadDigest, + receipt.legacyInventoryDigest, + receipt.rowSetDigest, + receipt.candidateSetDigest, + receipt.secretConfigPlanDigest, + receipt.planFileDigest, + receiptDigest, + ].every( + (candidate) => + typeof candidate === 'string' && DIGEST_PATTERN.test(candidate), + ) || + ![ + receipt.rowCount, + receipt.activeRowCount, + receipt.disabledRowCount, + receipt.manualRowCount, + receipt.activeGroupCount, + receipt.bindingReadyCount, + receipt.preservationReadyCount, + receipt.manualGroupCount, + receipt.eligibleBindingCount, + receipt.eligiblePreservationCount, + receipt.targetConflictCount, + receipt.planFileBytes, + receipt.preparedAtMs, + ].every((count) => Number.isSafeInteger(count) && (count as number) >= 0) || + !['absent', 'supported', 'unsupported_schema', 'budget_exceeded'].includes( + receipt.tableState as string, + ) || + !['ready', 'manual_required', 'no_effect'].includes( + receipt.outcome as string, + ) || + cutoverDigest(payload) !== receiptDigest + ) { + fail('receipt drifted'); + } + return Object.freeze( + receipt, + ) as unknown as Readonly; +} + +export function hashLocalReconciliationSecretConfigPlanFile( + descriptor: number, + expectedBytes: number, +): string { + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES); + let offset = 0; + while (offset < expectedBytes) { + const count = fs.readSync( + descriptor, + buffer, + 0, + Math.min(buffer.byteLength, expectedBytes - offset), + offset, + ); + if (count < 1) fail('plan file read stalled'); + hash.update(buffer.subarray(0, count)); + offset += count; + } + return hash.digest('hex'); +} diff --git a/packages/ql3-local-owner-cli/test/reconciliationSecretConfigRowPlan.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationSecretConfigRowPlan.test.cjs new file mode 100644 index 00000000..bd3e76f8 --- /dev/null +++ b/packages/ql3-local-owner-cli/test/reconciliationSecretConfigRowPlan.test.cjs @@ -0,0 +1,253 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { test } = require('node:test'); + +const { + buildLocalReconciliationSecretConfigPlanReceipt, + hashLocalReconciliationSecretConfigPlanFile, + normalizeLocalReconciliationSecretConfigPlanReceipt, + writeLocalReconciliationSecretConfigPlan, +} = require('../dist/deployment/reconciliation/application/secret-and-config/rowPlan'); + +const DIGEST = 'a'.repeat(64); +const HEADER = Object.freeze({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-secret-config-plan-header', + secretConfigId: '10000000-0000-4000-8000-000000000001', + applicationId: '20000000-0000-4000-8000-000000000002', + applicationPlanDigest: DIGEST, + reviewDigest: 'b'.repeat(64), + reviewAuthorizationDigest: 'c'.repeat(64), + reviewDecisionSetDigest: 'd'.repeat(64), + reviewDecisionFileDigest: 'e'.repeat(64), + bundleDigest: 'f'.repeat(64), + bundleFingerprintDigest: '1'.repeat(64), + profile: 'edge', + projectId: 'project-1', + tableDisposition: 'adopt_legacy', + preparedHeadDigest: '2'.repeat(64), + preparedAtMs: 1_780_000_000_000, +}); + +function databases() { + const legacy = new DatabaseSync(':memory:'); + legacy.exec(` + CREATE TABLE "Envs" ( + id INTEGER PRIMARY KEY, + name TEXT, + value TEXT, + status INTEGER, + position REAL, + "isPinned" INTEGER, + "createdAt" TEXT + ); + `); + const target = new DatabaseSync(':memory:'); + target.exec(` + CREATE TABLE "QingLong3LocalSecretEnvelopes" ( + project_id TEXT NOT NULL, + secret_name TEXT NOT NULL, + version INTEGER NOT NULL, + mutation_id TEXT NOT NULL, + key_id TEXT NOT NULL, + algorithm TEXT NOT NULL, + nonce BLOB NOT NULL, + ciphertext BLOB NOT NULL, + auth_tag BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (project_id, secret_name, version) + ); + `); + return { legacy, target }; +} + +function writePlan(t, legacy, target, maxBytes = 8 * 1024 * 1024) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-plan-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const filePath = path.join(directory, 'plan.ndjson'); + const descriptor = fs.openSync(filePath, 'w+', 0o600); + let result; + try { + result = writeLocalReconciliationSecretConfigPlan({ + descriptor, + maxBytes, + header: HEADER, + legacy, + target, + }); + fs.fsyncSync(descriptor); + assert.equal( + hashLocalReconciliationSecretConfigPlanFile(descriptor, result.fileBytes), + result.fileDigest, + ); + } finally { + fs.closeSync(descriptor); + } + return { + result, + serialized: fs.readFileSync(filePath, 'utf8'), + records: fs + .readFileSync(filePath, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)), + }; +} + +test('writes a content-free Env plan with separate active and disabled candidates', (t) => { + const { legacy, target } = databases(); + t.after(() => legacy.close()); + t.after(() => target.close()); + legacy.exec(` + INSERT INTO "Envs" VALUES + (1, 'TOKEN', 'later-secret', 0, 10, 0, '2026-01-01'), + (2, 'TOKEN', 'pinned-secret', 0, 1, 1, '2026-01-02'), + (3, 'DISABLED_TOKEN', 'disabled-secret', 1, 0, 0, '2026-01-03'); + `); + + const { result, records, serialized } = writePlan(t, legacy, target); + assert.equal(result.footer.outcome, 'ready'); + assert.equal(result.footer.rowCount, 3); + assert.equal(result.footer.eligibleBindingCount, 1); + assert.equal(result.footer.eligiblePreservationCount, 1); + assert.equal(result.footer.targetConflictCount, 0); + const candidates = records.filter((record) => + record.kind.endsWith('-candidate'), + ); + assert.deepEqual( + candidates.map(({ candidateType, requirement, proposedSecretName }) => ({ + candidateType, + requirement, + prefix: proposedSecretName.replace(/[0-9a-f]{32}$/, ''), + })), + [ + { + candidateType: 'active_binding', + requirement: 'review_apply_binding', + prefix: 'legacy-db-env-', + }, + { + candidateType: 'disabled_preservation', + requirement: 'review_preserve_disabled', + prefix: 'legacy-db-env-disabled-', + }, + ], + ); + for (const privateValue of [ + 'TOKEN', + 'DISABLED_TOKEN', + 'later-secret', + 'pinned-secret', + 'disabled-secret', + ]) { + assert.equal(serialized.includes(privateValue), false); + } + + const receipt = buildLocalReconciliationSecretConfigPlanReceipt( + result.header, + result.footer, + result.fileBytes, + result.fileDigest, + ); + assert.deepEqual( + normalizeLocalReconciliationSecretConfigPlanReceipt(receipt), + receipt, + ); + assert.throws( + () => + normalizeLocalReconciliationSecretConfigPlanReceipt({ + ...receipt, + eligibleBindingCount: 2, + }), + /receipt drifted/, + ); +}); + +test('captures a target Secret collision without reading plaintext', (t) => { + const { legacy, target } = databases(); + t.after(() => legacy.close()); + t.after(() => target.close()); + legacy.exec( + `INSERT INTO "Envs" VALUES + (1, 'TOKEN', 'private-value', 0, 1, 0, '2026-01-01')`, + ); + const initial = writePlan(t, legacy, target); + const candidate = initial.records.find((record) => + record.kind.endsWith('-candidate'), + ); + target + .prepare( + `INSERT INTO "QingLong3LocalSecretEnvelopes" VALUES + (?, ?, 1, ?, ?, 'aes-256-gcm', ?, ?, ?, ?)`, + ) + .run( + HEADER.projectId, + candidate.proposedSecretName, + '30000000-0000-4000-8000-000000000003', + 'qlsk-test', + Buffer.alloc(12, 1), + Buffer.from('ciphertext'), + Buffer.alloc(16, 2), + HEADER.preparedAtMs, + ); + + const conflicted = writePlan(t, legacy, target); + assert.equal(conflicted.result.footer.outcome, 'manual_required'); + assert.equal(conflicted.result.footer.eligibleBindingCount, 0); + assert.equal(conflicted.result.footer.targetConflictCount, 1); + const occupied = conflicted.records.find((record) => + record.kind.endsWith('-candidate'), + ); + assert.equal(occupied.requirement, 'review_skip_conflict'); + assert.equal(occupied.target.state, 'occupied'); + assert.equal(occupied.target.version, 1); + assert.match(occupied.target.contentDigest, /^[0-9a-f]{64}$/); + assert.equal(conflicted.serialized.includes('private-value'), false); + assert.equal(conflicted.serialized.includes('ciphertext'), false); + assert.equal(conflicted.serialized.includes('qlsk-test'), false); +}); + +test('makes absent Envs no-effect and malformed Env manual', (t) => { + const noEnvs = new DatabaseSync(':memory:'); + const { target } = databases(); + t.after(() => noEnvs.close()); + t.after(() => target.close()); + const empty = writePlan(t, noEnvs, target); + assert.equal(empty.result.footer.outcome, 'no_effect'); + assert.equal(empty.result.footer.tableState, 'absent'); + + const { legacy, target: secondTarget } = databases(); + t.after(() => legacy.close()); + t.after(() => secondTarget.close()); + legacy.exec( + `INSERT INTO "Envs" VALUES + (1, 'QL3_RESERVED', 'private-value', 0, 1, 0, '2026-01-01')`, + ); + const manual = writePlan(t, legacy, secondTarget); + assert.equal(manual.result.footer.outcome, 'manual_required'); + assert.equal(manual.result.footer.manualRowCount, 1); + assert.equal(manual.result.footer.eligibleBindingCount, 0); + assert.equal(manual.serialized.includes('QL3_RESERVED'), false); + assert.equal(manual.serialized.includes('private-value'), false); +}); + +test('fails closed before exceeding the plan byte budget', (t) => { + const { legacy, target } = databases(); + t.after(() => legacy.close()); + t.after(() => target.close()); + legacy.exec(` + WITH RECURSIVE rows(id) AS ( + SELECT 1 UNION ALL SELECT id + 1 FROM rows WHERE id < 400 + ) + INSERT INTO "Envs" + SELECT id, 'TOKEN_' || id, 'private-value', 0, id, 0, '2026-01-01' + FROM rows + `); + assert.throws( + () => writePlan(t, legacy, target, 64 * 1024), + /exceeds profile byte budget/, + ); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 1033ea47..93cf0667 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -2058,6 +2058,12 @@ function auditSourceImports(root, packagePath, findings) { 'src/deployment/reconciliation/application/automation/rowPlan.ts' && specifier === '@qinglong/local-admin/adoption-inspection' ) && + !( + path.relative(packageDirectory, filePath) === + 'src/deployment/reconciliation/application/secret-and-config/rowPlan.ts' && + specifier === + '@qinglong/local-admin/reconciliation-secret-and-config-inspection' + ) && !( [ 'src/deployment/compose/composeApply.ts', diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index feb019f3..b7d1862d 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -2246,6 +2246,40 @@ test('confines reconciliation automation apply authority to exact coordinators', ); }); +test('confines reconciliation Secret and Config inspection to its exact row planner', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-reconciliation-secret-config-boundary-'), + ); + const secretConfigDirectory = path.join( + root, + 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config', + ); + fs.mkdirSync(secretConfigDirectory, { recursive: true }); + fs.writeFileSync( + path.join(secretConfigDirectory, 'rowPlan.ts'), + "import { inspect } from '@qinglong/local-admin/reconciliation-secret-and-config-inspection';", + ); + fs.writeFileSync( + path.join(secretConfigDirectory, 'neighbor.ts'), + "import { inspect } from '@qinglong/local-admin/reconciliation-secret-and-config-inspection';", + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const findings = []; + auditSourceImports(root, 'packages/ql3-local-owner-cli', findings); + assert.deepEqual( + findings.map(({ code, file, specifier }) => ({ code, file, specifier })), + [ + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/neighbor.ts', + specifier: + '@qinglong/local-admin/reconciliation-secret-and-config-inspection', + }, + ], + ); +}); + test('deleted Owner ceremony package names remain dependency tombstones', (t) => { const root = fixture( t, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 5e9bb195..5a7985f2 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -97,10 +97,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localAdmin.rootSourceFileRoles, }, { - sourceFiles: 47, + sourceFiles: 48, rootSourceFiles: 1, rootSourceLines: 9, - nestedSourceFiles: 46, + nestedSourceFiles: 47, rootSourceFileRoles: { 'runtime.ts': 'public_export' }, }, ); @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 175, + sourceFiles: 176, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 174, + nestedSourceFiles: 175, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );