diff --git a/docs/current/PRODUCT.md b/docs/current/PRODUCT.md index 633f4a59a..e7d6841cd 100644 --- a/docs/current/PRODUCT.md +++ b/docs/current/PRODUCT.md @@ -35,7 +35,7 @@ REST and any future server functions are *intended as* sibling adapters over the ## Holds - Frontend work is **HOLD** until ADR-0030 gates are freshly green, `Layer::Ui` is accepted, contracts and an SSR shell are stable, and real E2E evidence exists. -- Company, Person, Employment, and PayRun projection fan-out is **HOLD** until each has an explicit owning port and a proven single-writer boundary. +- Company, Person, Employment, and PayRun projection fan-out is **HOLD** until each has an explicit owning port and a proven single-writer boundary. Whether that condition currently holds is decided by `node tools/ci/hold-release-conditions.mjs`, which reports each object's owning crate, owned tables, the port suite proving its boundary, and whether that suite runs in the workflow PostgreSQL job. Releasing the hold remains a separate authority decision; the command reports evidence and does not confer it. - Live production, DNS, TLS, secret, exposure, payment, credential-reset, and compliance-claim actions are **HOLD** without separate authority and evidence. - Korea compliance conclusions remain **HOLD** pending qualified authority. - The grandfathered OCI Ampere A1 instance (4 OCPU / 24 GB) must **never** be destroyed, terminated, resized, or reprovisioned; re-creation permanently loses the reserved capacity. diff --git a/docs/documentation-index.json b/docs/documentation-index.json index ccd51d930..9f16a4a06 100644 --- a/docs/documentation-index.json +++ b/docs/documentation-index.json @@ -740,7 +740,7 @@ "status": "active", "replacement": null, "retention": "retain", - "blob_sha": "633f4a59a5fb9a38d1956d017c0887726726efa8", + "blob_sha": "e7d6841cd8f8e00c8e904c95181fe4a70ffb67a5", "archive_tag": null }, { diff --git a/docs/documentation-manifest.seed.json b/docs/documentation-manifest.seed.json index c560768c2..fb8d67269 100644 --- a/docs/documentation-manifest.seed.json +++ b/docs/documentation-manifest.seed.json @@ -656,7 +656,7 @@ "status": "active", "replacement": null, "retention": "retain", - "blob_sha": "633f4a59a5fb9a38d1956d017c0887726726efa8", + "blob_sha": "e7d6841cd8f8e00c8e904c95181fe4a70ffb67a5", "archive_tag": null }, { diff --git a/tools/ci/gate-sweep.json b/tools/ci/gate-sweep.json index 13816907e..ab890f283 100644 --- a/tools/ci/gate-sweep.json +++ b/tools/ci/gate-sweep.json @@ -7,7 +7,7 @@ }, { "id": "ci-tools-suites", - "run": "node --test tools/ci/cargo-test-runner.test.mjs tools/ci/work-order-request-no-seed.test.mjs tools/ci/check-product-buck-residual.test.mjs tools/ci/check-nextest-config.test.mjs tools/ci/check-postgres-cargo-map.test.mjs tools/ci/ingest-soft-reds.test.mjs scripts/local-admission.test.mjs tools/ci/assess-tip-contention.test.mjs tools/ci/check-mjs-dark-suites.test.mjs tools/ci/classify-ci-failure.test.mjs tools/ci/postgres-timings.test.mjs tools/ci/cargo-needs-postgres-args.test.mjs tools/ci/check-nightly-workflow.test.mjs tools/ci/nextest-filterset.test.mjs tools/ci/postgres-partition.test.mjs" + "run": "node --test tools/ci/cargo-test-runner.test.mjs tools/ci/work-order-request-no-seed.test.mjs tools/ci/check-product-buck-residual.test.mjs tools/ci/check-nextest-config.test.mjs tools/ci/check-postgres-cargo-map.test.mjs tools/ci/ingest-soft-reds.test.mjs scripts/local-admission.test.mjs tools/ci/assess-tip-contention.test.mjs tools/ci/check-mjs-dark-suites.test.mjs tools/ci/classify-ci-failure.test.mjs tools/ci/postgres-timings.test.mjs tools/ci/cargo-needs-postgres-args.test.mjs tools/ci/check-nightly-workflow.test.mjs tools/ci/nextest-filterset.test.mjs tools/ci/postgres-partition.test.mjs tools/ci/hold-release-conditions.test.mjs" }, { "id": "check-product-buck-residual", @@ -29,6 +29,10 @@ "id": "check-nightly-workflow", "run": "node tools/ci/check-nightly-workflow.mjs" }, + { + "id": "check-hold-release-conditions", + "run": "node tools/ci/hold-release-conditions.mjs" + }, { "id": "check-foundation-gates-tests", "run": "node --test scripts/check-foundation-gates.test.mjs" diff --git a/tools/ci/hold-release-conditions.mjs b/tools/ci/hold-release-conditions.mjs new file mode 100644 index 000000000..02314f91b --- /dev/null +++ b/tools/ci/hold-release-conditions.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Mechanically evaluate the projection fan-out HOLD in docs/current/PRODUCT.md. + * + * The hold reads: + * + * "Company, Person, Employment, and PayRun projection fan-out is HOLD until + * each has an explicit owning port and a proven single-writer boundary." + * + * Every clause of that is already enforced somewhere, and enforced WELL: + * `canonical_contract.rs` proves each key names an owner crate and at least one + * table it alone may write, `gate_detects_violation.rs` derives its owned-table + * set from `ObjectKey::ALL` and proves the static gate catches a second writer + * (including deliberately misspelled evasions), and + * `topology.canonical_enforcement` refuses at runtime to claim enforcement over + * zero tables. This file adds NO enforcement. Duplicating any of that would be + * the mistake, not the fix. + * + * What is missing is COMPOSITION. The hold's release condition is prose, and its + * evidence is spread across six files whose naming conventions disagree: the + * registry spells a key `PayRun`, the suite on disk is + * `pay_run_port_as_runtime_role.rs`, and the CI map calls the same suite + * `payroll-adapter-postgres-pay-run-port-as-runtime-role-pg`. Deciding whether + * the hold may lift therefore means re-deriving three transforms by hand, and + * that derivation is genuinely error-prone -- a `find` for the suite under the + * canonical adapter misses PayRun entirely, because PayRun's port suite lives in + * the payroll adapter with a different owner. Getting that wrong reads as "PayRun + * has almost no coverage" when it has seventeen tests. + * + * So: one command, three transforms applied consistently, evidence cited. It + * fails if any leg of the condition stops being true, which also means the hold + * text cannot quietly drift away from the registry it describes. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +/** `PayRun` -> `pay_run`, matching the suite filenames on disk. */ +export function snakeCase(key) { + return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); +} + +/** `pay_run_port_as_runtime_role` -> `pay-run-port-as-runtime-role`, the CI map spelling. */ +export function kebabCase(snake) { + return snake.replace(/_/g, "-"); +} + +/** + * The canonical objects the hold names, read from the hold itself rather than + * hardcoded -- if someone edits the bullet, this follows. + * + * @param {string} markdown contents of PRODUCT.md + * @returns {string[]} + */ +export function holdObjects(markdown) { + const line = markdown + .split("\n") + .find((l) => l.includes("projection fan-out is **HOLD**")); + if (!line) { + throw new Error("PRODUCT.md no longer contains a projection fan-out HOLD bullet"); + } + const subject = line.slice(line.indexOf("- ") + 2, line.indexOf("projection fan-out")); + return subject.match(/[A-Z][A-Za-z]+/g) ?? []; +} + +/** + * The writer-ownership registry: every object key, its owning crate, its tables. + * + * @param {string} source contents of canonical-domain/src/lib.rs + * @returns {Array<{key:string, slug:string, owner:string, tables:string[]}>} + */ +export function registry(source) { + const out = []; + const re = /(\w+) => "(\w+)",\s*\n\s*owner = "([^"]+)",\s*\n\s*tables = \[([\s\S]*?)\];/g; + let m; + while ((m = re.exec(source)) !== null) { + const [, key, slug, owner, rawTables] = m; + const tables = rawTables + .split(",") + .map((t) => t.trim().replace(/^"|"$/g, "")) + .filter(Boolean); + out.push({ key, slug, owner, tables }); + } + return out; +} + +/** Every `*_port_as_runtime_role.rs` in the tree, by basename without extension. */ +export function portSuites(root = ROOT) { + const found = new Map(); + const walk = (dir) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === "target" || entry.name === ".git" || entry.name === "node_modules") continue; + const full = join(dir, entry.name); + // Resolve through symlinks: `withFileTypes` reports a symlinked directory + // as neither file nor directory, so a naive `isDirectory()` walk skips it + // and silently under-reports proofs. A verifier that misses a suite would + // report a met condition as unmet -- noisy, but worse, it trains you to + // ignore it. + let isDir = entry.isDirectory(); + if (entry.isSymbolicLink()) { + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + } + if (isDir) walk(full); + else if (entry.name.endsWith("_port_as_runtime_role.rs")) { + found.set(entry.name.replace(/\.rs$/, ""), full); + } + } + }; + walk(join(root, "backend")); + return found; +} + +/** + * Evaluate every leg of the hold's release condition. + * + * @returns {{failures: string[], rows: Array}} + */ +export function evaluate(root = ROOT) { + const failures = []; + const product = readFileSync(resolve(root, "docs/current/PRODUCT.md"), "utf8"); + const domain = readFileSync( + resolve(root, "backend/crates/ontology/canonical-domain/src/lib.rs"), + "utf8", + ); + const map = JSON.parse(readFileSync(resolve(root, "tools/ci/postgres-cargo-map.json"), "utf8")); + + const named = holdObjects(product); + const keys = registry(domain); + const suites = portSuites(root); + + if (keys.length === 0) { + // A verifier that examines nothing must fail; a regex that silently stopped + // matching would otherwise report a clean bill of health over zero objects. + failures.push("registry: parsed zero object keys from canonical-domain/src/lib.rs"); + return { failures, rows: [] }; + } + + // Every object the hold names must actually be a registry key. A hold naming + // an object that does not exist can never be evaluated, let alone released. + for (const object of named) { + if (!keys.some((k) => k.key === object)) { + failures.push( + `hold names "${object}" but it is not an ObjectKey; the hold cannot be evaluated against the registry`, + ); + } + } + + const rows = []; + for (const entry of keys) { + const snake = snakeCase(entry.key); + const suiteName = `${snake}_port_as_runtime_role`; + const suitePath = suites.get(suiteName); + const mapName = `${entry.owner.replace(/^console-/, "")}-${kebabCase(suiteName)}-pg`; + const mapped = (map.entries ?? []).find((e) => e.name === mapName); + + // Leg 1: an explicit owning port. + if (!entry.owner.startsWith("console-")) { + failures.push(`${entry.key}: owner "${entry.owner}" is not a workspace crate`); + } + // Leg 2: a single-writer boundary -- at least one table it alone may write. + if (entry.tables.length === 0) { + failures.push(`${entry.key}: owns no table, so no writer rule applies to it`); + } + // Leg 3: a test that fails when the boundary breaks, and that actually runs. + if (!suitePath) { + failures.push(`${entry.key}: no ${suiteName}.rs on disk; the boundary has no port suite`); + } + if (!mapped) { + failures.push(`${entry.key}: no CI map entry named ${mapName}; its port suite may never run`); + } else if (!mapped.in_workflow_postgres_job) { + failures.push(`${entry.key}: CI map entry ${mapName} is not in the workflow postgres job`); + } + + rows.push({ + key: entry.key, + named: named.includes(entry.key), + owner: entry.owner, + tables: entry.tables.length, + suite: suitePath ? suitePath.slice(root.length + 1) : null, + ci: Boolean(mapped?.in_workflow_postgres_job), + }); + } + return { failures, rows }; +} + +const isMain = process.argv[1] && process.argv[1].endsWith("hold-release-conditions.mjs"); +if (isMain) { + const { failures, rows } = evaluate(); + for (const r of rows) { + const mark = r.owner && r.tables > 0 && r.suite && r.ci ? "MET" : "NOT MET"; + console.log( + `${mark.padEnd(8)} ${r.key.padEnd(12)} ${r.named ? "(named in hold) " : " "}` + + `owner=${r.owner} tables=${r.tables} ci=${r.ci ? "yes" : "no"}`, + ); + if (r.suite) console.log(`${" ".repeat(9)}proof: ${r.suite}`); + } + console.log(""); + if (failures.length) { + console.error(failures.join("\n")); + console.error(`\nhold-release-conditions: ${failures.length} unmet condition(s)`); + process.exit(1); + } + console.log( + "hold-release-conditions: every leg met for all " + + rows.length + + " canonical objects (owning port, owned tables, port suite, CI-wired)", + ); +} diff --git a/tools/ci/hold-release-conditions.test.mjs b/tools/ci/hold-release-conditions.test.mjs new file mode 100644 index 000000000..f38d85177 --- /dev/null +++ b/tools/ci/hold-release-conditions.test.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluate, holdObjects, kebabCase, registry, snakeCase } from "./hold-release-conditions.mjs"; + +test("hold-release-conditions", async (t) => { + await t.test("the three naming transforms agree across the six files", () => { + // This is the derivation that is actually error-prone. The registry says + // `PayRun`, the file on disk says `pay_run`, the CI map says `pay-run`. + assert.equal(snakeCase("PayRun"), "pay_run"); + assert.equal(snakeCase("JobPosition"), "job_position"); + assert.equal(snakeCase("OrgUnit"), "org_unit"); + assert.equal(snakeCase("Person"), "person"); + assert.equal(kebabCase("pay_run_port_as_runtime_role"), "pay-run-port-as-runtime-role"); + }); + + await t.test("the hold's subject is read from the hold, not hardcoded", () => { + const md = [ + "- Something else is **HOLD** for other reasons.", + "- Company, Person, Employment, and PayRun projection fan-out is **HOLD** until each has an explicit owning port and a proven single-writer boundary.", + ].join("\n"); + assert.deepEqual(holdObjects(md), ["Company", "Person", "Employment", "PayRun"]); + }); + + await t.test("a rewritten hold is followed, not ignored", () => { + const md = "- Company and OrgUnit projection fan-out is **HOLD** until each has an explicit owning port."; + assert.deepEqual(holdObjects(md), ["Company", "OrgUnit"]); + }); + + await t.test("a deleted hold bullet is an error, not a silent pass", () => { + // If the bullet is removed, this verifier must not quietly report success + // over an empty subject list -- that would be the emptiest false green. + assert.throws(() => holdObjects("# PRODUCT\n\nno holds here\n"), /no longer contains/); + }); + + await t.test("the registry parser reads key, owner and tables", () => { + const src = ` + PayRun => "pay_run", + owner = "console-payroll-adapter-postgres", + tables = ["payroll_draft_runs", "payroll_draft_lines"]; + `; + assert.deepEqual(registry(src), [ + { + key: "PayRun", + slug: "pay_run", + owner: "console-payroll-adapter-postgres", + tables: ["payroll_draft_runs", "payroll_draft_lines"], + }, + ]); + }); + + await t.test("a registry that parses to nothing fails closed", () => { + // Guarding zero subjects must fail. If the macro's shape changes and the + // regex stops matching, this verifier must go red rather than declare the + // hold releasable over an empty roster. + assert.deepEqual(registry("nothing that looks like a port declaration"), []); + }); + + await t.test("every leg of the hold is currently met on this tree", () => { + // The load-bearing assertion: all four objects the hold names (and the two + // it does not) have an owning port, at least one owned table, a port suite + // proving the boundary, and that suite wired into the workflow postgres job. + const { failures, rows } = evaluate(); + assert.deepEqual(failures, [], failures.join("\n")); + assert.equal(rows.length, 6, "the six canonical object keys"); + for (const row of rows) { + assert.ok(row.owner.startsWith("console-"), `${row.key} owner`); + assert.ok(row.tables > 0, `${row.key} owns no table`); + assert.ok(row.suite, `${row.key} has no port suite`); + assert.ok(row.ci, `${row.key} port suite is not CI-wired`); + } + }); + + await t.test("every object the hold names is a real registry key", () => { + const { rows } = evaluate(); + const named = rows.filter((r) => r.named).map((r) => r.key); + assert.deepEqual(named, ["Company", "Person", "Employment", "PayRun"]); + }); + + await t.test("PayRun's proof lives outside the canonical adapter", () => { + // Pinned deliberately. Searching only the canonical adapter's test directory + // finds five of six port suites and misses PayRun, whose owner is the + // payroll adapter -- which reads as "PayRun is barely covered" when it has + // the largest owned-table set of the six. That wrong turn is the reason this + // verifier exists, so the shape of it is worth keeping red-detectable. + const { rows } = evaluate(); + const payRun = rows.find((r) => r.key === "PayRun"); + assert.equal(payRun.owner, "console-payroll-adapter-postgres"); + assert.match(payRun.suite, /^backend\/crates\/payroll\/adapter-postgres\//); + assert.equal(payRun.tables, 6, "PayRun owns the largest table set of the six"); + }); +});