diff --git a/docs/architecture/D2-cockpit-evidence-freshness-projection.md b/docs/architecture/D2-cockpit-evidence-freshness-projection.md new file mode 100644 index 0000000..3e2f1c3 --- /dev/null +++ b/docs/architecture/D2-cockpit-evidence-freshness-projection.md @@ -0,0 +1,154 @@ +# Cockpit Evidence Freshness Projection (Cockpit D2) + +Status: V1 defaults. Superseded only by an explicit architecture decision. + +## Scope + +D2 adds exactly one responsibility to the Cockpit layer: project PR 004's +evidence-freshness answers for the evidence records contained in one +**already-validated** `CockpitSnapshot`. + + validated CockpitSnapshot + -> snapshot evidence read models + -> minimal EvidenceRecord reconstruction + -> EvidenceTarget derived from the enclosing snapshot + -> PR 004 evaluateEvidenceSet() + -> immutable Cockpit presentation projection + +There is no reverse arrow. D2 is presentation and observability only. + +D2 is **not**: hostile JSON validation, evidence authority, policy, merge +readiness, reviewer quorum, execution authority, repair authority, a collector, +persistence, or any Git/GitHub, filesystem, network, or subprocess I/O. + +## Trust boundary (Option A) + +D2 accepts only an already-valid D1 `CockpitSnapshot`. + +- **D1 owns hostile `unknown` input.** JSON-shaped, unknown-provenance data goes + through `readCockpitSnapshot()`, which validates or rejects it. +- **D2 owns the projection of a valid snapshot.** Its public API takes a + `CockpitSnapshot`, never `unknown`. +- **D2 does not duplicate `readCockpitSnapshot`.** It adds no second + `invalidFields` envelope, no malformed-snapshot handling, no null/primitive + input semantics, no non-array evidence semantics, and no throwing-getter + validation. Those belong to D1. + +Consequently a zero-result projection means exactly one thing: the valid +snapshot contains zero evidence records. Malformed input never projects as a +legitimate empty evidence set, because malformed input never reaches D2. + +For a contract-valid snapshot, `projectCockpitEvidenceFreshness` is pure, +deterministic, synchronous, non-mutating, side-effect free, and returns a +deeply immutable value. Behaviour for values forced through an unsafe TypeScript +cast is intentionally undefined — that is separation of responsibilities, not a +missing defence, and no validation branch is added to support it. + +## Freshness authority + +PR 004 (`src/domain/evidence-freshness.ts`) is the freshness authority. D2 only +projects freshness: + +- it never compares SHAs and never decides `CURRENT` / `STALE` / `INVALID`; +- it copies PR 004's `state`, `reason`, and `invalidFields` verbatim; +- it reuses `EvidenceRecord`, `EvidenceKind`, `EvidenceSource` from + `evidence.ts` and `EvidenceTarget`, `evaluateEvidenceSet`, `FreshnessState`, + `FreshnessReason`, and `FRESHNESS` from `evidence-freshness.ts` (the + `FRESHNESS_REASON` vocabulary reaches the projection verbatim through the + kernel's answers), and re-declares none of them. + +## Evidence and target reconstruction + +For every `CockpitEvidenceReadModel` the minimum `EvidenceRecord` is rebuilt: + + { evidenceId, repositoryId, commitSha, kind, source, reference, observedAt } + +**`repositoryId` is injected from the enclosing snapshot** +(`snapshot.repository.repositoryId`). A D1 snapshot describes exactly one +repository, so per-element repository fields are neither present nor added to +the D1 read model. No metadata is attached. + +Exactly one `EvidenceTarget` is built from snapshot identity: + + { repositoryId: snapshot.repository.repositoryId, + currentHeadSha: snapshot.repository.observedHeadSha } + +**`observedHeadSha` is the only target HEAD.** Both identity values are read +once into locals and every record is evaluated against the same target. HEAD is +never inferred from an evidence `commitSha`, an `advisoryFreshness` echo, +finding data, a pull-request observation, reviewer output, or metadata. + +## Finding freshness is out of scope + +D2 neither reads nor recomputes `snapshot.findings[*].advisoryFreshness`, and +fabricates no evidence provenance from findings. D2 is evidence-record +freshness projection only; finding freshness remains a separate concern. + +## Output + + projectCockpitEvidenceFreshness(snapshot: CockpitSnapshot) + : CockpitEvidenceFreshnessProjection + +| Type | Fields | +| --- | --- | +| `CockpitEvidenceFreshnessItem` | `evidenceId`, `kind`, `source`, `commitSha`, `state`, `reason`, `invalidFields` | +| `CockpitEvidenceFreshnessCounts` | `current`, `stale`, `invalid`, `total` | +| `CockpitEvidenceFreshnessProjection` | `repositoryId`, `observedHeadSha`, `results`, `counts` | + +- `results[i]` corresponds to `snapshot.evidence[i]`: input order preserved, + nothing sorted, deduplicated, filtered, or dropped. +- `counts.total === results.length` and + `counts.current + counts.stale + counts.invalid === counts.total`. +- No `current[]` / `stale[]` / `invalid[]` buckets: they would duplicate + derivable presentation data. +- **`INVALID` is part of the domain vocabulary** and `counts.invalid` keeps the + projection structurally faithful to PR 004, **but it is not expected from a + valid D1 snapshot under the current schema**: D1 guarantees non-null identity + and structurally valid evidence, and repository identity is injected from the + same snapshot, so `REPOSITORY_MISMATCH`, `EVALUATION_TARGET_INVALID`, and + `EVIDENCE_MALFORMED` are unreachable through contractual D2 input. D2 still + copies whatever PR 004 returns without reinterpretation. + +## Authority model + +The projection is immutable presentation state. It carries no decision, +permit, approval, authority, merge-readiness, quorum, or repair field, and the +Cockpit architecture invariant admits exactly one non-`read*` public function — +`projectCockpitEvidenceFreshness` — without granting a general `project*` +namespace. No collector, persistence, or I/O is introduced. + +## Bounds and immutability + +- **No new bound beyond D1.** D2 is bounded by D1's + `COCKPIT_BOUNDS.MAX_EVIDENCE_RECORDS` (1,000) and projects every record. +- The returned projection is deeply frozen, detached from the caller's + snapshot, and contains only primitives and frozen records/lists; it survives + `JSON.parse(JSON.stringify(projection))` with its enumerable data unchanged. +- Although the input is trusted, the realm may be mutated between D1 + validation and D2 projection. D2 captures the intrinsics it relies on + (`Object.freeze`, `Object.defineProperty`, `Object.setPrototypeOf`) at module + load, builds lists by own-element definition (no `push`, `map`, `filter`, + spread, or iterator), gives its descriptors a `null` prototype before + `defineProperty` consumes them, gives returned records a `null` prototype, and + shadows `toJSON` on returned lists — so a poisoned `Object.prototype` or + `Array.prototype` cannot reach the projection or its JSON form. This is realm + robustness, not input validation: no D1 field is re-validated. + +## Modules + +| Module | Responsibility | +| --- | --- | +| `src/cockpit/evidence-freshness-projection.ts` | D2 projection types and `projectCockpitEvidenceFreshness` | +| `src/cockpit/index.ts` | Public re-export of the D2 contract | + +## Tests + +`tests/cockpit/evidence-freshness-projection.test.ts` covers CURRENT / STALE +projection, exact ordering and counts, parity with a direct +`evaluateEvidenceSet()` call, repository-identity injection, `observedHeadSha` +as the only target HEAD, evidence-as-HEAD refusal, finding independence, the +empty and D1-maximum cases, the no-INVALID-from-reconstruction property, deep +immutability, input non-mutation, determinism, JSON round trip, ambient +`Object.prototype` / `Array.prototype` / intrinsic-replacement robustness, and +absence of authority-shaped keys. `tests/cockpit/architecture-invariants.test.ts` +keeps the source-purity and single-exception export rules. diff --git a/src/cockpit/evidence-freshness-projection.ts b/src/cockpit/evidence-freshness-projection.ts new file mode 100644 index 0000000..0874706 --- /dev/null +++ b/src/cockpit/evidence-freshness-projection.ts @@ -0,0 +1,296 @@ +/** + * Cockpit evidence-freshness projection (Cockpit D2). + * + * Projects PR 004's freshness answers for the evidence records contained in + * one **already-validated** {@link CockpitSnapshot}: + * + * validated CockpitSnapshot + * -> snapshot evidence read models + * -> minimal EvidenceRecord reconstruction + * -> EvidenceTarget derived from the enclosing snapshot + * -> PR 004 evaluateEvidenceSet() + * -> immutable Cockpit presentation projection + * + * There is no reverse arrow. This module is presentation/observability only: + * no evidence authority, no policy, no merge readiness, no reviewer quorum, no + * execution or repair authority, no collector, no persistence, and no + * filesystem, network, Git/GitHub, or subprocess access. + * + * ## Trust boundary (Option A) + * + * The input is a `CockpitSnapshot` that has already passed D1's read boundary + * (`readCockpitSnapshot`). D1 owns hostile, JSON-shaped `unknown` input and its + * rejection; D2 owns only the projection of a valid snapshot. This module is + * deliberately **not** a second `readCockpitSnapshot`: it adds no `invalidFields` + * envelope, no malformed-snapshot handling, and no validation branches. A + * zero-result projection therefore means exactly one thing — the valid snapshot + * contains zero evidence records. Behaviour for values forced through an unsafe + * cast is intentionally undefined; that separation of responsibilities is the + * design, not a missing defence. + * + * ## Freshness authority + * + * PR 004 (`evidence-freshness.ts`) is the only freshness authority. D2 never + * compares a SHA, never decides `CURRENT`/`STALE`/`INVALID` on its own, and + * copies `state`, `reason`, and `invalidFields` verbatim from the kernel. The + * evaluation target is built from the enclosing snapshot's identity alone: + * `repository.repositoryId` and `repository.observedHeadSha`. Nothing inside an + * evidence record, finding, pull request, or provenance block can become HEAD. + * + * Finding `advisoryFreshness` is out of scope and is never read. + * + * ## Ambient-realm robustness + * + * Although the input is trusted, the JavaScript realm may be mutated between + * D1 validation and D2 projection. Every intrinsic this module relies on is + * captured at load; no `Array.prototype` method, spread, or iterator is on the + * path; returned records carry a `null` prototype and returned lists shadow + * `toJSON`, so a poisoned `Object.prototype` cannot reach the projection or its + * JSON form. This is realm robustness, not input validation — no D1 field is + * re-validated here. + */ + +import type { EvidenceKind, EvidenceRecord, EvidenceSource } from '../domain/evidence.js'; +import { + evaluateEvidenceSet, + FRESHNESS, + type EvidenceTarget, + type FreshnessReason, + type FreshnessState, +} from '../domain/evidence-freshness.js'; +import type { CockpitSnapshot } from './read-model.js'; + +/** + * Intrinsics captured at module load, before any ambient mutation that could + * follow D1 validation. Everything below uses these captured references or + * depends on no prototype method at all. + */ +const objectFreeze = Object.freeze; +const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; + +/** One evidence record's projected freshness, in `snapshot.evidence` order. */ +export interface CockpitEvidenceFreshnessItem { + readonly evidenceId: string; + readonly kind: EvidenceKind; + readonly source: EvidenceSource; + /** The commit the evidence is bound to. Data, never the evaluation HEAD. */ + readonly commitSha: string; + /** PR 004's state, verbatim. */ + readonly state: FreshnessState; + /** PR 004's reason, verbatim. */ + readonly reason: FreshnessReason; + /** PR 004's invalid-field list, verbatim (empty for a valid D1 snapshot). */ + readonly invalidFields: readonly string[]; +} + +/** + * Summary counts over `results`. `invalid` mirrors the complete PR 004 + * vocabulary; a contract-valid D1 snapshot is expected to yield `0` there. + */ +export interface CockpitEvidenceFreshnessCounts { + readonly current: number; + readonly stale: number; + readonly invalid: number; + readonly total: number; +} + +/** + * The projection: flat, input-ordered results plus summary counts. No + * `current[]`/`stale[]`/`invalid[]` buckets — they would only duplicate + * derivable presentation data. + */ +export interface CockpitEvidenceFreshnessProjection { + /** Injected from `snapshot.repository.repositoryId`. */ + readonly repositoryId: string; + /** The only target HEAD: `snapshot.repository.observedHeadSha`. */ + readonly observedHeadSha: string; + /** `results[i]` corresponds to `snapshot.evidence[i]`. Never sorted, filtered, or deduplicated. */ + readonly results: readonly CockpitEvidenceFreshnessItem[]; + readonly counts: CockpitEvidenceFreshnessCounts; +} + +/** + * Make a D2-owned descriptor immune to an inherited `Object.prototype.get` / + * `.set`. `ToPropertyDescriptor` walks the prototype chain, so an ordinary + * `{...}` descriptor under a poisoned realm would present accessor keys beside + * its own data keys and be rejected by `Object.defineProperty`. + */ +function dataDescriptor(value: unknown, enumerable: boolean): PropertyDescriptor { + const descriptor: PropertyDescriptor = { + value, + writable: false, + enumerable, + configurable: false, + }; + objectSetPrototypeOf(descriptor, null); + return descriptor; +} + +/** Append by defining an own element: no `push`, no inherited index setter. */ +function append(list: T[], value: T): void { + objectDefineProperty(list, list.length, dataDescriptor(value, true)); +} + +/** + * Detach a D2 record node from the live `Object.prototype` (so a poisoned + * inherited `toJSON` cannot reach it) and freeze it. + */ +function freezeRecord(record: T): Readonly { + objectSetPrototypeOf(record, null); + return objectFreeze(record); +} + +/** + * Freeze a D2 list node. Lists keep `Array.prototype` for consumers, so the + * inherited `toJSON` is shadowed by an own, non-enumerable, non-callable + * `undefined` that `JSON.stringify` skips. Enumeration and structural equality + * are unaffected. + */ +function freezeList(list: T[]): readonly T[] { + objectDefineProperty(list, 'toJSON', dataDescriptor(undefined, false)); + return objectFreeze(list); +} + +/** Copy PR 004's `invalidFields` into a D2-owned frozen list, element by element. */ +function copyInvalidFields(source: readonly string[]): readonly string[] { + const copy: string[] = []; + const length = source.length; + for (let index = 0; index < length; index += 1) { + const field = source[index]; + if (field !== undefined) { + append(copy, field); + } + } + return freezeList(copy); +} + +/** + * Project evidence freshness for one valid Cockpit snapshot. + * + * Pure, deterministic, synchronous, side-effect free, and non-mutating. The + * returned projection is deeply frozen and fully detached from the caller's + * snapshot, contains only primitives and frozen records/lists, and survives + * `JSON.parse(JSON.stringify(...))` with its enumerable data unchanged. + * + * Bounded by D1's `COCKPIT_BOUNDS.MAX_EVIDENCE_RECORDS`; D2 adds no bound of + * its own and drops no record. + * + * @param snapshot A `CockpitSnapshot` already accepted by `readCockpitSnapshot`. + */ +export function projectCockpitEvidenceFreshness( + snapshot: CockpitSnapshot, +): CockpitEvidenceFreshnessProjection { + // Snapshot identity is read exactly once. Every record below is evaluated + // against these same two locals; no evidence commit can become the target. + const repository = snapshot.repository; + const repositoryId = repository.repositoryId; + const observedHeadSha = repository.observedHeadSha; + + const target: EvidenceTarget = freezeRecord({ + repositoryId, + currentHeadSha: observedHeadSha, + }); + + // The evidence list reference is read once; each element once. + const evidence = snapshot.evidence; + const evidenceLength = evidence.length; + + const records: EvidenceRecord[] = []; + const evidenceIds: string[] = []; + const kinds: EvidenceKind[] = []; + const sources: EvidenceSource[] = []; + const commitShas: string[] = []; + + for (let index = 0; index < evidenceLength; index += 1) { + const item = evidence[index]; + if (item === undefined) { + continue; + } + const evidenceId = item.evidenceId; + const kind = item.kind; + const source = item.source; + const commitSha = item.commitSha; + + // Exactly the minimum EvidenceRecord. Repository identity is injected from + // the enclosing snapshot, which describes exactly one repository. + append( + records, + freezeRecord({ + evidenceId, + repositoryId, + commitSha, + kind, + source, + reference: item.reference, + observedAt: item.observedAt, + }), + ); + append(evidenceIds, evidenceId); + append(kinds, kind); + append(sources, source); + append(commitShas, commitSha); + } + + // PR 004 is the freshness authority. Its per-record answers are copied + // verbatim; nothing is reinterpreted, promoted, sorted, or dropped. + const evaluation = evaluateEvidenceSet(records, target); + const evaluated = evaluation.results; + + const results: CockpitEvidenceFreshnessItem[] = []; + let current = 0; + let stale = 0; + let invalid = 0; + + const resultLength = evaluated.length; + for (let index = 0; index < resultLength; index += 1) { + const answer = evaluated[index]; + const evidenceId = evidenceIds[index]; + const kind = kinds[index]; + const source = sources[index]; + const commitSha = commitShas[index]; + if ( + answer === undefined || + evidenceId === undefined || + kind === undefined || + source === undefined || + commitSha === undefined + ) { + continue; + } + const state = answer.state; + if (state === FRESHNESS.CURRENT) { + current += 1; + } else if (state === FRESHNESS.STALE) { + stale += 1; + } else { + invalid += 1; + } + append( + results, + freezeRecord({ + evidenceId, + kind, + source, + commitSha, + state, + reason: answer.reason, + invalidFields: copyInvalidFields(answer.invalidFields), + }), + ); + } + + const counts: CockpitEvidenceFreshnessCounts = freezeRecord({ + current, + stale, + invalid, + total: results.length, + }); + + return freezeRecord({ + repositoryId, + observedHeadSha, + results: freezeList(results), + counts, + }); +} diff --git a/src/cockpit/index.ts b/src/cockpit/index.ts index ee49abf..eeb9782 100644 --- a/src/cockpit/index.ts +++ b/src/cockpit/index.ts @@ -5,8 +5,20 @@ * Derived representation only: no authority, no persistence, no I/O, and no * duplication of domain truth — domain vocabularies are imported, never * re-declared. + * + * D2 adds one projection over an already-validated snapshot: + * {@link projectCockpitEvidenceFreshness}. It consumes a `CockpitSnapshot` that + * passed D1's read boundary and echoes PR 004's freshness answers; it is not a + * second reader and accepts no `unknown` input. */ +export { + projectCockpitEvidenceFreshness, + type CockpitEvidenceFreshnessCounts, + type CockpitEvidenceFreshnessItem, + type CockpitEvidenceFreshnessProjection, +} from './evidence-freshness-projection.js'; + export { COCKPIT_BOUNDS, COCKPIT_FINDING_DISPOSITION, diff --git a/tests/cockpit/architecture-invariants.test.ts b/tests/cockpit/architecture-invariants.test.ts index 803868a..b69b638 100644 --- a/tests/cockpit/architecture-invariants.test.ts +++ b/tests/cockpit/architecture-invariants.test.ts @@ -17,6 +17,9 @@ import * as cockpit from '../../src/cockpit/index.js'; const cockpitDir = fileURLToPath(new URL('../../src/cockpit/', import.meta.url)); +/** The single non-`read*` public function the Cockpit layer may export (D2). */ +const D2_PROJECTION_EXPORT = 'projectCockpitEvidenceFreshness'; + function cockpitSources(): readonly { readonly file: string; readonly text: string }[] { return readdirSync(cockpitDir) .filter((name) => name.endsWith('.ts')) @@ -72,8 +75,12 @@ describe('D1 exported surface grants no authority', () => { for (const [name, value] of Object.entries(cockpit)) { if (typeof value === 'function') { // Every exported function is a pure reader by naming convention and by - // contract; nothing exported authorizes, executes, persists, or grants. - expect(name.startsWith('read'), `unexpected non-reader export: ${name}`).toBe(true); + // contract, with exactly one named D2 exception: the freshness + // projection over an already-validated snapshot. No general `project*` + // namespace is granted; any other non-`read*` function is rejected. + // Nothing exported authorizes, executes, persists, or grants. + const allowed = name.startsWith('read') || name === D2_PROJECTION_EXPORT; + expect(allowed, `unexpected non-reader export: ${name}`).toBe(true); } else if (typeof value === 'object') { expect(Object.isFrozen(value), `unfrozen exported constant: ${name}`).toBe(true); } @@ -97,4 +104,13 @@ describe('D1 exported surface grants no authority', () => { expect(Object.hasOwn(result, key)).toBe(false); } }); + + it('the D2 projection export is exactly the one named function', () => { + expect(typeof cockpit[D2_PROJECTION_EXPORT]).toBe('function'); + const nonReaderFunctions = Object.entries(cockpit) + .filter(([, value]) => typeof value === 'function') + .map(([name]) => name) + .filter((name) => !name.startsWith('read')); + expect(nonReaderFunctions).toEqual([D2_PROJECTION_EXPORT]); + }); }); diff --git a/tests/cockpit/evidence-freshness-projection.test.ts b/tests/cockpit/evidence-freshness-projection.test.ts new file mode 100644 index 0000000..6181220 --- /dev/null +++ b/tests/cockpit/evidence-freshness-projection.test.ts @@ -0,0 +1,628 @@ +import { describe, expect, it } from 'vitest'; + +import { + projectCockpitEvidenceFreshness, + readCockpitSnapshot, + type CockpitEvidenceFreshnessProjection, + type CockpitEvidenceReadModel, + type CockpitSnapshot, +} from '../../src/cockpit/index.js'; +import type { EvidenceRecord } from '../../src/domain/evidence.js'; +import { + evaluateEvidenceSet, + FRESHNESS, + FRESHNESS_REASON, +} from '../../src/domain/evidence-freshness.js'; +import { + buildEvidence, + buildFinding, + buildRepository, + buildSnapshot, + HEAD_A, + HEAD_B, + REPO_A, +} from './read-model-fixtures.js'; + +/* ------------------------------------------------------------------------- + * Fixtures: every input below is a snapshot that D1 has actually accepted + * (Option A trust boundary). No malformed snapshot is ever handed to D2. + * ------------------------------------------------------------------------- */ + +/** Pass a fixture through D1's read boundary and assert it was accepted. */ +function validSnapshot(overrides: Partial = {}): CockpitSnapshot { + const result = readCockpitSnapshot(buildSnapshot(overrides)); + expect(result.invalidFields).toEqual([]); + expect(result.snapshot).not.toBeNull(); + return result.snapshot as CockpitSnapshot; +} + +const CURRENT_EVIDENCE = buildEvidence({ evidenceId: 'ev-current', commitSha: HEAD_A }); +const STALE_EVIDENCE = buildEvidence({ + evidenceId: 'ev-stale', + kind: 'code-review', + source: 'agent', + commitSha: HEAD_B, + reference: 'review-77', +}); + +/** Recursively assert every object/array node is frozen. */ +function expectDeepFrozen(value: unknown, path = 'projection'): void { + if (typeof value !== 'object' || value === null) { + return; + } + expect(Object.isFrozen(value), `${path} must be frozen`).toBe(true); + for (const key of Object.keys(value)) { + expectDeepFrozen((value as Record)[key], `${path}.${key}`); + } +} + +/* ------------------------------------------------------------------------- + * Core freshness projection + * ------------------------------------------------------------------------- */ + +describe('projectCockpitEvidenceFreshness — core projection', () => { + it('1. projects one CURRENT evidence item as CURRENT / BOUND_TO_CURRENT_HEAD', () => { + const projection = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE] }), + ); + + expect(projection.results).toHaveLength(1); + expect(projection.results[0]).toEqual({ + evidenceId: 'ev-current', + kind: 'ci-result', + source: 'github', + commitSha: HEAD_A, + state: FRESHNESS.CURRENT, + reason: FRESHNESS_REASON.BOUND_TO_CURRENT_HEAD, + invalidFields: [], + }); + expect(projection.counts).toEqual({ current: 1, stale: 0, invalid: 0, total: 1 }); + }); + + it('2. projects one STALE evidence item as STALE / COMMIT_SHA_MISMATCH', () => { + const projection = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [STALE_EVIDENCE] }), + ); + + expect(projection.results).toHaveLength(1); + expect(projection.results[0]).toEqual({ + evidenceId: 'ev-stale', + kind: 'code-review', + source: 'agent', + commitSha: HEAD_B, + state: FRESHNESS.STALE, + reason: FRESHNESS_REASON.COMMIT_SHA_MISMATCH, + invalidFields: [], + }); + expect(projection.counts).toEqual({ current: 0, stale: 1, invalid: 0, total: 1 }); + }); + + it('3. projects a mixed CURRENT + STALE snapshot with exact ordered results and counts', () => { + const projection = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [STALE_EVIDENCE, CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + + expect(projection.results.map((item) => [item.evidenceId, item.state])).toEqual([ + ['ev-stale', FRESHNESS.STALE], + ['ev-current', FRESHNESS.CURRENT], + ['ev-stale', FRESHNESS.STALE], + ]); + expect(projection.counts).toEqual({ current: 1, stale: 2, invalid: 0, total: 3 }); + expect(projection.counts.current + projection.counts.stale + projection.counts.invalid).toBe( + projection.counts.total, + ); + expect(projection.counts.total).toBe(projection.results.length); + }); + + it('4. state/reason/invalidFields equal a direct evaluateEvidenceSet() over the same snapshot', () => { + const snapshot = validSnapshot({ + evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE, buildEvidence({ evidenceId: 'ev-3' })], + }); + const projection = projectCockpitEvidenceFreshness(snapshot); + + const records: EvidenceRecord[] = snapshot.evidence.map((item) => ({ + evidenceId: item.evidenceId, + repositoryId: snapshot.repository.repositoryId, + commitSha: item.commitSha, + kind: item.kind, + source: item.source, + reference: item.reference, + observedAt: item.observedAt, + })); + const direct = evaluateEvidenceSet(records, { + repositoryId: snapshot.repository.repositoryId, + currentHeadSha: snapshot.repository.observedHeadSha, + }); + + expect(projection.results).toHaveLength(direct.results.length); + for (let index = 0; index < direct.results.length; index += 1) { + const projected = projection.results[index]; + const kernel = direct.results[index]; + expect(projected?.state).toBe(kernel?.state); + expect(projected?.reason).toBe(kernel?.reason); + expect(projected?.invalidFields).toEqual(kernel?.invalidFields); + expect(projected?.evidenceId).toBe(kernel?.evidenceId); + expect(projected?.commitSha).toBe(kernel?.commitSha); + expect(projected?.kind).toBe(kernel?.kind); + expect(projected?.source).toBe(kernel?.source); + } + expect(projection.counts).toEqual({ + current: direct.current.length, + stale: direct.stale.length, + invalid: direct.invalid.length, + total: direct.results.length, + }); + }); +}); + +/* ------------------------------------------------------------------------- + * Target identity comes only from the enclosing snapshot + * ------------------------------------------------------------------------- */ + +describe('projectCockpitEvidenceFreshness — target identity', () => { + it('5. injects repositoryId from snapshot.repository.repositoryId', () => { + const OTHER_REPO = 'github.com/LogicDuke/other'; + const snapshot = validSnapshot({ + repository: buildRepository({ repositoryId: OTHER_REPO }), + evidence: [CURRENT_EVIDENCE], + }); + const projection = projectCockpitEvidenceFreshness(snapshot); + + expect(projection.repositoryId).toBe(OTHER_REPO); + // The injected identity is what the kernel compares against, so the record + // is about this repository and evaluates CURRENT — never REPOSITORY_MISMATCH. + expect(projection.results[0]?.state).toBe(FRESHNESS.CURRENT); + expect(projection.results[0]?.reason).toBe(FRESHNESS_REASON.BOUND_TO_CURRENT_HEAD); + }); + + it('6. uses snapshot.repository.observedHeadSha as EvidenceTarget.currentHeadSha', () => { + const snapshot = validSnapshot({ + repository: buildRepository({ observedHeadSha: HEAD_B }), + evidence: [STALE_EVIDENCE], + }); + const projection = projectCockpitEvidenceFreshness(snapshot); + + expect(projection.observedHeadSha).toBe(HEAD_B); + // STALE_EVIDENCE is bound to HEAD_B, so against an observed HEAD_B it is CURRENT. + expect(projection.results[0]?.state).toBe(FRESHNESS.CURRENT); + }); + + it('7. changing observedHeadSha flips freshness exactly as the domain kernel dictates', () => { + const atA = projectCockpitEvidenceFreshness( + validSnapshot({ + repository: buildRepository({ observedHeadSha: HEAD_A }), + evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE], + }), + ); + const atB = projectCockpitEvidenceFreshness( + validSnapshot({ + repository: buildRepository({ observedHeadSha: HEAD_B }), + evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE], + }), + ); + + expect(atA.results.map((item) => item.state)).toEqual([FRESHNESS.CURRENT, FRESHNESS.STALE]); + expect(atB.results.map((item) => item.state)).toEqual([FRESHNESS.STALE, FRESHNESS.CURRENT]); + expect(atA.counts).toEqual({ current: 1, stale: 1, invalid: 0, total: 2 }); + expect(atB.counts).toEqual({ current: 1, stale: 1, invalid: 0, total: 2 }); + }); + + it('8. an evidence commitSha can never become HEAD authority', () => { + // Every record agrees on HEAD_B; the snapshot observed HEAD_A. Agreement + // among records is not a HEAD, so all of them are STALE. + const snapshot = validSnapshot({ + repository: buildRepository({ observedHeadSha: HEAD_A }), + evidence: [ + buildEvidence({ evidenceId: 'e1', commitSha: HEAD_B }), + buildEvidence({ evidenceId: 'e2', commitSha: HEAD_B, kind: 'repository-state' }), + buildEvidence({ evidenceId: 'e3', commitSha: HEAD_B, kind: 'human-decision' }), + ], + }); + const projection = projectCockpitEvidenceFreshness(snapshot); + + expect(projection.observedHeadSha).toBe(HEAD_A); + expect(projection.results.every((item) => item.state === FRESHNESS.STALE)).toBe(true); + expect(projection.counts).toEqual({ current: 0, stale: 3, invalid: 0, total: 3 }); + }); + + it('9. findings and advisoryFreshness have no effect on the projection', () => { + const withoutFindings = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE], findings: [] }), + ); + const withContradictingFindings = projectCockpitEvidenceFreshness( + validSnapshot({ + evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE], + findings: [ + buildFinding({ findingId: 'f1', reviewedCommitSha: HEAD_B, advisoryFreshness: 'CURRENT' }), + buildFinding({ findingId: 'f2', reviewedCommitSha: HEAD_A, advisoryFreshness: 'STALE' }), + buildFinding({ findingId: 'f3', reviewedCommitSha: HEAD_B, advisoryFreshness: 'INVALID' }), + ], + }), + ); + + expect(withContradictingFindings).toEqual(withoutFindings); + expect(Object.keys(withContradictingFindings)).toEqual([ + 'repositoryId', + 'observedHeadSha', + 'results', + 'counts', + ]); + }); +}); + +/* ------------------------------------------------------------------------- + * Ordering, emptiness, and bounds + * ------------------------------------------------------------------------- */ + +describe('projectCockpitEvidenceFreshness — order and bounds', () => { + it('10. preserves snapshot.evidence input order, without sorting or deduplication', () => { + const evidence: CockpitEvidenceReadModel[] = [ + buildEvidence({ evidenceId: 'z', commitSha: HEAD_B }), + buildEvidence({ evidenceId: 'a', commitSha: HEAD_A }), + buildEvidence({ evidenceId: 'z', commitSha: HEAD_B }), + buildEvidence({ evidenceId: 'm', commitSha: HEAD_A }), + ]; + const snapshot = validSnapshot({ evidence }); + const projection = projectCockpitEvidenceFreshness(snapshot); + + expect(projection.results.map((item) => item.evidenceId)).toEqual(['z', 'a', 'z', 'm']); + for (let index = 0; index < snapshot.evidence.length; index += 1) { + expect(projection.results[index]?.evidenceId).toBe(snapshot.evidence[index]?.evidenceId); + expect(projection.results[index]?.commitSha).toBe(snapshot.evidence[index]?.commitSha); + } + }); + + it('11. an empty VALID evidence list projects to [] with all counts 0', () => { + const projection = projectCockpitEvidenceFreshness(validSnapshot({ evidence: [] })); + + expect(projection.results).toEqual([]); + expect(projection.counts).toEqual({ current: 0, stale: 0, invalid: 0, total: 0 }); + expect(projection.repositoryId).toBe(REPO_A); + expect(projection.observedHeadSha).toBe(HEAD_A); + }); + + it('12. projects all 1,000 records of a D1-maximum snapshot in order, with no new D2 bound', () => { + const evidence: CockpitEvidenceReadModel[] = []; + for (let index = 0; index < 1_000; index += 1) { + evidence.push( + buildEvidence({ + evidenceId: `ev-${String(index)}`, + commitSha: index % 2 === 0 ? HEAD_A : HEAD_B, + }), + ); + } + const snapshot = validSnapshot({ evidence }); + expect(snapshot.evidence).toHaveLength(1_000); + + const projection = projectCockpitEvidenceFreshness(snapshot); + + expect(projection.results).toHaveLength(1_000); + expect(projection.counts).toEqual({ current: 500, stale: 500, invalid: 0, total: 1_000 }); + for (let index = 0; index < 1_000; index += 1) { + expect(projection.results[index]?.evidenceId).toBe(`ev-${String(index)}`); + expect(projection.results[index]?.state).toBe( + index % 2 === 0 ? FRESHNESS.CURRENT : FRESHNESS.STALE, + ); + } + }); + + it('13. a contract-valid D1 snapshot never becomes INVALID through D2 reconstruction', () => { + const kinds = [ + 'ci-result', + 'code-review', + 'security-review', + 'test-result', + 'repository-state', + 'human-decision', + ] as const; + const sources = ['github', 'local-verification', 'agent', 'human'] as const; + const evidence: CockpitEvidenceReadModel[] = []; + for (const kind of kinds) { + for (const source of sources) { + evidence.push( + buildEvidence({ evidenceId: `${kind}/${source}/A`, kind, source, commitSha: HEAD_A }), + ); + evidence.push( + buildEvidence({ evidenceId: `${kind}/${source}/B`, kind, source, commitSha: HEAD_B }), + ); + } + } + const projection = projectCockpitEvidenceFreshness(validSnapshot({ evidence })); + + expect(projection.results).toHaveLength(evidence.length); + expect(projection.counts.invalid).toBe(0); + for (const item of projection.results) { + expect(item.state).not.toBe(FRESHNESS.INVALID); + expect(item.invalidFields).toEqual([]); + expect([FRESHNESS_REASON.BOUND_TO_CURRENT_HEAD, FRESHNESS_REASON.COMMIT_SHA_MISMATCH]).toContain( + item.reason, + ); + } + }); +}); + +/* ------------------------------------------------------------------------- + * Immutability, purity, determinism, JSON + * ------------------------------------------------------------------------- */ + +describe('projectCockpitEvidenceFreshness — immutability and purity', () => { + it('14. returns a deeply immutable projection', () => { + const projection = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + + expectDeepFrozen(projection); + expect(Object.isFrozen(projection.results)).toBe(true); + expect(Object.isFrozen(projection.counts)).toBe(true); + for (const item of projection.results) { + expect(Object.isFrozen(item)).toBe(true); + expect(Object.isFrozen(item.invalidFields)).toBe(true); + for (const value of Object.values(item)) { + expect(typeof value).not.toBe('function'); + } + } + expect(() => { + (projection as { results: unknown }).results = []; + }).toThrow(TypeError); + expect(() => { + (projection.results as unknown[]).push(null); + }).toThrow(TypeError); + expect(() => { + (projection.results[0] as { state: string }).state = 'CURRENT'; + }).toThrow(TypeError); + expect(() => { + (projection.counts as { current: number }).current = 99; + }).toThrow(TypeError); + }); + + it('14b. is detached from the caller snapshot — shares no object reference', () => { + const snapshot = validSnapshot({ evidence: [CURRENT_EVIDENCE] }); + const projection = projectCockpitEvidenceFreshness(snapshot); + + expect(projection.results).not.toBe(snapshot.evidence); + expect(projection.results[0]).not.toBe(snapshot.evidence[0]); + expect(projection.counts).not.toBe(snapshot.repository); + }); + + it('15. does not mutate the input snapshot', () => { + const raw = buildSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }); + const before = JSON.stringify(raw); + const snapshot = validSnapshot(raw); + const snapshotBefore = JSON.stringify(snapshot); + + projectCockpitEvidenceFreshness(snapshot); + + expect(JSON.stringify(raw)).toBe(before); + expect(JSON.stringify(snapshot)).toBe(snapshotBefore); + expect(Object.keys(snapshot.evidence[0] ?? {})).not.toContain('repositoryId'); + expect(Object.keys(snapshot.evidence[0] ?? {})).not.toContain('state'); + }); + + it('16. two equal valid inputs yield structurally equal projections', () => { + const first = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + const second = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + + expect(first).toEqual(second); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(first).not.toBe(second); + }); + + it('17. survives a JSON round trip with its enumerable data unchanged', () => { + const projection = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + const roundTripped = JSON.parse(JSON.stringify(projection)) as CockpitEvidenceFreshnessProjection; + + expect(roundTripped).toEqual(projection); + expect(Object.keys(roundTripped)).toEqual(Object.keys(projection)); + expect(roundTripped.results.map((item) => Object.keys(item))).toEqual( + projection.results.map((item) => Object.keys(item)), + ); + }); +}); + +/* ------------------------------------------------------------------------- + * Ambient-realm mutation after D1 validation + * ------------------------------------------------------------------------- */ + +// Test-side intrinsics captured before any test swaps them, so install and +// restore keep working while `Object.defineProperty` itself is replaced. +const realDefineProperty = Object.defineProperty; +const realGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const realDeleteProperty = Reflect.deleteProperty; + +function withPrototypeProperty( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, + body: () => T, +): T { + const original = realGetOwnPropertyDescriptor(target, key); + // The helper's own descriptor must not inherit a `get`/`set` that a nested + // call has already installed on `Object.prototype`, so it is built with a + // `null` prototype — the same insulation D2 gives its descriptors. + const install: PropertyDescriptor = Object.assign(Object.create(null) as PropertyDescriptor, descriptor, { + configurable: true, + }); + realDefineProperty(target, key, install); + try { + return body(); + } finally { + if (original === undefined) { + realDeleteProperty(target, key); + } else { + realDefineProperty(target, key, original); + } + } +} + +function withReplacedIntrinsic( + holder: object, + key: PropertyKey, + replacement: unknown, + body: () => T, +): T { + return withPrototypeProperty( + holder, + key, + { value: replacement, writable: true, enumerable: false }, + body, + ); +} + +describe('projectCockpitEvidenceFreshness — ambient prototype mutation', () => { + const expected = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + const expectedJson = JSON.stringify(expected); + + it('18. a hostile Object.prototype.toJSON cannot alter the projection or its JSON form', () => { + const snapshot = validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }); + const POISON = '__poisoned__'; + const { projection, json } = withPrototypeProperty( + Object.prototype, + 'toJSON', + { value: () => POISON, writable: true, enumerable: false }, + () => { + const projection = projectCockpitEvidenceFreshness(snapshot); + return { projection, json: JSON.stringify(projection) }; + }, + ); + + expect(json).toBe(expectedJson); + expect(json).not.toContain(POISON); + expect(projection).toEqual(expected); + expect(Object.getPrototypeOf(projection)).toBeNull(); + expect(Object.getPrototypeOf(projection.results[0])).toBeNull(); + expect(Object.getPrototypeOf(projection.counts)).toBeNull(); + expect(Object.getOwnPropertyDescriptor(projection.results, 'toJSON')).toEqual({ + value: undefined, + writable: false, + enumerable: false, + configurable: false, + }); + // Realm restored. + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'toJSON')).toBeUndefined(); + }); + + it('19. hostile Object.prototype.get / set cannot break D2-owned descriptors', () => { + const snapshot = validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }); + const accessor = { value: () => undefined, writable: true, enumerable: false }; + const projection = withPrototypeProperty(Object.prototype, 'get', accessor, () => + withPrototypeProperty(Object.prototype, 'set', accessor, () => + projectCockpitEvidenceFreshness(snapshot), + ), + ); + + expect(projection).toEqual(expected); + expect(JSON.stringify(projection)).toBe(expectedJson); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'get')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'set')).toBeUndefined(); + }); + + it('19b. inherited numeric setters and poisoned Array.prototype methods cannot affect results', () => { + const snapshot = validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }); + const throwing = () => { + throw new Error('poisoned prototype method'); + }; + const projection = withPrototypeProperty( + Object.prototype, + '0', + { set: throwing, get: () => 'inherited', enumerable: false }, + () => + withPrototypeProperty( + Array.prototype, + '1', + { set: throwing, get: () => 'inherited', enumerable: false }, + () => + withReplacedIntrinsic(Array.prototype, 'push', throwing, () => + withReplacedIntrinsic(Array.prototype, 'map', throwing, () => + withReplacedIntrinsic(Array.prototype, 'filter', throwing, () => + withReplacedIntrinsic(Array.prototype, Symbol.iterator, throwing, () => + projectCockpitEvidenceFreshness(snapshot), + ), + ), + ), + ), + ), + ); + + expect(Object.hasOwn(projection.results, '0')).toBe(true); + expect(Object.hasOwn(projection.results, '1')).toBe(true); + expect(projection).toEqual(expected); + expect(JSON.stringify(projection)).toBe(expectedJson); + }); + + it('20. post-module-load replacement of Object intrinsics does not change D2 behaviour', () => { + const snapshot = validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }); + const noFreeze = (value: T): T => value; + const throwing = () => { + throw new Error('replaced intrinsic'); + }; + const projection = withReplacedIntrinsic(Object, 'freeze', noFreeze, () => + withReplacedIntrinsic(Object, 'defineProperty', throwing, () => + withReplacedIntrinsic(Object, 'setPrototypeOf', throwing, () => + projectCockpitEvidenceFreshness(snapshot), + ), + ), + ); + + // Captured references were used: the output is still frozen, detached from + // Object.prototype, and equal to the unpoisoned projection. + expectDeepFrozen(projection); + expect(Object.getPrototypeOf(projection)).toBeNull(); + expect(projection).toEqual(expected); + expect(JSON.stringify(projection)).toBe(expectedJson); + expect(Object.freeze).not.toBe(noFreeze); + }); +}); + +/* ------------------------------------------------------------------------- + * Authority leakage + * ------------------------------------------------------------------------- */ + +describe('projectCockpitEvidenceFreshness — no authority', () => { + it('21. carries no authority-shaped key anywhere in the projection', () => { + const forbidden = [ + 'decision', + 'permit', + 'approval', + 'approved', + 'mayExecuteOnce', + 'authority', + 'mergeReady', + 'mayMerge', + ]; + const projection = projectCockpitEvidenceFreshness( + validSnapshot({ evidence: [CURRENT_EVIDENCE, STALE_EVIDENCE] }), + ); + const nodes: object[] = [projection, projection.counts, projection.results, ...projection.results]; + for (const node of nodes) { + for (const key of forbidden) { + expect(Object.hasOwn(node, key), `forbidden key ${key}`).toBe(false); + } + } + expect(Object.keys(projection)).toEqual(['repositoryId', 'observedHeadSha', 'results', 'counts']); + expect(Object.keys(projection.counts)).toEqual(['current', 'stale', 'invalid', 'total']); + expect(Object.keys(projection.results[0] ?? {})).toEqual([ + 'evidenceId', + 'kind', + 'source', + 'commitSha', + 'state', + 'reason', + 'invalidFields', + ]); + }); + + it('exposes no current/stale/invalid buckets', () => { + const projection = projectCockpitEvidenceFreshness(validSnapshot()); + expect(Object.hasOwn(projection, 'current')).toBe(false); + expect(Object.hasOwn(projection, 'stale')).toBe(false); + expect(Object.hasOwn(projection, 'invalid')).toBe(false); + }); +});