{"version":3,"file":"test-run-admission.mjs","names":[],"sources":["../src/test-run-admission.ts"],"sourcesContent":["import type { TestRunEvidence } from \"./test-run-model.js\";\nimport type { MarquetteDiagnostic } from \"./validate.js\";\nimport { parseTestRunAdmissionId, testRunFingerprint } from \"./test-run-canonical.js\";\nimport { validateTestRunEvidence } from \"./test-run-validate.js\";\nimport { error, isStrictTimestamp } from \"./test-run-validate-rules.js\";\n\nexport { TEST_RUN_ADMISSION_PREFIX, parseTestRunAdmissionId } from \"./test-run-canonical.js\";\n\n/**\n * Exact release candidate a deployment gate wants evidence for.\n *\n * Every field must match the record exactly; admission never falls back to a\n * newer, older, or partially matching record.\n */\nexport interface TestRunCandidate {\n  /** Application the gate is deploying. */\n  readonly application: string;\n  /** Deployment environment the gate is promoting into. */\n  readonly environment: string;\n  /** Lowercase SHA-256 fingerprint of the application contract. */\n  readonly contractFingerprint: string;\n  /** Exact source revision of the candidate. */\n  readonly sourceRevision: string;\n  /** Release the candidate belongs to. */\n  readonly release: string;\n  /** Lowercase SHA-256 fingerprint of the exact artifact being promoted. */\n  readonly artifactFingerprint: string;\n}\n\n/**\n * Decides whether one record admits one exact candidate at one instant.\n *\n * An empty result admits the deployment. Any diagnostic rejects it: the\n * record must validate cleanly, its canonical fingerprint must be the one\n * named by `admissionId`, every candidate binding must match exactly, the\n * record must not be expired at `now`, the independent verification must\n * have accepted the run, and no skipped test may remain unaccounted for.\n *\n * Codes, paths, messages, and ordering are identical to the native\n * implementation, so both host families reach the same decision for the\n * same record.\n *\n * `now` must be a millisecond-precision UTC timestamp such as\n * `2026-01-01T00:00:00.000Z`; the fixed-width format keeps the expiry\n * comparison exact. Callers own retrieval: fetch the canonical bytes from an\n * immutable store within their own deadline, refuse oversized content, and\n * hand the parsed record here.\n */\nexport async function admitTestRun(\n  evidence: TestRunEvidence,\n  candidate: TestRunCandidate,\n  admissionId: string,\n  now: string,\n): Promise<MarquetteDiagnostic[]> {\n  const diagnostics = validateTestRunEvidence(evidence);\n\n  const nowIsExact = isStrictTimestamp(now);\n  if (!nowIsExact) {\n    diagnostics.push(\n      error(\n        \"VIZE_MARQUETTE_148\",\n        \"admission.now\",\n        \"admission time must be a millisecond-precision UTC instant\",\n      ),\n    );\n  }\n\n  const expected = parseTestRunAdmissionId(admissionId);\n  if (expected === undefined) {\n    diagnostics.push(\n      error(\n        \"VIZE_MARQUETTE_141\",\n        \"admission.id\",\n        \"admission id must be test-run: followed by 64 lowercase hexadecimal characters\",\n      ),\n    );\n  } else if ((await testRunFingerprint(evidence)) !== expected) {\n    diagnostics.push(\n      error(\n        \"VIZE_MARQUETTE_142\",\n        \"admission.id\",\n        \"admission id does not name this record's canonical fingerprint\",\n      ),\n    );\n  }\n\n  const bindings = [\n    [evidence.application, candidate.application, \"application\"],\n    [evidence.environment, candidate.environment, \"environment\"],\n    [evidence.contractFingerprint, candidate.contractFingerprint, \"contractFingerprint\"],\n    [evidence.sourceRevision, candidate.sourceRevision, \"sourceRevision\"],\n    [evidence.release, candidate.release, \"release\"],\n    [evidence.artifact.fingerprint, candidate.artifactFingerprint, \"artifact.fingerprint\"],\n  ] as const;\n  for (const [recorded, wanted, field] of bindings) {\n    if (recorded !== wanted) {\n      diagnostics.push(\n        error(\"VIZE_MARQUETTE_144\", field, `record does not bind the candidate ${field}`),\n      );\n    }\n  }\n\n  if (nowIsExact && evidence.validUntil <= now) {\n    diagnostics.push(\n      error(\"VIZE_MARQUETTE_145\", \"validUntil\", \"record is expired at the admission time\"),\n    );\n  }\n  if (evidence.verification.outcome !== \"accepted\") {\n    diagnostics.push(\n      error(\n        \"VIZE_MARQUETTE_146\",\n        \"verification.outcome\",\n        \"only an accepted verification can admit a deployment\",\n      ),\n    );\n  }\n  if (evidence.verification.skipped > 0) {\n    diagnostics.push(\n      error(\n        \"VIZE_MARQUETTE_147\",\n        \"verification.skipped\",\n        \"skipped tests are not approved for deployment admission\",\n      ),\n    );\n  }\n\n  diagnostics.sort((left, right) =>\n    left.path !== right.path\n      ? left.path < right.path\n        ? -1\n        : 1\n      : left.code !== right.code\n        ? left.code < right.code\n          ? -1\n          : 1\n        : left.message < right.message\n          ? -1\n          : left.message > right.message\n            ? 1\n            : 0,\n  );\n  return diagnostics;\n}\n\n/**\n * Every denial code, in the stable lexicographic decision order.\n *\n * The vocabulary is shared by every backend family: a JavaScript, Rust, Go,\n * or JVM host must derive the same codes from the same diagnostics, as\n * pinned by the shared `tests/fixtures/test-run-evidence` decision fixtures.\n * Codes are append-only: they are never renamed, renumbered, reused, or\n * removed, and a new rejection cause always ships with a new code.\n */\nexport const TEST_RUN_DENIAL_CODES = [\n  \"admission-id-malformed\",\n  \"admission-id-mismatch\",\n  \"admission-time-malformed\",\n  \"candidate-application-mismatch\",\n  \"candidate-artifact-fingerprint-mismatch\",\n  \"candidate-contract-fingerprint-mismatch\",\n  \"candidate-environment-mismatch\",\n  \"candidate-release-mismatch\",\n  \"candidate-source-revision-mismatch\",\n  \"check-candidate-mismatch\",\n  \"check-invalid\",\n  \"check-observer-not-independent\",\n  \"record-expired\",\n  \"record-invalid\",\n  \"skipped-tests-recorded\",\n  \"transition-chain-broken\",\n  \"transition-invalid\",\n  \"transition-replayed\",\n  \"transition-state-mismatch\",\n  \"verification-not-accepted\",\n] as const;\n\n/** Stable machine-readable cause class of one admission denial. */\nexport type TestRunDenialCode = (typeof TEST_RUN_DENIAL_CODES)[number];\n\n/**\n * Structured allow-or-deny admission decision for one exact candidate.\n *\n * The decision carries the machine-readable cause classes next to the exact\n * diagnostics, so a deployment gate in any language can act on one bounded\n * vocabulary while operators keep the full explanation. Serialization\n * follows the shared `test-run-admission` schema; decisions are outputs, so\n * a gate must never trust a decision it did not compute itself.\n */\nexport interface TestRunAdmissionDecision {\n  /** Whether the record admits the candidate; true only with no diagnostics. */\n  readonly allowed: boolean;\n  /** Deduplicated denial causes sorted lexicographically; empty when allowed. */\n  readonly denialCodes: readonly TestRunDenialCode[];\n  /** Complete diagnostics in the stable path, code, message order. */\n  readonly diagnostics: readonly MarquetteDiagnostic[];\n}\n\nconst CANDIDATE_MISMATCH_CODES: ReadonlyMap<string, TestRunDenialCode> = new Map([\n  [\"application\", \"candidate-application-mismatch\"],\n  [\"artifact.fingerprint\", \"candidate-artifact-fingerprint-mismatch\"],\n  [\"contractFingerprint\", \"candidate-contract-fingerprint-mismatch\"],\n  [\"environment\", \"candidate-environment-mismatch\"],\n  [\"release\", \"candidate-release-mismatch\"],\n  [\"sourceRevision\", \"candidate-source-revision-mismatch\"],\n]);\n\n/**\n * Returns the stable denial code one admission diagnostic maps to.\n *\n * The mapping is total and identical in every host family: admission codes\n * `VIZE_MARQUETTE_141` through `VIZE_MARQUETTE_148` map to their exact\n * cause, `VIZE_MARQUETTE_144` distinguishes the mismatched candidate binding\n * by its diagnostic path, `VIZE_MARQUETTE_149` through `VIZE_MARQUETTE_151`\n * map to their tests-check cause, `VIZE_MARQUETTE_156` through\n * `VIZE_MARQUETTE_159` map to their transition cause, every other\n * diagnostic at a `check.` path is a `check-invalid` tests-check validation\n * failure, every other diagnostic at a `transition.` path is a\n * `transition-invalid` transition validation failure, and every remaining\n * diagnostic is a `record-invalid` record-validation failure.\n */\nexport function testRunDenialCode(diagnostic: MarquetteDiagnostic): TestRunDenialCode {\n  switch (diagnostic.code) {\n    case \"VIZE_MARQUETTE_141\":\n      return \"admission-id-malformed\";\n    case \"VIZE_MARQUETTE_142\":\n      return \"admission-id-mismatch\";\n    case \"VIZE_MARQUETTE_144\": {\n      const mismatch = CANDIDATE_MISMATCH_CODES.get(diagnostic.path);\n      if (mismatch !== undefined) {\n        return mismatch;\n      }\n      break;\n    }\n    case \"VIZE_MARQUETTE_145\":\n      return \"record-expired\";\n    case \"VIZE_MARQUETTE_146\":\n      return \"verification-not-accepted\";\n    case \"VIZE_MARQUETTE_147\":\n      return \"skipped-tests-recorded\";\n    case \"VIZE_MARQUETTE_148\":\n      return \"admission-time-malformed\";\n    case \"VIZE_MARQUETTE_149\":\n      return \"check-candidate-mismatch\";\n    case \"VIZE_MARQUETTE_150\":\n      return \"check-invalid\";\n    case \"VIZE_MARQUETTE_151\":\n      return \"check-observer-not-independent\";\n    case \"VIZE_MARQUETTE_156\":\n      return \"transition-state-mismatch\";\n    case \"VIZE_MARQUETTE_157\":\n      return \"transition-chain-broken\";\n    case \"VIZE_MARQUETTE_158\":\n      return \"transition-replayed\";\n    case \"VIZE_MARQUETTE_159\":\n      return \"transition-state-mismatch\";\n    default:\n      break;\n  }\n  if (diagnostic.path.startsWith(\"check.\")) {\n    return \"check-invalid\";\n  }\n  return diagnostic.path.startsWith(\"transition.\") ? \"transition-invalid\" : \"record-invalid\";\n}\n\n/**\n * Decides one candidate and returns the structured admission decision.\n *\n * The decision wraps {@link admitTestRun}: `diagnostics` is exactly its\n * result, `denialCodes` maps every diagnostic through\n * {@link testRunDenialCode} and then deduplicates and sorts the codes\n * lexicographically, and `allowed` is true only when both are empty. Codes,\n * ordering, and diagnostics are identical to the native implementation, as\n * pinned by the shared decision fixtures. Inputs carry the same obligations\n * as {@link admitTestRun}.\n */\nexport async function decideTestRunAdmission(\n  evidence: TestRunEvidence,\n  candidate: TestRunCandidate,\n  admissionId: string,\n  now: string,\n): Promise<TestRunAdmissionDecision> {\n  const diagnostics = await admitTestRun(evidence, candidate, admissionId, now);\n  const denialCodes = [...new Set(diagnostics.map(testRunDenialCode))].sort();\n  return { allowed: diagnostics.length === 0, denialCodes, diagnostics };\n}\n"],"mappings":"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"}