476 lines
41 KiB
TypeScript
476 lines
41 KiB
TypeScript
// lib/gate-record.ts — T165 WP1 pure E1/E2 contracts.
|
|
//
|
|
// This module is deliberately isolated from brokers, adapters, storage, and
|
|
// provider clients. It supplies closed record validation, RFC 8785 canonical
|
|
// bytes, and digest/idempotency derivation for a later canonical writer.
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
export const MG_CODES = ["MG01", "MG02", "MG03", "MG04", "MG05", "MG06", "MG07", "MG08"] as const;
|
|
export type MgCode = typeof MG_CODES[number];
|
|
export const FIXED_TARGET = {
|
|
host: "git.mosaicstack.dev",
|
|
repository: "mosaicstack/stack",
|
|
pullRequest: 1491,
|
|
baseBranch: "next",
|
|
headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68",
|
|
headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8",
|
|
} as const;
|
|
|
|
/** Normative CDDL identifiers. Semantic constraints below make all maps closed. */
|
|
export const GATE_RECORD_V1_CDDL = String.raw`GateRecordV1 = GateEvidenceBundleV1 / GateVerdictV1
|
|
GateEvidenceBundleV1 = { schemaVersion: 1, recordType: "gate.merge.evidence", evidenceBundleId: uuid-v4, bundleSha256: sha256, gateProfile: "merge", requestedTransition: requested-transition, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, target: target, evidence: [8*64 evidence-item], securityReview: security-review, requiredContextSetSha256: sha256, observedAt: timestamp, expiresAt: timestamp, sensitivity: "internal" }
|
|
GateVerdictV1 = { schemaVersion: 1, recordType: "gate.merge.verdict", verdictId: uuid-v4, transitionId: uuid-v4, idempotencyKey: sha256, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, authority: authority, transition: transition, target: target, evidenceBundleId: uuid-v4, evidenceBundleSha256: sha256, decision: decision, validity: validity, sensitivity: "internal", recordSha256: sha256 }
|
|
requested-transition = { requestedFrom: "reviewed", requestedTo: "authorized-integration" }
|
|
actor-context = { principalId: opaque-id, seatId: role-slug, incarnationId: uuid-v4, sessionId: opaque-id, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int }
|
|
mission-context = { missionId: uuid-v4, workUnitId: uuid-v4, workflowState: "reviewed", workflowRevision: positive-int }
|
|
target = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseBranch: "next", baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8", diffSha256: sha256, changedPathsSha256: sha256 }
|
|
evidence-item = { evidenceId: uuid-v4, criterionCode: criterion-code, class: evidence-class, evidenceKind: evidence-kind, producer: producer, capability: evidence-capability, source: evidence-source, targetBinding: target-binding, result: evidence-result, observedAt: timestamp, freshnessClass: freshness-class, expiresAt: timestamp / null, invalidationKeys: [1*32 invalidation-key] }
|
|
producer = { producerId: opaque-id, principalId: opaque-id, roleId: role-slug, roleRevision: positive-int, roleDigest: sha256, relationship: "independent" / "system-observer", independentOf: [*16 opaque-id] }
|
|
evidence-capability = { id: "repo.target.inspect" / "gate.evidence.observe", binding: "mosaic_gate_target_read" / "mosaic_gate_evidence_observe", backend: "mosaic-gate-broker" }
|
|
evidence-source = { sourceType: "gitea-wrapper" / "woodpecker-wrapper" / "git-object" / "brain-artifact" / "coordinator-record" / "runtime-attestation" / "journal-record", locator: locator, sourceSha256: sha256, immutable: true }
|
|
target-binding = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68" }
|
|
evidence-result = { outcome: "pass" / "fail" / "blocked", reasonCode: reason-code / null }
|
|
security-review = { triggered: bool, triggerPolicyDigest: sha256, requiredEvidenceId: uuid-v4 / null }
|
|
authority = { policyVersion: 4, policyDigest: sha256, roleBindingId: uuid-v4, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, intentId: uuid-v4, decisionId: uuid-v4, grantId: uuid-v4, invocationId: uuid-v4, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int, leaseExpiresAt: timestamp }
|
|
transition = { domain: "gate", objectId: "git.mosaicstack.dev/mosaicstack/stack#1491:merge", requestedFrom: "reviewed", requestedTo: "authorized-integration", expectedStateRevision: positive-int, previousState: "reviewed", previousRevision: positive-int, resultingState: "passed" / "failed" / "blocked", resultingRevision: positive-int }
|
|
decision = { verdict: "PASS" / "FAIL" / "BLOCKED", criteria: [criterion-mg01, criterion-mg02, criterion-mg03, criterion-mg04, criterion-mg05, criterion-mg06, criterion-mg07, criterion-mg08], summary: summary, failedCriteria: [*8 criterion-code], blockedCriteria: [*8 criterion-code], reasonCodes: [*8 reason-code], authorizedNextOperation: authorized-next-operation / null }
|
|
validity = { issuedAt: timestamp, expiresAt: timestamp, invalidationKeys: [1*32 invalidation-key] }
|
|
criterion-mg01 = { code: "MG01", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg02 = { code: "MG02", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg03 = { code: "MG03", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg04 = { code: "MG04", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg05 = { code: "MG05", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg06 = { code: "MG06", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg07 = { code: "MG07", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
criterion-mg08 = { code: "MG08", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
|
authorized-next-operation = { capability: "operation.merge", host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", baseBranch: "next", strategy: "squash", operationGrantId: null }
|
|
criterion-code = "MG01" / "MG02" / "MG03" / "MG04" / "MG05" / "MG06" / "MG07" / "MG08"
|
|
criterion-outcome = "pass" / "fail" / "blocked"
|
|
evidence-class = "observation" / "verification" / "review"
|
|
evidence-kind = "pr-metadata" / "pr-diff" / "changed-paths" / "code-review" / "ci-contexts" / "merge-queue" / "security-trigger" / "security-review" / "repository-declaration" / "coordinator-authority" / "independence" / "runtime-attestation" / "journal-attestation"
|
|
freshness-class = "pr-live" / "queue-live" / "ci-head" / "review-head" / "security-policy" / "authority-submit" / "runtime-incarnation"
|
|
uuid-v4 = tstr .regexp "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
sha256 = tstr .regexp "^[0-9a-f]{64}$"
|
|
git-sha = tstr .regexp "^[0-9a-f]{40}$"
|
|
role-slug = tstr .regexp "^[a-z][a-z0-9-]{0,63}$"
|
|
opaque-id = tstr .regexp "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
|
timestamp = tstr .regexp "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9][.][0-9]{3}Z$"
|
|
locator = tstr .size (1..1024)
|
|
summary = tstr .size (1..500)
|
|
positive-int = 1..9007199254740991
|
|
reason-code = "TARGET_IDENTITY_MISMATCH" / "TARGET_HEAD_MISMATCH" / "TARGET_TREE_MISMATCH" / "TARGET_BASE_MISMATCH" / "TARGET_DIFF_MISMATCH" / "TARGET_CHANGED_PATHS_MISMATCH" / "TARGET_STATE_UNAVAILABLE" / "TARGET_DRIFT_REOBSERVE" / "REVIEW_REJECTED" / "REVIEW_TARGET_MISMATCH" / "REVIEW_INDEPENDENCE_VIOLATION" / "REVIEW_MISSING" / "REVIEW_STALE" / "REVIEW_INDEPENDENCE_UNAVAILABLE" / "SECURITY_REVIEW_REJECTED" / "SECURITY_POLICY_VIOLATION" / "SECURITY_REVIEW_MISSING" / "SECURITY_POLICY_UNAVAILABLE" / "SECURITY_EVIDENCE_STALE" / "CI_RED" / "CI_REQUIRED_CONTEXT_MISSING" / "CI_PENDING" / "CI_STATE_UNAVAILABLE" / "CI_EVIDENCE_STALE" / "QUEUE_BUSY" / "QUEUE_INDETERMINATE" / "QUEUE_EVIDENCE_STALE" / "PR_CLOSED" / "PR_DRAFT" / "PR_NOT_MERGEABLE" / "TRUNK_MISMATCH" / "MERGE_STRATEGY_MISMATCH" / "DECLARATION_INVALID" / "PR_STATE_UNAVAILABLE" / "DECLARATION_UNAVAILABLE" / "ASSIGNMENT_MISSING" / "AUTHORITY_EXPIRED" / "LEASE_EXPIRED" / "FENCE_STALE" / "WORKFLOW_REVISION_STALE" / "OPERATION_REQUEST_MISMATCH" / "AUTHORITY_BACKEND_UNAVAILABLE" / "GATE_CAPABILITY_UNAVAILABLE" / "BROKER_UNAVAILABLE" / "RUNTIME_ATTESTATION_UNAVAILABLE" / "RUNTIME_POLICY_VIOLATION" / "ROLE_RECONCILIATION_MISMATCH" / "MODEL_FLOOR_UNMET" / "JOURNAL_UNAVAILABLE" / "DENIED_PATH_CONTROL_FAILED" / "INDEPENDENCE_VIOLATION" / "INDEPENDENCE_UNAVAILABLE" / "EVIDENCE_MALFORMED" / "EVIDENCE_STALE" / "EVIDENCE_CONTRADICTORY"
|
|
invalidation-key = { kind: "pr-head" / "head-tree" / "diff" / "changed-paths" / "base-branch" / "base-head" / "pr-state" / "mergeability" / "required-context-set" / "ci-state" / "queue-state" / "review-state" / "review-independence" / "security-policy" / "security-verdict" / "mission-revision" / "work-unit-revision" / "workflow-revision" / "operation-request" / "assignment-revision" / "lease-revision" / "fencing-token" / "incarnation" / "role-binding" / "active-tool-set" / "policy-set" / "broker-availability" / "expires-at", valueSha256: sha256 }`;
|
|
|
|
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
const GIT_SHA = /^[0-9a-f]{40}$/;
|
|
const ROLE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
const OPAQUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
const TIMESTAMP = /^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}Z$/;
|
|
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
const EVIDENCE_KINDS = new Set(["pr-metadata", "pr-diff", "changed-paths", "code-review", "ci-contexts", "merge-queue", "security-trigger", "security-review", "repository-declaration", "coordinator-authority", "independence", "runtime-attestation", "journal-attestation"]);
|
|
const SOURCE_TYPES = new Set(["gitea-wrapper", "woodpecker-wrapper", "git-object", "brain-artifact", "coordinator-record", "runtime-attestation", "journal-record"]);
|
|
const INVALIDATION_KINDS = new Set(["pr-head", "head-tree", "diff", "changed-paths", "base-branch", "base-head", "pr-state", "mergeability", "required-context-set", "ci-state", "queue-state", "review-state", "review-independence", "security-policy", "security-verdict", "mission-revision", "work-unit-revision", "workflow-revision", "operation-request", "assignment-revision", "lease-revision", "fencing-token", "incarnation", "role-binding", "active-tool-set", "policy-set", "broker-availability", "expires-at"]);
|
|
|
|
const REASONS: Record<MgCode, { fail: readonly string[]; blocked: readonly string[] }> = {
|
|
MG01: { fail: ["TARGET_IDENTITY_MISMATCH", "TARGET_HEAD_MISMATCH", "TARGET_TREE_MISMATCH", "TARGET_BASE_MISMATCH", "TARGET_DIFF_MISMATCH", "TARGET_CHANGED_PATHS_MISMATCH"], blocked: ["TARGET_STATE_UNAVAILABLE", "TARGET_DRIFT_REOBSERVE"] },
|
|
MG02: { fail: ["REVIEW_REJECTED", "REVIEW_TARGET_MISMATCH", "REVIEW_INDEPENDENCE_VIOLATION"], blocked: ["REVIEW_MISSING", "REVIEW_STALE", "REVIEW_INDEPENDENCE_UNAVAILABLE"] },
|
|
MG03: { fail: ["SECURITY_REVIEW_REJECTED", "SECURITY_POLICY_VIOLATION"], blocked: ["SECURITY_REVIEW_MISSING", "SECURITY_POLICY_UNAVAILABLE", "SECURITY_EVIDENCE_STALE"] },
|
|
MG04: { fail: ["CI_RED", "CI_REQUIRED_CONTEXT_MISSING"], blocked: ["CI_PENDING", "CI_STATE_UNAVAILABLE", "CI_EVIDENCE_STALE"] },
|
|
MG05: { fail: [], blocked: ["QUEUE_BUSY", "QUEUE_INDETERMINATE", "QUEUE_EVIDENCE_STALE"] },
|
|
MG06: { fail: ["PR_CLOSED", "PR_DRAFT", "PR_NOT_MERGEABLE", "TRUNK_MISMATCH", "MERGE_STRATEGY_MISMATCH", "DECLARATION_INVALID"], blocked: ["PR_STATE_UNAVAILABLE", "DECLARATION_UNAVAILABLE"] },
|
|
MG07: { fail: [], blocked: ["ASSIGNMENT_MISSING", "AUTHORITY_EXPIRED", "LEASE_EXPIRED", "FENCE_STALE", "WORKFLOW_REVISION_STALE", "OPERATION_REQUEST_MISMATCH", "AUTHORITY_BACKEND_UNAVAILABLE"] },
|
|
MG08: { fail: ["RUNTIME_POLICY_VIOLATION", "ROLE_RECONCILIATION_MISMATCH", "MODEL_FLOOR_UNMET", "DENIED_PATH_CONTROL_FAILED", "INDEPENDENCE_VIOLATION"], blocked: ["GATE_CAPABILITY_UNAVAILABLE", "BROKER_UNAVAILABLE", "RUNTIME_ATTESTATION_UNAVAILABLE", "JOURNAL_UNAVAILABLE", "INDEPENDENCE_UNAVAILABLE"] },
|
|
};
|
|
const UNIVERSAL_BLOCKED = ["EVIDENCE_MALFORMED", "EVIDENCE_STALE", "EVIDENCE_CONTRADICTORY"] as const;
|
|
|
|
export type GateRecordValidation = Readonly<{ ok: true; canonical: string }> | Readonly<{ ok: false; reason: string }>;
|
|
function fail(reason: string): GateRecordValidation { return Object.freeze({ ok: false, reason }); }
|
|
function pass(value: unknown): GateRecordValidation { return Object.freeze({ ok: true, canonical: canonicalizeRfc8785(value) }); }
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
function closed(value: unknown, keys: readonly string[], label: string): Record<string, unknown> | undefined {
|
|
if (!isObject(value)) return undefined;
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) return undefined;
|
|
return value;
|
|
}
|
|
function text(value: unknown, min = 1, max = 1024): value is string {
|
|
return typeof value === "string" && Buffer.byteLength(value, "utf8") >= min && Buffer.byteLength(value, "utf8") <= max && value.normalize("NFC") === value && !CONTROL.test(value);
|
|
}
|
|
function sha(value: unknown): value is string { return typeof value === "string" && SHA256.test(value); }
|
|
function uuid(value: unknown): value is string { return typeof value === "string" && UUID_V4.test(value); }
|
|
function opaque(value: unknown): value is string { return typeof value === "string" && OPAQUE.test(value) && Buffer.byteLength(value, "utf8") <= 128; }
|
|
function positive(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; }
|
|
function timestamp(value: unknown): value is string { return typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; }
|
|
function stringSet(value: unknown, max: number, predicate: (entry: string) => boolean, ordered = true): value is string[] {
|
|
return Array.isArray(value) && value.length <= max && value.every((entry) => typeof entry === "string" && predicate(entry)) && new Set(value).size === value.length && (!ordered || value.every((entry, index) => index === 0 || value[index - 1] < entry));
|
|
}
|
|
function exactCodes(value: unknown): value is MgCode[] { return Array.isArray(value) && value.length === MG_CODES.length && value.every((code, index) => code === MG_CODES[index]); }
|
|
function reasonAllowed(code: MgCode, outcome: string, reason: unknown): boolean {
|
|
if (outcome === "pass") return reason === null;
|
|
if (outcome !== "fail" && outcome !== "blocked") return false;
|
|
if (typeof reason !== "string") return false;
|
|
return outcome === "fail"
|
|
? REASONS[code].fail.includes(reason)
|
|
: REASONS[code].blocked.includes(reason) || (UNIVERSAL_BLOCKED as readonly string[]).includes(reason);
|
|
}
|
|
function noExtraStringControls(value: unknown): boolean {
|
|
if (typeof value === "string") return text(value, 0, Number.MAX_SAFE_INTEGER);
|
|
if (typeof value === "number") return Number.isSafeInteger(value) && !Object.is(value, -0);
|
|
if (value === null || typeof value === "boolean") return true;
|
|
if (Array.isArray(value)) return value.every(noExtraStringControls);
|
|
return isObject(value) && Object.entries(value).every(([key, nested]) => text(key, 0, Number.MAX_SAFE_INTEGER) && noExtraStringControls(nested));
|
|
}
|
|
|
|
/** RFC 8785 JSON Canonicalization Scheme bytes, after I-JSON validation. */
|
|
export function canonicalizeRfc8785(value: unknown): string {
|
|
if (!noExtraStringControls(value)) throw new Error("not I-JSON");
|
|
const render = (current: unknown): string => {
|
|
if (current === null || typeof current === "boolean") return String(current);
|
|
if (typeof current === "number") return JSON.stringify(current);
|
|
if (typeof current === "string") return JSON.stringify(current);
|
|
if (Array.isArray(current)) return `[${current.map(render).join(",")}]`;
|
|
const object = current as Record<string, unknown>;
|
|
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${render(object[key])}`).join(",")}}`;
|
|
};
|
|
return render(value);
|
|
}
|
|
export function sha256Canonical(value: unknown): string { return createHash("sha256").update(canonicalizeRfc8785(value), "utf8").digest("hex"); }
|
|
function omitTop(record: Record<string, unknown>, field: string): Record<string, unknown> {
|
|
const copy = { ...record };
|
|
delete copy[field];
|
|
return copy;
|
|
}
|
|
|
|
function validTarget(value: unknown): value is Record<string, unknown> {
|
|
const target = closed(value, ["host", "repository", "pullRequest", "baseBranch", "baseHeadSha", "headSha", "headTreeSha", "diffSha256", "changedPathsSha256"], "target");
|
|
return !!target && target.host === FIXED_TARGET.host && target.repository === FIXED_TARGET.repository && target.pullRequest === FIXED_TARGET.pullRequest && target.baseBranch === FIXED_TARGET.baseBranch && typeof target.baseHeadSha === "string" && GIT_SHA.test(target.baseHeadSha) && target.headSha === FIXED_TARGET.headSha && target.headTreeSha === FIXED_TARGET.headTreeSha && sha(target.diffSha256) && sha(target.changedPathsSha256);
|
|
}
|
|
function validTargetBinding(value: unknown): boolean {
|
|
const binding = closed(value, ["host", "repository", "pullRequest", "baseHeadSha", "headSha"], "targetBinding");
|
|
return !!binding && binding.host === FIXED_TARGET.host && binding.repository === FIXED_TARGET.repository && binding.pullRequest === FIXED_TARGET.pullRequest && typeof binding.baseHeadSha === "string" && GIT_SHA.test(binding.baseHeadSha) && binding.headSha === FIXED_TARGET.headSha;
|
|
}
|
|
function validActor(value: unknown): value is Record<string, unknown> {
|
|
const actor = closed(value, ["principalId", "seatId", "incarnationId", "sessionId", "roleId", "roleRevision", "roleDigest", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken"], "actor");
|
|
return !!actor && opaque(actor.principalId) && typeof actor.seatId === "string" && ROLE.test(actor.seatId) && uuid(actor.incarnationId) && opaque(actor.sessionId) && actor.roleId === "gate-merge-ng" && actor.roleRevision === 1 && sha(actor.roleDigest) && uuid(actor.assignmentId) && positive(actor.assignmentRevision) && uuid(actor.leaseId) && positive(actor.leaseRevision) && positive(actor.fencingToken);
|
|
}
|
|
function validMission(value: unknown): value is Record<string, unknown> {
|
|
const mission = closed(value, ["missionId", "workUnitId", "workflowState", "workflowRevision"], "mission");
|
|
return !!mission && uuid(mission.missionId) && uuid(mission.workUnitId) && mission.workflowState === "reviewed" && positive(mission.workflowRevision);
|
|
}
|
|
function validInvalidationKeys(value: unknown): boolean {
|
|
if (!Array.isArray(value) || value.length < 1 || value.length > 32) return false;
|
|
let previous = "";
|
|
const seen = new Set<string>();
|
|
for (const entry of value) {
|
|
const key = closed(entry, ["kind", "valueSha256"], "invalidation");
|
|
if (!key || typeof key.kind !== "string" || !INVALIDATION_KINDS.has(key.kind) || !sha(key.valueSha256)) return false;
|
|
const order = `${key.kind}\u0000${key.valueSha256}`;
|
|
if (order <= previous || seen.has(order)) return false;
|
|
previous = order;
|
|
seen.add(order);
|
|
}
|
|
return true;
|
|
}
|
|
function validProducer(value: unknown): boolean {
|
|
const producer = closed(value, ["producerId", "principalId", "roleId", "roleRevision", "roleDigest", "relationship", "independentOf"], "producer");
|
|
if (!producer || !opaque(producer.producerId) || !opaque(producer.principalId) || typeof producer.roleId !== "string" || !ROLE.test(producer.roleId) || !positive(producer.roleRevision) || !sha(producer.roleDigest) || (producer.relationship !== "independent" && producer.relationship !== "system-observer") || !stringSet(producer.independentOf, 16, opaque)) return false;
|
|
return !producer.independentOf.includes(producer.principalId);
|
|
}
|
|
function validCapability(value: unknown): boolean {
|
|
const capability = closed(value, ["id", "binding", "backend"], "capability");
|
|
if (!capability || capability.backend !== "mosaic-gate-broker") return false;
|
|
return (capability.id === "repo.target.inspect" && capability.binding === "mosaic_gate_target_read") || (capability.id === "gate.evidence.observe" && capability.binding === "mosaic_gate_evidence_observe");
|
|
}
|
|
function validSource(value: unknown): boolean {
|
|
const source = closed(value, ["sourceType", "locator", "sourceSha256", "immutable"], "source");
|
|
return !!source && typeof source.sourceType === "string" && SOURCE_TYPES.has(source.sourceType) && text(source.locator, 1, 1024) && sha(source.sourceSha256) && source.immutable === true;
|
|
}
|
|
function expectedFreshness(kind: string): { freshness: string; class: string } | undefined {
|
|
if (["pr-metadata", "pr-diff", "changed-paths", "repository-declaration"].includes(kind)) return { freshness: "pr-live", class: "observation" };
|
|
if (kind === "code-review") return { freshness: "review-head", class: "review" };
|
|
if (kind === "ci-contexts") return { freshness: "ci-head", class: "observation" };
|
|
if (kind === "merge-queue") return { freshness: "queue-live", class: "observation" };
|
|
if (kind === "security-trigger") return { freshness: "security-policy", class: "observation" };
|
|
if (kind === "security-review") return { freshness: "security-policy", class: "review" };
|
|
if (kind === "coordinator-authority") return { freshness: "authority-submit", class: "observation" };
|
|
if (["independence", "runtime-attestation", "journal-attestation"].includes(kind)) return { freshness: "runtime-incarnation", class: "verification" };
|
|
return undefined;
|
|
}
|
|
function validEvidenceItem(value: unknown): value is Record<string, unknown> {
|
|
const item = closed(value, ["evidenceId", "criterionCode", "class", "evidenceKind", "producer", "capability", "source", "targetBinding", "result", "observedAt", "freshnessClass", "expiresAt", "invalidationKeys"], "evidence");
|
|
if (!item || !uuid(item.evidenceId) || typeof item.criterionCode !== "string" || !MG_CODES.includes(item.criterionCode as MgCode) || typeof item.class !== "string" || typeof item.evidenceKind !== "string" || !EVIDENCE_KINDS.has(item.evidenceKind) || !validProducer(item.producer) || !validCapability(item.capability) || !validSource(item.source) || !validTargetBinding(item.targetBinding) || !timestamp(item.observedAt) || !validInvalidationKeys(item.invalidationKeys)) return false;
|
|
const result = closed(item.result, ["outcome", "reasonCode"], "result");
|
|
const fresh = expectedFreshness(item.evidenceKind);
|
|
if (!result || !fresh || item.class !== fresh.class || item.freshnessClass !== fresh.freshness || !reasonAllowed(item.criterionCode as MgCode, String(result.outcome), result.reasonCode)) return false;
|
|
if (item.expiresAt !== null && !timestamp(item.expiresAt)) return false;
|
|
if (item.expiresAt === null && item.freshnessClass !== "review-head" && item.freshnessClass !== "runtime-incarnation") return false;
|
|
return true;
|
|
}
|
|
type CriterionDerivation = Readonly<{
|
|
outcome: "pass" | "fail" | "blocked";
|
|
reasonCode: string | null;
|
|
}>;
|
|
|
|
/**
|
|
* E1 is the only source of E2 criterion outcomes. The first applicable reason
|
|
* follows the CDDL's left-to-right priority after all evidence for that
|
|
* criterion has been considered. A caller cannot choose a weaker reason or a
|
|
* PASS result over a negative observation.
|
|
*/
|
|
function deriveCriterion(code: MgCode, items: readonly Record<string, unknown>[]): CriterionDerivation | undefined {
|
|
const outcomes = items.map((item) => (item.result as Record<string, unknown>).outcome);
|
|
const outcome = outcomes.includes("fail")
|
|
? "fail"
|
|
: outcomes.includes("blocked")
|
|
? "blocked"
|
|
: outcomes.every((entry) => entry === "pass")
|
|
? "pass"
|
|
: undefined;
|
|
if (!outcome) return undefined;
|
|
if (outcome === "pass") return Object.freeze({ outcome, reasonCode: null });
|
|
const priority = outcome === "fail"
|
|
? REASONS[code].fail
|
|
: [...REASONS[code].blocked, ...UNIVERSAL_BLOCKED];
|
|
for (const reasonCode of priority) {
|
|
if (items.some((item) => (item.result as Record<string, unknown>).reasonCode === reasonCode)) {
|
|
return Object.freeze({ outcome, reasonCode });
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function validateGateEvidenceBundle(value: unknown): GateRecordValidation {
|
|
const bundle = closed(value, ["schemaVersion", "recordType", "evidenceBundleId", "bundleSha256", "gateProfile", "requestedTransition", "operationRequestId", "actorContext", "missionContext", "target", "evidence", "securityReview", "requiredContextSetSha256", "observedAt", "expiresAt", "sensitivity"], "bundle");
|
|
if (!bundle) return fail("E1:closed-map");
|
|
if (bundle.schemaVersion !== 1 || bundle.recordType !== "gate.merge.evidence" || !uuid(bundle.evidenceBundleId) || !sha(bundle.bundleSha256) || bundle.gateProfile !== "merge" || !uuid(bundle.operationRequestId) || !validActor(bundle.actorContext) || !validMission(bundle.missionContext) || !validTarget(bundle.target) || !sha(bundle.requiredContextSetSha256) || !timestamp(bundle.observedAt) || !timestamp(bundle.expiresAt) || bundle.sensitivity !== "internal") return fail("E1:scalar");
|
|
if (!closed(bundle.requestedTransition, ["requestedFrom", "requestedTo"], "requestedTransition") || (bundle.requestedTransition as Record<string, unknown>).requestedFrom !== "reviewed" || (bundle.requestedTransition as Record<string, unknown>).requestedTo !== "authorized-integration") return fail("E1:transition");
|
|
if (!Array.isArray(bundle.evidence) || bundle.evidence.length < 8 || bundle.evidence.length > 64) return fail("E1:evidence-count");
|
|
const ids = new Set<string>();
|
|
const byCriterion = new Map<MgCode, Record<string, unknown>[]>();
|
|
let prior = "";
|
|
for (const item of bundle.evidence) {
|
|
if (!validEvidenceItem(item)) return fail("E1:evidence-item");
|
|
const evidence = item as Record<string, unknown>;
|
|
const id = evidence.evidenceId as string;
|
|
const criterion = evidence.criterionCode as MgCode;
|
|
const order = `${criterion}\u0000${evidence.evidenceKind as string}\u0000${id}`;
|
|
if (ids.has(id) || order <= prior) return fail("E1:evidence-order");
|
|
const targetBinding = evidence.targetBinding as Record<string, unknown>;
|
|
if (targetBinding.baseHeadSha !== (bundle.target as Record<string, unknown>).baseHeadSha) return fail("E1:target-binding");
|
|
ids.add(id); prior = order;
|
|
byCriterion.set(criterion, [...(byCriterion.get(criterion) ?? []), evidence]);
|
|
}
|
|
if (!MG_CODES.every((code) => (byCriterion.get(code)?.length ?? 0) >= 1)) return fail("E1:criterion-coverage");
|
|
const security = closed(bundle.securityReview, ["triggered", "triggerPolicyDigest", "requiredEvidenceId"], "security");
|
|
if (!security || typeof security.triggered !== "boolean" || !sha(security.triggerPolicyDigest) || (security.triggered ? !uuid(security.requiredEvidenceId) || !ids.has(security.requiredEvidenceId as string) : security.requiredEvidenceId !== null)) return fail("E1:security");
|
|
if (security.triggered) {
|
|
const match = bundle.evidence.find((item) => (item as Record<string, unknown>).evidenceId === security.requiredEvidenceId) as Record<string, unknown> | undefined;
|
|
if (!match || match.criterionCode !== "MG03" || match.evidenceKind !== "security-review") return fail("E1:security-reference");
|
|
}
|
|
const observed = bundle.evidence.map((item) => (item as Record<string, unknown>).observedAt as string).sort().at(-1);
|
|
if (bundle.observedAt !== observed) return fail("E1:observed-at");
|
|
const expiries = bundle.evidence.map((item) => (item as Record<string, unknown>).expiresAt).filter((entry): entry is string => typeof entry === "string").sort();
|
|
if (expiries.length > 0 && bundle.expiresAt !== expiries[0]) return fail("E1:expires-at");
|
|
if (bundle.bundleSha256 !== sha256Canonical(omitTop(bundle, "bundleSha256"))) return fail("E1:bundle-digest");
|
|
return pass(bundle);
|
|
}
|
|
|
|
function validAuthority(value: unknown, actor: Record<string, unknown>): boolean {
|
|
const authority = closed(value, ["policyVersion", "policyDigest", "roleBindingId", "roleId", "roleRevision", "roleDigest", "intentId", "decisionId", "grantId", "invocationId", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken", "leaseExpiresAt"], "authority");
|
|
return !!authority && authority.policyVersion === 4 && sha(authority.policyDigest) && uuid(authority.roleBindingId) && authority.roleId === "gate-merge-ng" && authority.roleRevision === 1 && authority.roleDigest === actor.roleDigest && uuid(authority.intentId) && uuid(authority.decisionId) && uuid(authority.grantId) && uuid(authority.invocationId) && authority.assignmentId === actor.assignmentId && authority.assignmentRevision === actor.assignmentRevision && authority.leaseId === actor.leaseId && authority.leaseRevision === actor.leaseRevision && authority.fencingToken === actor.fencingToken && timestamp(authority.leaseExpiresAt);
|
|
}
|
|
function validTransition(value: unknown): value is Record<string, unknown> {
|
|
const transition = closed(value, ["domain", "objectId", "requestedFrom", "requestedTo", "expectedStateRevision", "previousState", "previousRevision", "resultingState", "resultingRevision"], "transition");
|
|
return !!transition && transition.domain === "gate" && transition.objectId === "git.mosaicstack.dev/mosaicstack/stack#1491:merge" && transition.requestedFrom === "reviewed" && transition.requestedTo === "authorized-integration" && positive(transition.expectedStateRevision) && transition.previousState === "reviewed" && transition.previousRevision === transition.expectedStateRevision && ["passed", "failed", "blocked"].includes(String(transition.resultingState)) && transition.resultingRevision === (transition.expectedStateRevision as number) + 1;
|
|
}
|
|
function validAuthorizedOperation(value: unknown): boolean {
|
|
return !!closed(value, ["capability", "host", "repository", "pullRequest", "headSha", "baseBranch", "strategy", "operationGrantId"], "operation") && exactFixed(value, { capability: "operation.merge", host: FIXED_TARGET.host, repository: FIXED_TARGET.repository, pullRequest: FIXED_TARGET.pullRequest, headSha: FIXED_TARGET.headSha, baseBranch: FIXED_TARGET.baseBranch, strategy: "squash", operationGrantId: null });
|
|
}
|
|
function exactFixed(value: unknown, expected: Record<string, unknown>): boolean {
|
|
const obj = closed(value, Object.keys(expected), "fixed");
|
|
return !!obj && Object.keys(expected).every((key) => obj[key] === expected[key]);
|
|
}
|
|
function derivedIdempotency(verdict: Record<string, unknown>): string {
|
|
const actor = verdict.actorContext as Record<string, unknown>;
|
|
const target = verdict.target as Record<string, unknown>;
|
|
const transition = verdict.transition as Record<string, unknown>;
|
|
const source = {
|
|
assignmentId: actor.assignmentId,
|
|
assignmentRevision: actor.assignmentRevision,
|
|
baseHeadSha: target.baseHeadSha,
|
|
expectedStateRevision: transition.expectedStateRevision,
|
|
headSha: target.headSha,
|
|
operationRequestId: verdict.operationRequestId,
|
|
requestedFrom: "reviewed",
|
|
requestedTo: "authorized-integration",
|
|
transitionId: verdict.transitionId,
|
|
};
|
|
return createHash("sha256").update("mosaic-gate-verdict-v1\n", "utf8").update(canonicalizeRfc8785(source), "utf8").digest("hex");
|
|
}
|
|
|
|
function validDecision(
|
|
value: unknown,
|
|
transition: Record<string, unknown>,
|
|
evidenceById: ReadonlyMap<string, Record<string, unknown>>,
|
|
): { ok: true } | { ok: false } {
|
|
const decision = closed(value, ["verdict", "criteria", "summary", "failedCriteria", "blockedCriteria", "reasonCodes", "authorizedNextOperation"], "decision");
|
|
if (!decision || !["PASS", "FAIL", "BLOCKED"].includes(String(decision.verdict)) || !text(decision.summary, 1, 500) || !Array.isArray(decision.criteria) || decision.criteria.length !== MG_CODES.length || !Array.isArray(decision.failedCriteria) || !Array.isArray(decision.blockedCriteria) || !stringSet(decision.reasonCodes, 8, (code) => typeof code === "string")) return { ok: false };
|
|
|
|
const expectedFailed: MgCode[] = [];
|
|
const expectedBlocked: MgCode[] = [];
|
|
const expectedReasonCodes: string[] = [];
|
|
const referencedIds = new Set<string>();
|
|
|
|
for (let index = 0; index < MG_CODES.length; index++) {
|
|
const criterion = closed(decision.criteria[index], ["code", "outcome", "reasonCode", "evidenceIds"], "criterion");
|
|
const code = MG_CODES[index];
|
|
if (!criterion || criterion.code !== code || !["pass", "fail", "blocked"].includes(String(criterion.outcome)) || !Array.isArray(criterion.evidenceIds) || criterion.evidenceIds.length < 1 || criterion.evidenceIds.length > 16 || !stringSet(criterion.evidenceIds, 16, uuid)) return { ok: false };
|
|
|
|
const evidence: Record<string, unknown>[] = [];
|
|
for (const id of criterion.evidenceIds) {
|
|
if (referencedIds.has(id)) return { ok: false };
|
|
const item = evidenceById.get(id);
|
|
if (!item || item.criterionCode !== code) return { ok: false };
|
|
referencedIds.add(id);
|
|
evidence.push(item);
|
|
}
|
|
|
|
const derived = deriveCriterion(code, evidence);
|
|
if (!derived || criterion.outcome !== derived.outcome || criterion.reasonCode !== derived.reasonCode) return { ok: false };
|
|
if (derived.outcome === "fail") expectedFailed.push(code);
|
|
if (derived.outcome === "blocked") expectedBlocked.push(code);
|
|
if (derived.reasonCode !== null) expectedReasonCodes.push(derived.reasonCode);
|
|
}
|
|
|
|
if (referencedIds.size !== evidenceById.size) return { ok: false };
|
|
if (!sameOrderedCodes(decision.failedCriteria, expectedFailed) || !sameOrderedCodes(decision.blockedCriteria, expectedBlocked)) return { ok: false };
|
|
const expectedReasons = [...new Set(expectedReasonCodes)].sort();
|
|
if (JSON.stringify(decision.reasonCodes) !== JSON.stringify(expectedReasons)) return { ok: false };
|
|
const derivedVerdict = expectedFailed.length > 0 ? "FAIL" : expectedBlocked.length > 0 ? "BLOCKED" : "PASS";
|
|
const expectedState = derivedVerdict === "PASS" ? "passed" : derivedVerdict === "FAIL" ? "failed" : "blocked";
|
|
if (decision.verdict !== derivedVerdict || transition.resultingState !== expectedState) return { ok: false };
|
|
if ((derivedVerdict === "PASS" && !validAuthorizedOperation(decision.authorizedNextOperation)) || (derivedVerdict !== "PASS" && decision.authorizedNextOperation !== null)) return { ok: false };
|
|
return { ok: true };
|
|
}
|
|
function sameOrderedCodes(value: unknown, expected: MgCode[]): boolean { return Array.isArray(value) && value.length === expected.length && value.every((code, index) => code === expected[index]); }
|
|
function validValidity(value: unknown): value is Record<string, unknown> {
|
|
const validity = closed(value, ["issuedAt", "expiresAt", "invalidationKeys"], "validity");
|
|
return !!validity && timestamp(validity.issuedAt) && timestamp(validity.expiresAt) && validInvalidationKeys(validity.invalidationKeys) && validity.issuedAt <= validity.expiresAt;
|
|
}
|
|
function sameCanonical(left: unknown, right: unknown): boolean {
|
|
try {
|
|
return canonicalizeRfc8785(left) === canonicalizeRfc8785(right);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* E2 is valid only at the broker's supplied submit time. Requiring the caller
|
|
* to supply that time prevents an offline E2 shape check from masquerading as
|
|
* an operation-authorizing validation.
|
|
*/
|
|
function validityBoundAtSubmit(
|
|
validity: Record<string, unknown>,
|
|
bundle: Record<string, unknown>,
|
|
authority: Record<string, unknown>,
|
|
submittedAt: unknown,
|
|
): boolean {
|
|
if (!timestamp(submittedAt)) return false;
|
|
const issuedAt = validity.issuedAt as string;
|
|
const expiresAt = validity.expiresAt as string;
|
|
const evidenceObservedAt = bundle.observedAt as string;
|
|
const evidenceExpiresAt = bundle.expiresAt as string;
|
|
const leaseExpiresAt = authority.leaseExpiresAt as string;
|
|
return issuedAt === submittedAt
|
|
&& evidenceObservedAt <= submittedAt
|
|
&& expiresAt <= evidenceExpiresAt
|
|
&& expiresAt <= leaseExpiresAt
|
|
&& submittedAt <= expiresAt
|
|
&& submittedAt <= evidenceExpiresAt
|
|
&& submittedAt <= leaseExpiresAt;
|
|
}
|
|
|
|
export function validateGateVerdict(value: unknown, bundle?: unknown, submittedAt?: unknown): GateRecordValidation {
|
|
const verdict = closed(value, ["schemaVersion", "recordType", "verdictId", "transitionId", "idempotencyKey", "operationRequestId", "actorContext", "missionContext", "authority", "transition", "target", "evidenceBundleId", "evidenceBundleSha256", "decision", "validity", "sensitivity", "recordSha256"], "verdict");
|
|
if (!verdict) return fail("E2:closed-map");
|
|
if (verdict.schemaVersion !== 1 || verdict.recordType !== "gate.merge.verdict" || !uuid(verdict.verdictId) || !uuid(verdict.transitionId) || !sha(verdict.idempotencyKey) || !uuid(verdict.operationRequestId) || !validActor(verdict.actorContext) || !validMission(verdict.missionContext) || !validTarget(verdict.target) || !uuid(verdict.evidenceBundleId) || !sha(verdict.evidenceBundleSha256) || !validValidity(verdict.validity) || verdict.sensitivity !== "internal" || !sha(verdict.recordSha256)) return fail("E2:scalar");
|
|
const actor = verdict.actorContext as Record<string, unknown>;
|
|
if (!validAuthority(verdict.authority, actor) || !validTransition(verdict.transition)) return fail("E2:authority-transition");
|
|
const authority = verdict.authority as Record<string, unknown>;
|
|
if (verdict.transitionId !== authority.invocationId || verdict.idempotencyKey !== derivedIdempotency(verdict)) return fail("E2:idempotency");
|
|
if (bundle === undefined) return fail("E2:bundle-required");
|
|
|
|
const bundleCheck = validateGateEvidenceBundle(bundle);
|
|
if (!bundleCheck.ok) return fail("E2:bundle-invalid");
|
|
const evidence = bundle as Record<string, unknown>;
|
|
if (
|
|
verdict.evidenceBundleId !== evidence.evidenceBundleId
|
|
|| verdict.evidenceBundleSha256 !== evidence.bundleSha256
|
|
|| verdict.operationRequestId !== evidence.operationRequestId
|
|
|| !sameCanonical(verdict.actorContext, evidence.actorContext)
|
|
|| !sameCanonical(verdict.missionContext, evidence.missionContext)
|
|
|| !sameCanonical(verdict.target, evidence.target)
|
|
) return fail("E2:bundle-binding");
|
|
if (!validityBoundAtSubmit(verdict.validity, evidence, authority, submittedAt)) return fail("E2:validity");
|
|
|
|
const evidenceById = new Map<string, Record<string, unknown>>((evidence.evidence as unknown[]).map((item) => [(item as Record<string, unknown>).evidenceId as string, item as Record<string, unknown>]));
|
|
const decision = validDecision(verdict.decision, verdict.transition as Record<string, unknown>, evidenceById);
|
|
if (!decision.ok) return fail("E2:decision");
|
|
try {
|
|
if (verdict.recordSha256 !== sha256Canonical(omitTop(verdict, "recordSha256"))) return fail("E2:record-digest");
|
|
} catch {
|
|
return fail("E2:record-digest");
|
|
}
|
|
return pass(verdict);
|
|
}
|
|
|
|
export function validateGateRecord(value: unknown): GateRecordValidation {
|
|
if (!isObject(value)) return fail("record:not-object");
|
|
if (value.recordType === "gate.merge.evidence") return validateGateEvidenceBundle(value);
|
|
if (value.recordType === "gate.merge.verdict") return validateGateVerdict(value);
|
|
return fail("record:unknown-type");
|
|
}
|
|
|
|
/** Parse untrusted JSON with duplicate-key, I-JSON, and closed-schema checks. */
|
|
export function parseGateRecordJson(raw: string): GateRecordValidation {
|
|
try {
|
|
assertNoDuplicateJsonKeys(raw);
|
|
return validateGateRecord(JSON.parse(raw));
|
|
} catch {
|
|
return fail("record:invalid-json");
|
|
}
|
|
}
|
|
|
|
function assertNoDuplicateJsonKeys(raw: string): void {
|
|
let index = 0;
|
|
const whitespace = (): void => { while (/\s/.test(raw[index] ?? "")) index++; };
|
|
const string = (): string => {
|
|
const start = index;
|
|
if (raw[index++] !== '"') throw new Error("string");
|
|
while (index < raw.length) {
|
|
const char = raw[index++];
|
|
if (char === '"') return JSON.parse(raw.slice(start, index));
|
|
if (char === "\\") { const escaped = raw[index++]; if (escaped === "u") index += 4; else if (escaped === undefined) throw new Error("escape"); }
|
|
else if (char < " ") throw new Error("control");
|
|
}
|
|
throw new Error("unterminated");
|
|
};
|
|
const value = (): void => {
|
|
whitespace(); const char = raw[index];
|
|
if (char === "{") { index++; whitespace(); const keys = new Set<string>(); if (raw[index] === "}") { index++; return; } while (true) { whitespace(); const key = string(); if (keys.has(key)) throw new Error("duplicate"); keys.add(key); whitespace(); if (raw[index++] !== ":") throw new Error("colon"); value(); whitespace(); if (raw[index] === "}") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
|
|
if (char === "[") { index++; whitespace(); if (raw[index] === "]") { index++; return; } while (true) { value(); whitespace(); if (raw[index] === "]") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
|
|
if (char === '"') { string(); return; }
|
|
const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(raw.slice(index));
|
|
if (!token) throw new Error("value");
|
|
index += token[0].length;
|
|
};
|
|
value(); whitespace(); if (index !== raw.length) throw new Error("trailing");
|
|
}
|