Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/current/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/documentation-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@
"status": "active",
"replacement": null,
"retention": "retain",
"blob_sha": "633f4a59a5fb9a38d1956d017c0887726726efa8",
"blob_sha": "e7d6841cd8f8e00c8e904c95181fe4a70ffb67a5",
"archive_tag": null
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/documentation-manifest.seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,7 @@
"status": "active",
"replacement": null,
"retention": "retain",
"blob_sha": "633f4a59a5fb9a38d1956d017c0887726726efa8",
"blob_sha": "e7d6841cd8f8e00c8e904c95181fe4a70ffb67a5",
"archive_tag": null
},
{
Expand Down
6 changes: 5 additions & 1 deletion tools/ci/gate-sweep.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
220 changes: 220 additions & 0 deletions tools/ci/hold-release-conditions.mjs
Original file line number Diff line number Diff line change
@@ -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<object>}}
*/
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)",
);
}
92 changes: 92 additions & 0 deletions tools/ci/hold-release-conditions.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading