From 4dc33d25b7010faa3356d0fb0931a61692a6d1e8 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 01:56:09 +0300 Subject: [PATCH 1/2] feat: add fixture control pulse --- docs/BUILD_LOG.md | 9 + docs/PROJECT_STATE.md | 9 +- extension/src/pulse/capsule.ts | 110 ++++++++++ extension/src/pulse/catalog.ts | 166 +++++++++++++++ extension/src/pulse/run.ts | 222 ++++++++++++++++++++ extension/src/pulse/types.ts | 114 +++++++++++ extension/src/pulse/validate.ts | 241 ++++++++++++++++++++++ extension/test/control-pulse.test.ts | 295 +++++++++++++++++++++++++++ 8 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 extension/src/pulse/capsule.ts create mode 100644 extension/src/pulse/catalog.ts create mode 100644 extension/src/pulse/run.ts create mode 100644 extension/src/pulse/types.ts create mode 100644 extension/src/pulse/validate.ts create mode 100644 extension/test/control-pulse.test.ts diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 4b07108..4343423 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,6 +2,15 @@ This is a concise chronological record of material implementation work and runtime evidence. It is not a substitute for Git history; it captures intent, verification, and blockers that a commit alone may not explain. +## 2026-07-31 — R4.5 fixture-only Control Pulse local candidate + +- Implemented exact bounded Change Claim, participant/internal probe, Side Coach capsule/proposal, attempt, and result contracts. Claim, probe, attempt, command result, and probe result identities use their normative domain-separated hashes. +- Added one immutable Explain-to-Break catalog input for the reviewed tenant-isolation fixture. The participant commits a prediction before observation; execution can resolve only the catalog-owned `mutated` state and declared tenant-isolation check through `TrustedFixtureRunner`. +- Kept controller bindings out of participant serialization and model context. The Side Coach receives only capped participant-visible excerpts with evidence IDs, local paths, and known credential shapes removed; its output cannot become code, paths, arguments, environment, or tests. +- Six R4.5 tests pass locally on Windows. They cover golden hashes, exact projection, evidence/privacy bounds, model proposal isolation, precommit ordering, result invariance to prose, catalog-only execution, replay/cross-project/late rejection, and timeout/cancellation/runner-error invalidation. The complete extension suite passes 63/63; TypeScript, production build, and VSIX packaging pass. Protected Linux/Windows verification is pending. + +Evidence: `extension/src/pulse/`, `extension/test/control-pulse.test.ts`, and local command/package output on 2026-07-31. + ## 2026-07-31 — R4 Experience Compiler and Phase-A Judge accepted - Added deterministic seam selection over fully supported R2 evidence and compiled the real R1/R2 fixture path into separate internal and participant recovery manifests. The participant projection is exact-schema and omits source revisions, run identity, setup, judge internals, hidden repair, production paths, and controller handles. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index a82922c..a46c0c3 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,7 +2,7 @@ Last updated: 2026-07-31 -## Current branch milestone — R4 Experience Compiler and Judge accepted +## Current branch milestone — R4.5 fixture-only Control Pulse local candidate Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -24,7 +24,10 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - R4 now compiles one fully attributed, bounded, test-backed seam from real R1 replay plus R2 change evidence into separate internal and participant recovery manifests. The participant projection excludes source revisions, run identity, judge internals, setup, hidden repair, production paths, and controller handles. - The Phase-A Evidence Judge reopens the immutable snapshot and command registry, verifies the standalone Git boundary, computes the participant diff, rejects protected or out-of-scope changes, and evaluates only the exact catalog-owned known repair in a clean evaluation twin. No-op and unrelated candidates fail without execution; reveal and abandonment produce no executable readiness evidence. - Seven R4 tests cover deterministic compilation, strict projection/schema rejection, unsupported and fixture-drift rejection, protected-path integrity, no-op/unrelated non-execution, three identical clean replays, reveal/abandon honesty, and project-scoped persistent command evidence with tamper detection. The full local Windows extension suite passes 57/57; `npm run check`, production build, and VSIX packaging pass. Protected PR #14 run `30670322126` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R4 acceptance is complete. -- The Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. R4 remains a closed reviewed-fixture mechanism and does not execute arbitrary participant or workspace code. +- R4.5 now implements bounded `ChangeClaim`, participant/internal probe, Side Coach capsule/proposal, attempt, and result contracts with exact schemas and domain-separated hashes. The deterministic no-model path commits a developer prediction before observation and resolves only one extension-owned fixture state/check pair. +- Participant serialization contains no fixture binding, command, state, revision, oracle, controller handle, or production path. Capsule construction reopens only participant-visible project evidence, caps and scrubs excerpts, and treats every model proposal as non-executable text plus an optional existing input ID. +- Six R4.5 tests cover cross-platform golden claim/probe/attempt hashes, strict projection and catalog isolation, unstable/cross-project/hidden/malformed evidence rejection, capsule path/secret/ID redaction, precommitted prediction, prose-invariant observation, replay/late/identity failures, and timeout/cancellation/runner-error invalidation. The full local Windows extension suite passes 63/63; `npm run check`, production build, and VSIX packaging pass. Protected branch verification is pending. +- The readiness ledger and v0.3 cockpit do not exist yet. R0–R4.5 remain a closed reviewed-fixture mechanism and do not execute arbitrary participant or workspace code. - No skill-retention or speed metric has been measured. Values in the PRD are predeclared R&D targets. - A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization; R0a/R0b now implement and verify that complete substrate. - A guarded Jules dispatcher and PR policy are defined as a finite R0→R4 queue. They create at most one session after a successful preflight, stop after merged R4, remain inert unless dispatch is explicitly enabled, and keep plan approval on by default. Merges remain manual because the current project tests are not an independent immutable verifier. Full scheduled continuation still requires the dispatcher workflow to be reviewed into the default branch. @@ -142,7 +145,7 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Pass R4.5: one bounded, catalog-only Explain-to-Break Pulse with replay/error fail-closed tests. +1. Obtain protected Linux/Windows acceptance for the R4.5 Explain-to-Break Control Pulse. 2. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. 3. Add the local readiness ledger and minimal cockpit only after the vertical slice is reliable. 4. Run the preregistered delayed-transfer pilot before making any skill-retention claim. diff --git a/extension/src/pulse/capsule.ts b/extension/src/pulse/capsule.ts new file mode 100644 index 0000000..216aad9 --- /dev/null +++ b/extension/src/pulse/capsule.ts @@ -0,0 +1,110 @@ +import { assertBoundedText, assertObjectShape, assertRecord } from "../agent/types"; +import { assertExactKeys, assertSha256, canonicalJson } from "../rnd/canonical"; +import type { + ParticipantControlProbe, + SideCoachCapsule, + SideCoachProposal, + ValidatedClaim, +} from "./types"; +import { assertParticipantControlProbe, assertProbeSurface } from "./validate"; + +const windowsPath = /(?:[A-Za-z]:\\|\\\\)[^\s"'<>]+/g; +const unixPath = /(?:^|[\s("'])(\/(?:home|Users|opt|var|tmp|workspace|repo)\/[^\s"')<>]+)/g; +const secret = /\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16})\b/g; + +export async function buildSideCoachCapsule( + participant: ParticipantControlProbe, + validated: ValidatedClaim, + developerAnswer: string, +): Promise { + assertParticipantControlProbe(participant); + assertBoundedText(developerAnswer, 4096, "developer answer"); + if (canonicalJson(participant.claim) !== canonicalJson(validated.claim)) { + throw new Error("Side Coach claim projection changed"); + } + if (participant.internalProbeHash.length !== 64) throw new Error("Side Coach probe binding is invalid"); + + const ids = new Set(validated.evidence.map(({ ref }) => ref.id)); + const evidence = validated.evidence.slice(0, 4).map(({ ref, content }) => ({ + kind: ref.kind, + sha256: ref.sha256, + mediaType: ref.mediaType, + excerpt: truncateUtf8(scrub(content, ids), 2048), + })); + const claim = { + intent: validated.claim.intent, + changedBehavior: validated.claim.changedBehavior, + boundary: structuredClone(validated.claim.boundary), + invariant: validated.claim.invariant, + ...(validated.claim.unresolvedAssumption === undefined + ? {} + : { unresolvedAssumption: validated.claim.unresolvedAssumption }), + }; + const capsule: SideCoachCapsule = { + schemaVersion: 1, + claimHash: validated.claimHash, + claim, + evidence, + probe: structuredClone(participant.participant), + developerAnswer, + }; + assertSideCoachCapsule(capsule); + if (Buffer.byteLength(canonicalJson(capsule)) > 16_384) throw new Error("Side Coach capsule exceeds 16384 bytes"); + return capsule; +} + +export function validateSideCoachProposal(value: unknown, capsule: SideCoachCapsule): SideCoachProposal { + assertSideCoachCapsule(capsule); + assertRecord(value, "Side Coach proposal"); + assertObjectShape(value, ["schemaVersion", "hypothesis"], ["probeInputId", "clarification"], "Side Coach proposal"); + if (value.schemaVersion !== 1) throw new Error("Unsupported Side Coach proposal"); + assertBoundedText(value.hypothesis, 2048, "Side Coach hypothesis"); + if (value.probeInputId !== undefined) { + assertBoundedText(value.probeInputId, 128, "Side Coach probe input ID"); + if (!capsule.probe.inputs.some(({ id }) => id === value.probeInputId)) { + throw new Error("Side Coach proposal selected an unknown probe input"); + } + } + if (value.clarification !== undefined) assertBoundedText(value.clarification, 2048, "Side Coach clarification"); + if (Buffer.byteLength(canonicalJson(value)) > 4096) throw new Error("Side Coach proposal exceeds 4096 bytes"); + return structuredClone(value) as unknown as SideCoachProposal; +} + +function assertSideCoachCapsule(value: unknown): asserts value is SideCoachCapsule { + assertRecord(value, "Side Coach capsule"); + assertExactKeys(value, ["schemaVersion", "claimHash", "claim", "evidence", "probe", "developerAnswer"], "Side Coach capsule"); + if (value.schemaVersion !== 1) throw new Error("Unsupported Side Coach capsule"); + assertSha256(String(value.claimHash), "claimHash"); + assertRecord(value.claim, "Side Coach claim"); + assertObjectShape(value.claim, ["intent", "changedBehavior", "boundary", "invariant"], ["unresolvedAssumption"], "Side Coach claim"); + assertBoundedText(value.claim.intent, 1024, "claim intent"); + assertBoundedText(value.claim.changedBehavior, 1024, "changed behavior"); + assertBoundedText(value.claim.invariant, 1024, "claim invariant"); + if (value.claim.unresolvedAssumption !== undefined) assertBoundedText(value.claim.unresolvedAssumption, 1024, "unresolved assumption"); + assertRecord(value.claim.boundary, "claim boundary"); + assertExactKeys(value.claim.boundary, ["path", "symbol"], "claim boundary"); + if (!Array.isArray(value.evidence) || value.evidence.length > 4) throw new Error("Side Coach evidence is invalid"); + for (const item of value.evidence) { + assertRecord(item, "Side Coach evidence item"); + assertExactKeys(item, ["kind", "sha256", "mediaType", "excerpt"], "Side Coach evidence item"); + assertSha256(String(item.sha256), "evidence hash"); + assertBoundedText(item.mediaType, 128, "evidence media type"); + assertBoundedText(item.excerpt, 2048, "evidence excerpt"); + } + assertProbeSurface(value.probe); + assertBoundedText(value.developerAnswer, 4096, "developer answer"); +} + +function scrub(value: string, ids: Set): string { + let text = value.replace(windowsPath, "[local-path]"); + text = text.replace(unixPath, (match, path: string) => match.replace(path, "[local-path]")); + text = text.replace(secret, "[secret]"); + for (const id of ids) text = text.replaceAll(id, "[evidence]"); + return text; +} + +function truncateUtf8(value: string, maxBytes: number): string { + let result = value; + while (Buffer.byteLength(result) > maxBytes) result = result.slice(0, -1); + return result; +} diff --git a/extension/src/pulse/catalog.ts b/extension/src/pulse/catalog.ts new file mode 100644 index 0000000..4e8fda8 --- /dev/null +++ b/extension/src/pulse/catalog.ts @@ -0,0 +1,166 @@ +import { assertToken } from "../agent/types"; +import { assertExactKeys, assertSha256, canonicalHash, canonicalJson, compareUtf8 } from "../rnd/canonical"; +import { EXPECTED_TENANT_CACHE_KEY } from "../twin/fixture-factory"; +import type { + CatalogControlProbe, + FixtureControlProbe, + ParticipantControlProbe, + ParticipantProbeSurface, + ValidatedClaim, +} from "./types"; +import { + assertCatalogControlProbe, + assertFixtureControlProbe, + assertParticipantControlProbe, + assertProbeSurface, +} from "./validate"; + +interface CatalogEntry { + probe: CatalogControlProbe; + label: string; +} + +const entries: CatalogEntry[] = [{ + probe: { + schemaVersion: 1, + id: "tenant-isolation-boundary", + fixtureId: "tenant-cache-key", + fixtureManifestHash: EXPECTED_TENANT_CACHE_KEY.manifestHash, + state: "mutated", + checkId: "cache-key.tenant-isolation", + prompt: "Before the check runs: will tenant isolation hold for two tenants sharing the same id?", + }, + label: "Run the tenant-isolation boundary check", +}]; + +export class FixtureControlProbeCatalog { + list(fixtureId: string, fixtureManifestHash: string): CatalogControlProbe[] { + const values = entries + .filter(({ probe }) => probe.fixtureId === fixtureId && probe.fixtureManifestHash === fixtureManifestHash) + .map(({ probe }) => structuredClone(probe)) + .sort((left, right) => compareUtf8(left.id, right.id)); + values.forEach(assertCatalogControlProbe); + return values; + } + + open(id: string, fixtureId: string, fixtureManifestHash: string): CatalogControlProbe | undefined { + const entry = entries.find(({ probe }) => + probe.id === id && probe.fixtureId === fixtureId && probe.fixtureManifestHash === fixtureManifestHash, + ); + if (!entry) return undefined; + assertCatalogControlProbe(entry.probe); + return structuredClone(entry.probe); + } + + surface(fixtureId: string, fixtureManifestHash: string): ParticipantProbeSurface { + const selected = entries + .filter(({ probe }) => probe.fixtureId === fixtureId && probe.fixtureManifestHash === fixtureManifestHash) + .sort((left, right) => compareUtf8(left.probe.id, right.probe.id)); + if (selected.length === 0) throw new Error("Fixture has no allowlisted control probes"); + const prompts = new Set(selected.map(({ probe }) => probe.prompt)); + if (prompts.size !== 1) throw new Error("Catalog probe prompts do not form one committed surface"); + const surface = { + prompt: selected[0]!.probe.prompt, + inputs: selected.map(({ probe, label }) => ({ id: probe.id, label })), + }; + assertProbeSurface(surface); + return surface; + } +} + +export interface PublishedFixtureProbe { + internal: FixtureControlProbe; + internalProbeHash: string; + participant: ParticipantControlProbe; +} + +export class FixtureControlProbeStore { + private readonly values = new Map(); + + publish(value: PublishedFixtureProbe): PublishedFixtureProbe { + assertPublished(value); + const key = `${value.internal.projectId}/${value.internal.id}/${value.internalProbeHash}`; + const current = this.values.get(key); + if (current && canonicalJson(current) !== canonicalJson(value)) { + throw new Error("Published control probe is immutable"); + } + this.values.set(key, structuredClone(value)); + return structuredClone(value); + } + + open(projectId: string, id: string, hash: string): PublishedFixtureProbe | undefined { + const value = this.values.get(`${projectId}/${id}/${hash}`); + return value ? structuredClone(value) : undefined; + } +} + +export function publishFixtureControlProbe(input: { + id: string; + projectId: string; + claim: ValidatedClaim; + sourceTreeHash: string; + catalog: FixtureControlProbeCatalog; + store: FixtureControlProbeStore; +}): PublishedFixtureProbe { + assertToken(input.id, "probeId"); + assertToken(input.projectId, "projectId"); + assertSha256(input.sourceTreeHash, "sourceTreeHash"); + const fixtureId = "tenant-cache-key"; + const fixtureManifestHash = EXPECTED_TENANT_CACHE_KEY.manifestHash; + const internal: FixtureControlProbe = { + schemaVersion: 1, + mode: "fixture", + id: input.id, + projectId: input.projectId, + claimHash: input.claim.claimHash, + sourceTreeHash: input.sourceTreeHash, + fixtureId, + fixtureManifestHash, + participant: input.catalog.surface(fixtureId, fixtureManifestHash), + }; + assertFixtureControlProbe(internal); + const internalProbeHash = canonicalHash("control-probe", internal); + const participant: ParticipantControlProbe = { + schemaVersion: 1, + id: internal.id, + internalProbeHash, + claim: structuredClone(input.claim.claim), + participant: structuredClone(internal.participant), + }; + assertParticipantControlProbe(participant); + return input.store.publish({ internal, internalProbeHash, participant }); +} + +export function serializeParticipantControlProbe(value: ParticipantControlProbe): string { + assertParticipantControlProbe(value); + return canonicalJson(value); +} + +export function assertParticipantProjection( + published: PublishedFixtureProbe, + participant: ParticipantControlProbe, +): void { + assertPublished(published); + assertParticipantControlProbe(participant); + if (canonicalJson(participant) !== canonicalJson(published.participant)) { + throw new Error("Participant control probe projection changed"); + } +} + +function assertPublished(value: PublishedFixtureProbe): void { + assertExactKeys(value, ["internal", "internalProbeHash", "participant"], "published fixture probe"); + assertFixtureControlProbe(value.internal); + assertSha256(value.internalProbeHash, "internalProbeHash"); + assertParticipantControlProbe(value.participant); + if (canonicalHash("control-probe", value.internal) !== value.internalProbeHash) { + throw new Error("Internal control probe hash changed"); + } + if ( + value.participant.id !== value.internal.id || + value.participant.internalProbeHash !== value.internalProbeHash || + canonicalHash("control-claim", value.participant.claim) !== value.internal.claimHash || + canonicalJson(value.participant.participant) !== canonicalJson(value.internal.participant) + ) { + throw new Error("Participant control probe projection changed"); + } +} diff --git a/extension/src/pulse/run.ts b/extension/src/pulse/run.ts new file mode 100644 index 0000000..afbc6ae --- /dev/null +++ b/extension/src/pulse/run.ts @@ -0,0 +1,222 @@ +import { randomUUID } from "node:crypto"; +import { canonicalHash, canonicalJson } from "../rnd/canonical"; +import type { EvidenceRef } from "../recorder/events"; +import { BuiltinFixtureCatalog } from "../twin/catalog"; +import { TrustedFixtureProcessRunner } from "../twin/commands"; +import type { FixtureBlobRef } from "../twin/fixture-contract"; +import { TwinManager } from "../twin/manager"; +import { FixtureSnapshotStore } from "../twin/snapshot"; +import type { CommandResult, TwinSession } from "../twin/types"; +import { + assertParticipantProjection, + FixtureControlProbeCatalog, + FixtureControlProbeStore, + type PublishedFixtureProbe, +} from "./catalog"; +import type { + CatalogControlProbe, + ControlProbeAttempt, + ControlProbeResult, + ParticipantControlProbe, +} from "./types"; +import { assertControlProbeAttempt, assertControlProbeResult } from "./validate"; + +interface CommittedAttempt { + attempt: ControlProbeAttempt; + attemptHash: string; + published: PublishedFixtureProbe; +} + +export class FixtureControlPulse { + private readonly attempts = new Map(); + private readonly tombstones = new Set(); + + constructor( + private readonly catalog: FixtureControlProbeCatalog, + private readonly probes: FixtureControlProbeStore, + private readonly fixtures: BuiltinFixtureCatalog, + private readonly snapshots: FixtureSnapshotStore, + private readonly twins: TwinManager, + private readonly runner: TrustedFixtureProcessRunner, + private readonly options: { now?: () => Date; maxAgeMs?: number } = {}, + ) {} + + async commit(participant: ParticipantControlProbe, attempt: ControlProbeAttempt): Promise<{ + attempt: ControlProbeAttempt; + attemptHash: string; + }> { + assertControlProbeAttempt(attempt); + const key = attemptKey(attempt.projectId, attempt.id); + if (this.attempts.has(key) || this.tombstones.has(key)) throw new Error("Control probe attempt ID was reused"); + const published = this.probes.open(attempt.projectId, attempt.probeId, attempt.internalProbeHash); + if (!published) throw new Error("Control probe is unknown for project"); + assertParticipantProjection(published, participant); + if ( + attempt.claimHash !== published.internal.claimHash || + attempt.sourceTreeHash !== published.internal.sourceTreeHash || + attempt.probeId !== published.internal.id + ) { + throw new Error("Control probe attempt identity changed"); + } + if (!published.internal.participant.inputs.some(({ id }) => id === attempt.selectedProbeInputId)) { + throw new Error("Control probe attempt selected an unknown input"); + } + const evidence = new Set(participant.claim.evidenceRefs.map(({ id }) => id)); + if (attempt.selectedEvidenceIds.some((id) => !evidence.has(id))) { + throw new Error("Control probe attempt selected unknown evidence"); + } + const attemptHash = canonicalHash("control-probe-attempt", attempt); + this.attempts.set(key, { + attempt: structuredClone(attempt), + attemptHash, + published: structuredClone(published), + }); + return { attempt: structuredClone(attempt), attemptHash }; + } + + async execute(projectId: string, attemptId: string, attemptHash: string): Promise { + const key = attemptKey(projectId, attemptId); + if (this.tombstones.has(key)) throw new Error("Control probe attempt replay was rejected"); + const committed = this.attempts.get(key); + if (!committed) { + if ([...this.attempts.values()].some(({ attempt }) => attempt.id === attemptId)) { + throw new Error("Control probe attempt belongs to another project"); + } + throw new Error("Control probe attempt is unknown"); + } + if (committed.attemptHash !== attemptHash) throw new Error("Control probe attempt hash changed"); + const { attempt, published } = committed; + const selected = this.resolveSelected(published, attempt.selectedProbeInputId); + const binding = await this.resolveBinding(published, selected); + + this.attempts.delete(key); + this.tombstones.add(key); + if (!binding) return terminalResult(attempt, attemptHash, selected, null, "failed-integrity"); + + const age = (this.options.now ?? (() => new Date()))().getTime() - Date.parse(attempt.committedAt); + if (age < 0 || age > (this.options.maxAgeMs ?? 10 * 60_000)) { + return terminalResult(attempt, attemptHash, selected, binding.commandId, "failed-integrity"); + } + + let twin: TwinSession | undefined; + try { + const mutation = evidenceFromFixture(binding.record.manifest.mutation.changeRef); + const target = binding.record.manifest.states.find((state) => state.id === "target")!; + const snapshot = await this.snapshots.create({ + projectId, + sourceRevision: binding.record.manifest.targetRevision, + mutationId: binding.record.manifest.mutation.id, + mutation, + allowedFiles: target.files.map(({ path }) => path), + }); + twin = await this.twins.prepare(projectId, snapshot.id); + const command = await this.runner.run({ + executionId: randomUUID().replaceAll("-", ""), + projectId, + fixtureId: selected.fixtureId, + manifestHash: selected.fixtureManifestHash, + stateId: selected.state, + commandId: binding.commandId, + twinHandle: twin.handle, + }); + return observedResult(attempt, attemptHash, selected, binding.commandId, command); + } catch { + return terminalResult(attempt, attemptHash, selected, binding.commandId, "execution-error"); + } finally { + if (twin) await this.twins.cleanup(projectId, twin.handle); + } + } + + private resolveSelected(published: PublishedFixtureProbe, inputId: string): CatalogControlProbe { + const selected = this.catalog.open(inputId, published.internal.fixtureId, published.internal.fixtureManifestHash); + if (!selected) throw new Error("Committed probe input left the immutable catalog"); + const surface = this.catalog.surface(published.internal.fixtureId, published.internal.fixtureManifestHash); + if (canonicalJson(surface) !== canonicalJson(published.internal.participant)) { + throw new Error("Committed probe surface changed"); + } + return selected; + } + + private async resolveBinding(published: PublishedFixtureProbe, selected: CatalogControlProbe) { + const record = await this.fixtures.open(selected.fixtureId, selected.fixtureManifestHash); + if (!record) return undefined; + const target = record.manifest.states.find((state) => state.id === "target"); + const state = record.manifest.states.find((item) => item.id === selected.state); + const check = record.manifest.checks.find((item) => item.id === selected.checkId); + if ( + !target || published.internal.sourceTreeHash !== target.treeHash || + !state || !check || !state.commandIds.includes(check.commandId) + ) { + return undefined; + } + return { record, commandId: check.commandId }; + } +} + +function observedResult( + attempt: ControlProbeAttempt, + attemptHash: string, + selected: CatalogControlProbe, + commandId: string, + command: CommandResult, +): ControlProbeResult { + if (command.timedOut || command.cancelled || command.exitCode === null) { + return terminalResult(attempt, attemptHash, selected, commandId, "execution-error", command); + } + const observation = command.exitCode === 0 ? "passes" : "fails"; + return terminalResult(attempt, attemptHash, selected, commandId, observation, command); +} + +function terminalResult( + attempt: ControlProbeAttempt, + attemptHash: string, + selected: CatalogControlProbe, + commandId: string | null, + observation: ControlProbeResult["observation"], + command?: CommandResult, +): ControlProbeResult { + const observed = observation === "passes" || observation === "fails"; + const core = { + schemaVersion: 1 as const, + projectId: attempt.projectId, + probeId: attempt.probeId, + internalProbeHash: attempt.internalProbeHash, + claimHash: attempt.claimHash, + sourceTreeHash: attempt.sourceTreeHash, + attemptHash, + probeInputId: selected.id, + checkId: selected.checkId, + commandId: commandId ?? "unresolved-command", + observation, + commandStatus: { + exitCode: command?.exitCode ?? null, + timedOut: command?.timedOut ?? false, + cancelled: command?.cancelled ?? false, + }, + predictionResult: observed + ? (attempt.prediction === observation ? "confirmed" as const : "falsified" as const) + : "invalid" as const, + commandResultHash: command ? canonicalHash("command-result", command) : null, + }; + const value = { ...core, resultHash: canonicalHash("control-probe-result", core) }; + assertControlProbeResult(value); + return value; +} + +function evidenceFromFixture(ref: FixtureBlobRef): EvidenceRef { + return { + id: ref.id, + kind: "diff", + sha256: ref.sha256, + storedBytes: ref.storedBytes, + originalBytes: ref.storedBytes, + truncated: false, + redactions: [], + mediaType: ref.mediaType, + visibility: "controller", + }; +} + +function attemptKey(projectId: string, id: string): string { + return `${projectId}/${id}`; +} diff --git a/extension/src/pulse/types.ts b/extension/src/pulse/types.ts new file mode 100644 index 0000000..5d861fd --- /dev/null +++ b/extension/src/pulse/types.ts @@ -0,0 +1,114 @@ +import type { EvidenceKind, EvidenceRef } from "../recorder/events"; + +export interface ChangeClaim { + schemaVersion: 1; + checkpointId: string; + intent: string; + changedBehavior: string; + boundary: { path: string; symbol: string }; + invariant: string; + evidenceRefs: Array<{ id: string; sha256: string; visibility: "participant" }>; + unresolvedAssumption?: string; +} + +export interface ParticipantProbeSurface { + prompt: string; + inputs: Array<{ id: string; label: string }>; +} + +export interface SideCoachCapsule { + schemaVersion: 1; + claimHash: string; + claim: { + intent: string; + changedBehavior: string; + boundary: { path: string; symbol: string }; + invariant: string; + unresolvedAssumption?: string; + }; + evidence: Array<{ kind: EvidenceKind; sha256: string; mediaType: string; excerpt: string }>; + probe: ParticipantProbeSurface; + developerAnswer: string; +} + +export interface SideCoachProposal { + schemaVersion: 1; + hypothesis: string; + probeInputId?: string; + clarification?: string; +} + +export interface CatalogControlProbe { + schemaVersion: 1; + id: string; + fixtureId: string; + fixtureManifestHash: string; + state: "target" | "mutated"; + checkId: string; + prompt: string; +} + +export interface FixtureControlProbe { + schemaVersion: 1; + mode: "fixture"; + id: string; + projectId: string; + claimHash: string; + sourceTreeHash: string; + fixtureId: string; + fixtureManifestHash: string; + participant: ParticipantProbeSurface; +} + +export interface ParticipantControlProbe { + schemaVersion: 1; + id: string; + internalProbeHash: string; + claim: ChangeClaim; + participant: ParticipantProbeSurface; +} + +export interface ControlProbeAttempt { + schemaVersion: 1; + id: string; + projectId: string; + probeId: string; + internalProbeHash: string; + claimHash: string; + sourceTreeHash: string; + selectedProbeInputId: string; + prediction: "passes" | "fails"; + selectedEvidenceIds: string[]; + explanation: string; + committedAt: string; +} + +export interface ControlProbeResult { + schemaVersion: 1; + projectId: string; + probeId: string; + internalProbeHash: string; + claimHash: string; + sourceTreeHash: string; + attemptHash: string; + probeInputId: string; + checkId: string | null; + commandId: string; + observation: "passes" | "fails" | "execution-error" | "failed-integrity"; + commandStatus: { exitCode: number | null; timedOut: boolean; cancelled: boolean }; + predictionResult: "confirmed" | "falsified" | "invalid"; + commandResultHash: string | null; + resultHash: string; +} + +export interface OpenedParticipantEvidence { + projectId: string; + ref: EvidenceRef & { visibility: "participant" }; + content: string; +} + +export interface ValidatedClaim { + claim: ChangeClaim; + claimHash: string; + evidence: OpenedParticipantEvidence[]; +} diff --git a/extension/src/pulse/validate.ts b/extension/src/pulse/validate.ts new file mode 100644 index 0000000..8b781c5 --- /dev/null +++ b/extension/src/pulse/validate.ts @@ -0,0 +1,241 @@ +import { + assertBoundedText, + assertIsoTime, + assertObjectShape, + assertRecord, + assertToken, +} from "../agent/types"; +import { + assertExactKeys, + assertRelPath, + assertSha256, + canonicalHash, + canonicalJson, + compareUtf8, +} from "../rnd/canonical"; +import { assertEvidenceRef, type EvidenceRef } from "../recorder/events"; +import type { + CatalogControlProbe, + ChangeClaim, + ControlProbeAttempt, + ControlProbeResult, + FixtureControlProbe, + OpenedParticipantEvidence, + ParticipantControlProbe, + ParticipantProbeSurface, + ValidatedClaim, +} from "./types"; + +export interface ParticipantEvidenceReader { + open(projectId: string, id: string): Promise<{ projectId: string; ref: EvidenceRef; content: string } | undefined>; +} + +export interface ClaimValidationContext { + projectId: string; + checkpointId: string; + stablePassing: boolean; + evidence: ParticipantEvidenceReader; +} + +export async function validateChangeClaim( + value: unknown, + context: ClaimValidationContext, +): Promise { + assertToken(context.projectId, "projectId"); + assertToken(context.checkpointId, "checkpointId"); + if (!context.stablePassing) throw new Error("Control claim requires a stable passing checkpoint"); + assertChangeClaim(value); + if (value.checkpointId !== context.checkpointId) throw new Error("Control claim checkpoint identity changed"); + + const evidence: OpenedParticipantEvidence[] = []; + for (const compact of value.evidenceRefs) { + const opened = await context.evidence.open(context.projectId, compact.id); + if (!opened || opened.projectId !== context.projectId) { + throw new Error("Claim evidence is unknown or belongs to another project"); + } + assertEvidenceRef(opened.ref, opened.ref.kind); + if (opened.ref.visibility !== "participant" || compact.visibility !== "participant") { + throw new Error("Claim evidence must be participant-visible"); + } + if (opened.ref.id !== compact.id || opened.ref.sha256 !== compact.sha256) { + throw new Error("Claim evidence identity or hash changed"); + } + evidence.push({ + projectId: opened.projectId, + ref: structuredClone(opened.ref) as OpenedParticipantEvidence["ref"], + content: opened.content, + }); + } + return { + claim: structuredClone(value), + claimHash: canonicalHash("control-claim", value), + evidence, + }; +} + +export function assertChangeClaim(value: unknown): asserts value is ChangeClaim { + assertRecord(value, "change claim"); + assertObjectShape( + value, + ["schemaVersion", "checkpointId", "intent", "changedBehavior", "boundary", "invariant", "evidenceRefs"], + ["unresolvedAssumption"], + "change claim", + ); + if (value.schemaVersion !== 1) throw new Error("Unsupported change claim"); + assertToken(value.checkpointId, "checkpointId"); + assertBoundedText(value.intent, 1024, "claim intent"); + assertBoundedText(value.changedBehavior, 1024, "changed behavior"); + assertBoundedText(value.invariant, 1024, "claim invariant"); + if (value.unresolvedAssumption !== undefined) { + assertBoundedText(value.unresolvedAssumption, 1024, "unresolved assumption"); + } + assertRecord(value.boundary, "claim boundary"); + assertExactKeys(value.boundary, ["path", "symbol"], "claim boundary"); + if (typeof value.boundary.path !== "string") throw new Error("Claim boundary path must be relative"); + assertRelPath(value.boundary.path); + if (Buffer.byteLength(value.boundary.path) > 1024) throw new Error("Claim boundary path exceeds 1024 bytes"); + assertBoundedText(value.boundary.symbol, 256, "claim boundary symbol"); + if (!Array.isArray(value.evidenceRefs) || value.evidenceRefs.length > 8) { + throw new Error("Claim evidence refs must contain at most eight entries"); + } + let previous: string | undefined; + for (const ref of value.evidenceRefs) { + assertRecord(ref, "claim evidence ref"); + assertExactKeys(ref, ["id", "sha256", "visibility"], "claim evidence ref"); + assertToken(ref.id, "evidenceId"); + assertSha256(String(ref.sha256), "claim evidence hash"); + if (ref.visibility !== "participant") throw new Error("Claim evidence must be participant-visible"); + if (previous !== undefined && compareUtf8(previous, ref.id as string) >= 0) { + throw new Error("Claim evidence refs must be sorted and unique"); + } + previous = ref.id as string; + } + if (Buffer.byteLength(canonicalJson(value)) > 8192) throw new Error("Canonical claim exceeds 8192 bytes"); +} + +export function assertProbeSurface(value: unknown): asserts value is ParticipantProbeSurface { + assertRecord(value, "probe surface"); + assertExactKeys(value, ["prompt", "inputs"], "probe surface"); + assertBoundedText(value.prompt, 2048, "probe prompt"); + if (!Array.isArray(value.inputs) || value.inputs.length === 0 || value.inputs.length > 8) { + throw new Error("Probe surface inputs are invalid"); + } + let previous: string | undefined; + for (const input of value.inputs) { + assertRecord(input, "probe input"); + assertExactKeys(input, ["id", "label"], "probe input"); + assertToken(input.id, "probe input ID"); + assertBoundedText(input.label, 512, "probe input label"); + if (previous !== undefined && compareUtf8(previous, input.id as string) >= 0) { + throw new Error("Probe inputs must be sorted and unique"); + } + previous = input.id as string; + } +} + +export function assertCatalogControlProbe(value: unknown): asserts value is CatalogControlProbe { + assertRecord(value, "catalog control probe"); + assertExactKeys(value, [ + "schemaVersion", "id", "fixtureId", "fixtureManifestHash", "state", "checkId", "prompt", + ], "catalog control probe"); + if (value.schemaVersion !== 1) throw new Error("Unsupported catalog control probe"); + assertToken(value.id, "probe input ID"); + assertToken(value.fixtureId, "fixtureId"); + assertSha256(String(value.fixtureManifestHash), "fixtureManifestHash"); + if (value.state !== "target" && value.state !== "mutated") throw new Error("Catalog probe state is invalid"); + assertToken(value.checkId, "checkId"); + assertBoundedText(value.prompt, 2048, "catalog probe prompt"); +} + +export function assertFixtureControlProbe(value: unknown): asserts value is FixtureControlProbe { + assertRecord(value, "fixture control probe"); + assertExactKeys(value, [ + "schemaVersion", "mode", "id", "projectId", "claimHash", "sourceTreeHash", + "fixtureId", "fixtureManifestHash", "participant", + ], "fixture control probe"); + if (value.schemaVersion !== 1 || value.mode !== "fixture") throw new Error("Unsupported fixture control probe"); + assertToken(value.id, "probeId"); + assertToken(value.projectId, "projectId"); + assertSha256(String(value.claimHash), "claimHash"); + assertSha256(String(value.sourceTreeHash), "sourceTreeHash"); + assertToken(value.fixtureId, "fixtureId"); + assertSha256(String(value.fixtureManifestHash), "fixtureManifestHash"); + assertProbeSurface(value.participant); +} + +export function assertParticipantControlProbe(value: unknown): asserts value is ParticipantControlProbe { + assertRecord(value, "participant control probe"); + assertExactKeys(value, ["schemaVersion", "id", "internalProbeHash", "claim", "participant"], "participant control probe"); + if (value.schemaVersion !== 1) throw new Error("Unsupported participant control probe"); + assertToken(value.id, "probeId"); + assertSha256(String(value.internalProbeHash), "internalProbeHash"); + assertChangeClaim(value.claim); + assertProbeSurface(value.participant); +} + +export function assertControlProbeAttempt(value: unknown): asserts value is ControlProbeAttempt { + assertRecord(value, "control probe attempt"); + assertExactKeys(value, [ + "schemaVersion", "id", "projectId", "probeId", "internalProbeHash", "claimHash", "sourceTreeHash", + "selectedProbeInputId", "prediction", "selectedEvidenceIds", "explanation", "committedAt", + ], "control probe attempt"); + if (value.schemaVersion !== 1) throw new Error("Unsupported control probe attempt"); + assertToken(value.id, "attemptId"); + assertToken(value.projectId, "projectId"); + assertToken(value.probeId, "probeId"); + assertSha256(String(value.internalProbeHash), "internalProbeHash"); + assertSha256(String(value.claimHash), "claimHash"); + assertSha256(String(value.sourceTreeHash), "sourceTreeHash"); + assertToken(value.selectedProbeInputId, "selectedProbeInputId"); + if (value.prediction !== "passes" && value.prediction !== "fails") throw new Error("Prediction is invalid"); + assertSortedTokens(value.selectedEvidenceIds, "selected evidence IDs", 8); + assertBoundedText(value.explanation, 4096, "attempt explanation"); + assertIsoTime(value.committedAt, "committedAt"); +} + +export function assertControlProbeResult(value: unknown): asserts value is ControlProbeResult { + assertRecord(value, "control probe result"); + assertExactKeys(value, [ + "schemaVersion", "projectId", "probeId", "internalProbeHash", "claimHash", "sourceTreeHash", "attemptHash", + "probeInputId", "checkId", "commandId", "observation", "commandStatus", "predictionResult", + "commandResultHash", "resultHash", + ], "control probe result"); + if (value.schemaVersion !== 1) throw new Error("Unsupported control probe result"); + assertToken(value.projectId, "projectId"); + assertToken(value.probeId, "probeId"); + assertSha256(String(value.internalProbeHash), "internalProbeHash"); + assertSha256(String(value.claimHash), "claimHash"); + assertSha256(String(value.sourceTreeHash), "sourceTreeHash"); + assertSha256(String(value.attemptHash), "attemptHash"); + assertToken(value.probeInputId, "probeInputId"); + if (value.checkId !== null) assertToken(value.checkId, "checkId"); + assertToken(value.commandId, "commandId"); + if (!["passes", "fails", "execution-error", "failed-integrity"].includes(String(value.observation))) throw new Error("Observation is invalid"); + assertRecord(value.commandStatus, "command status"); + assertExactKeys(value.commandStatus, ["exitCode", "timedOut", "cancelled"], "command status"); + if (value.commandStatus.exitCode !== null && !Number.isSafeInteger(value.commandStatus.exitCode)) throw new Error("Exit code is invalid"); + if (typeof value.commandStatus.timedOut !== "boolean" || typeof value.commandStatus.cancelled !== "boolean") throw new Error("Command flags are invalid"); + if ((value.commandStatus.timedOut || value.commandStatus.cancelled) && value.commandStatus.exitCode !== null) { + throw new Error("Timed out or cancelled probes cannot claim an exit code"); + } + if (!["confirmed", "falsified", "invalid"].includes(String(value.predictionResult))) throw new Error("Prediction result is invalid"); + const observed = value.observation === "passes" || value.observation === "fails"; + if (observed && (value.commandStatus.exitCode === null || value.commandStatus.timedOut || value.commandStatus.cancelled)) { + throw new Error("Observed probe result requires a clean command terminal state"); + } + if (!observed && value.predictionResult !== "invalid") throw new Error("Non-observation cannot confirm a prediction"); + if (value.commandResultHash !== null) assertSha256(String(value.commandResultHash), "commandResultHash"); + assertSha256(String(value.resultHash), "resultHash"); + const { resultHash, ...core } = value; + if (canonicalHash("control-probe-result", core) !== resultHash) throw new Error("Control probe result hash changed"); +} + +function assertSortedTokens(value: unknown, label: string, max: number): asserts value is string[] { + if (!Array.isArray(value) || value.length > max) throw new Error(`${label} are invalid`); + let previous: string | undefined; + for (const item of value) { + assertToken(item, label); + if (previous !== undefined && compareUtf8(previous, item) >= 0) throw new Error(`${label} must be sorted and unique`); + previous = item; + } +} diff --git a/extension/test/control-pulse.test.ts b/extension/test/control-pulse.test.ts new file mode 100644 index 0000000..e872152 --- /dev/null +++ b/extension/test/control-pulse.test.ts @@ -0,0 +1,295 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSideCoachCapsule, validateSideCoachProposal } from "../src/pulse/capsule"; +import { + FixtureControlProbeCatalog, + FixtureControlProbeStore, + publishFixtureControlProbe, + serializeParticipantControlProbe, +} from "../src/pulse/catalog"; +import { FixtureControlPulse } from "../src/pulse/run"; +import { validateChangeClaim, type ParticipantEvidenceReader } from "../src/pulse/validate"; +import type { ChangeClaim, ControlProbeAttempt } from "../src/pulse/types"; +import { canonicalHash, rawSha256 } from "../src/rnd/canonical"; +import type { EvidenceRef } from "../src/recorder/events"; +import { BuiltinFixtureCatalog } from "../src/twin/catalog"; +import { MemoryCommandEvidenceStore, TrustedFixtureProcessRunner } from "../src/twin/commands"; +import { EXPECTED_TENANT_CACHE_KEY } from "../src/twin/fixture-factory"; +import { TwinManager } from "../src/twin/manager"; +import { FixtureSnapshotStore } from "../src/twin/snapshot"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true, maxRetries: 3 }))); +}); + +describe("R4.5 fixture-only Control Pulse", () => { + it("validates a bounded claim and publishes an isolated deterministic probe", async () => { + const env = await setup(); + const first = await publish(env); + const second = await publish(env); + + expect(second.internal).toEqual(first.internal); + expect(env.validated.claimHash).toBe("b4c96f12dcc9fd57d6c4d832e02ec4ac78c00e90def8d02adcaa7cd1af0d8774"); + expect(first.internalProbeHash).toBe("bb5affc86bb401df2fcf950b2a7d0fe708bb66b653033bf9758723cf6c6d3934"); + expect(first.participant).toEqual({ + schemaVersion: 1, + id: "probe_tenant_isolation", + internalProbeHash: first.internalProbeHash, + claim: env.claim, + participant: { + prompt: "Before the check runs: will tenant isolation hold for two tenants sharing the same id?", + inputs: [{ id: "tenant-isolation-boundary", label: "Run the tenant-isolation boundary check" }], + }, + }); + const wire = serializeParticipantControlProbe(first.participant); + for (const forbidden of ["fixtureId", "commandId", "manifestHash", "mutated", "oracle", EXPECTED_TENANT_CACHE_KEY.targetRevision]) { + expect(wire).not.toContain(forbidden); + } + expect(() => serializeParticipantControlProbe({ ...first.participant, commandId: "leak" } as typeof first.participant)).toThrow("unknown"); + }); + + it("fails malformed, unstable, cross-project, hidden, unsorted, and oversized claims closed", async () => { + const env = await setup(); + await expect(validateChangeClaim({ ...env.claim, checkpointId: "wrong" }, env.context)).rejects.toThrow("checkpoint"); + await expect(validateChangeClaim(env.claim, { ...env.context, stablePassing: false })).rejects.toThrow("stable"); + await expect(validateChangeClaim({ + ...env.claim, + evidenceRefs: [env.claim.evidenceRefs[0]!, { ...env.claim.evidenceRefs[0]!, id: "evidence_a" }], + }, env.context)).rejects.toThrow("sorted"); + await expect(validateChangeClaim({ ...env.claim, intent: "x".repeat(1_025) }, env.context)).rejects.toThrow("1024"); + await expect(validateChangeClaim({ ...env.claim, boundary: { ...env.claim.boundary, path: "C:/secret.ts" } }, env.context)).rejects.toThrow("relative"); + + const hidden = structuredClone(env.claim); + hidden.evidenceRefs[0]!.visibility = "participant"; + const hiddenReader: ParticipantEvidenceReader = { + open: async () => ({ ...env.opened, ref: { ...env.opened.ref, visibility: "controller" } }), + }; + await expect(validateChangeClaim(hidden, { ...env.context, evidence: hiddenReader })).rejects.toThrow("participant"); + await expect(validateChangeClaim(env.claim, { ...env.context, projectId: "other_project" })).rejects.toThrow("project"); + }); + + it("builds a redacted Side Coach capsule and keeps model output non-executable", async () => { + const env = await setup("See C:\\work\\repo\\src\\cache-key.ts and /home/dev/repo; token=ghp_abcdefghijklmnopqrstuvwxyz123456"); + const published = await publish(env); + const capsule = await buildSideCoachCapsule(published.participant, env.validated, "I predict failure because tenant is missing."); + const wire = JSON.stringify(capsule); + expect(wire).not.toContain("C:\\work"); + expect(wire).not.toContain("/home/dev"); + expect(wire).not.toContain("ghp_"); + expect(wire).not.toContain(env.opened.ref.id); + + expect(validateSideCoachProposal({ + schemaVersion: 1, + hypothesis: "The tenant dimension is absent.", + probeInputId: "tenant-isolation-boundary", + }, capsule)).toMatchObject({ probeInputId: "tenant-isolation-boundary" }); + expect(() => validateSideCoachProposal({ + schemaVersion: 1, + hypothesis: "Run this patch", + probeInputId: "rm-rf", + command: "rm -rf /", + }, capsule)).toThrow(); + }); + + it("commits prediction before observation and executes only the catalog state/check pair", async () => { + const env = await setup(); + const published = await publish(env); + const committed = await env.pulse.commit(published.participant, attempt(published)); + expect(committed.attemptHash).toBe("078d4a5857df1d91e0d71e858cd30a77889f7e5f6ae05ab9cbb99980a64d04d9"); + const result = await env.pulse.execute("project_r0_fixture", committed.attempt.id, committed.attemptHash); + + expect(result).toMatchObject({ + probeId: "probe_tenant_isolation", + probeInputId: "tenant-isolation-boundary", + checkId: "cache-key.tenant-isolation", + observation: "fails", + commandStatus: { exitCode: 1, timedOut: false, cancelled: false }, + predictionResult: "confirmed", + }); + expect(result.commandResultHash).toMatch(/^[0-9a-f]{64}$/); + expect(env.runner.requests).toEqual([{ + fixtureId: "tenant-cache-key", + manifestHash: EXPECTED_TENANT_CACHE_KEY.manifestHash, + stateId: "mutated", + commandId: "cache-key.tenant-isolation", + }]); + + const proseAttempt = attempt(published, "attempt_same_prediction"); + proseAttempt.explanation = "Different prose, same committed causal prediction."; + const proseCommit = await env.pulse.commit(published.participant, proseAttempt); + const proseResult = await env.pulse.execute("project_r0_fixture", proseCommit.attempt.id, proseCommit.attemptHash); + expect(proseResult.observation).toBe(result.observation); + expect(proseResult.predictionResult).toBe(result.predictionResult); + }, 45_000); + + it("rejects projection drift, evidence drift, replay, cross-project, and late attempts before execution", async () => { + const env = await setup(); + const published = await publish(env); + const drifted = structuredClone(published.participant); + drifted.participant.inputs[0]!.label = "Different label"; + await expect(env.pulse.commit(drifted, attempt(published))).rejects.toThrow("projection"); + + const badEvidence = attempt(published); + badEvidence.selectedEvidenceIds = ["unknown_evidence"]; + await expect(env.pulse.commit(published.participant, badEvidence)).rejects.toThrow("evidence"); + + const committed = await env.pulse.commit(published.participant, attempt(published)); + await expect(env.pulse.execute("other_project", committed.attempt.id, committed.attemptHash)).rejects.toThrow("project"); + await env.pulse.execute("project_r0_fixture", committed.attempt.id, committed.attemptHash); + await expect(env.pulse.execute("project_r0_fixture", committed.attempt.id, committed.attemptHash)).rejects.toThrow("replay"); + + const late = attempt(published, "attempt_late"); + late.committedAt = "2025-12-31T23:00:00.000Z"; + const lateCommit = await env.pulse.commit(published.participant, late); + const lateResult = await env.pulse.execute("project_r0_fixture", lateCommit.attempt.id, lateCommit.attemptHash); + expect(lateResult).toMatchObject({ observation: "failed-integrity", predictionResult: "invalid", commandResultHash: null }); + expect(env.runner.requests).toHaveLength(1); + }, 45_000); + + it("never confirms a predicted failure on timeout, cancellation, or runner error", async () => { + const env = await setup(); + const published = await publish(env); + env.runner.mode = "timeout"; + const timed = await env.pulse.commit(published.participant, attempt(published, "attempt_timeout")); + const timedResult = await env.pulse.execute("project_r0_fixture", timed.attempt.id, timed.attemptHash); + expect(timedResult).toMatchObject({ observation: "execution-error", predictionResult: "invalid" }); + expect(timedResult.commandResultHash).toMatch(/^[0-9a-f]{64}$/); + + env.runner.mode = "cancel"; + const cancelled = await env.pulse.commit(published.participant, attempt(published, "attempt_cancel")); + const cancelledResult = await env.pulse.execute("project_r0_fixture", cancelled.attempt.id, cancelled.attemptHash); + expect(cancelledResult).toMatchObject({ observation: "execution-error", predictionResult: "invalid" }); + expect(cancelledResult.commandResultHash).toMatch(/^[0-9a-f]{64}$/); + + env.runner.mode = "error"; + const committed = await env.pulse.commit(published.participant, attempt(published, "attempt_error")); + const result = await env.pulse.execute("project_r0_fixture", committed.attempt.id, committed.attemptHash); + expect(result).toMatchObject({ observation: "execution-error", predictionResult: "invalid", commandResultHash: null }); + }, 45_000); +}); + +class RecordingRunner extends TrustedFixtureProcessRunner { + requests: Array<{ fixtureId: string; manifestHash: string; stateId: string; commandId: string }> = []; + mode: "real" | "error" | "timeout" | "cancel" = "real"; + + override async run(request: Parameters[0]) { + this.requests.push({ + fixtureId: request.fixtureId, + manifestHash: request.manifestHash, + stateId: request.stateId, + commandId: request.commandId, + }); + if (this.mode === "error") throw new Error("runner unavailable"); + if (this.mode === "timeout" || this.mode === "cancel") { + const empty = emptyEvidence("pulse_empty_output"); + return { + executionId: request.executionId, + commandId: request.commandId, + exitCode: null, + timedOut: this.mode === "timeout", + cancelled: this.mode === "cancel", + stdout: empty, + stderr: empty, + }; + } + return super.run(request); + } +} + +function emptyEvidence(id: string): EvidenceRef { + return { + id, + kind: "command-output", + sha256: rawSha256(""), + storedBytes: 0, + originalBytes: 0, + truncated: false, + redactions: [], + mediaType: "text/plain", + visibility: "controller", + }; +} + +async function setup(content = "The diff removes tenant from the cache key.") { + const root = await mkdtemp(join(tmpdir(), "pureflow-r45-")); + roots.push(root); + const bytes = Buffer.from(content, "utf8"); + const ref: EvidenceRef = { + id: "evidence_cache_key_diff", + kind: "diff", + sha256: rawSha256(bytes), + storedBytes: bytes.byteLength, + originalBytes: bytes.byteLength, + truncated: false, + redactions: [], + mediaType: "text/plain", + visibility: "participant", + }; + const opened = { projectId: "project_r0_fixture", ref, content }; + const evidence: ParticipantEvidenceReader = { + open: async (projectId, id) => projectId === opened.projectId && id === ref.id ? structuredClone(opened) : undefined, + }; + const claim: ChangeClaim = { + schemaVersion: 1, + checkpointId: "checkpoint_tenant_cache", + intent: "Keep cache entries isolated by tenant.", + changedBehavior: "The cache key now includes the tenant boundary.", + boundary: { path: "src/cache-key.ts", symbol: "cacheKey" }, + invariant: "Equal ids in different tenants must produce different keys.", + evidenceRefs: [{ id: ref.id, sha256: ref.sha256, visibility: "participant" }], + unresolvedAssumption: "Callers always provide a tenant id.", + }; + const context = { + projectId: opened.projectId, + checkpointId: claim.checkpointId, + stablePassing: true, + evidence, + }; + const validated = await validateChangeClaim(claim, context); + const fixtureCatalog = new BuiltinFixtureCatalog(); + const snapshots = new FixtureSnapshotStore(join(root, "snapshots"), fixtureCatalog, { now: () => "2026-01-01T00:00:00.000Z" }); + const twins = new TwinManager(join(root, "twins"), snapshots); + const commandEvidence = new MemoryCommandEvidenceStore(); + const runner = new RecordingRunner(fixtureCatalog, twins, commandEvidence); + const probeCatalog = new FixtureControlProbeCatalog(); + const probes = new FixtureControlProbeStore(); + const pulse = new FixtureControlPulse(probeCatalog, probes, fixtureCatalog, snapshots, twins, runner, { + now: () => new Date("2026-01-01T00:05:00.000Z"), + }); + return { root, opened, evidence, claim, context, validated, fixtureCatalog, snapshots, twins, runner, probeCatalog, probes, pulse }; +} + +async function publish(env: Awaited>) { + return publishFixtureControlProbe({ + id: "probe_tenant_isolation", + projectId: "project_r0_fixture", + claim: env.validated, + sourceTreeHash: EXPECTED_TENANT_CACHE_KEY.targetTreeHash, + catalog: env.probeCatalog, + store: env.probes, + }); +} + +function attempt( + published: { internalProbeHash: string; participant: { claim: ChangeClaim } }, + id = "attempt_tenant_isolation", +): ControlProbeAttempt { + return { + schemaVersion: 1, + id, + projectId: "project_r0_fixture", + probeId: "probe_tenant_isolation", + internalProbeHash: published.internalProbeHash, + claimHash: canonicalHash("control-claim", published.participant.claim), + sourceTreeHash: EXPECTED_TENANT_CACHE_KEY.targetTreeHash, + selectedProbeInputId: "tenant-isolation-boundary", + prediction: "fails", + selectedEvidenceIds: ["evidence_cache_key_diff"], + explanation: "Without the tenant dimension, equal ids collide.", + committedAt: "2026-01-01T00:04:00.000Z", + }; +} From 58720a1b27bf7fc5fa9611f5b37813cd0f954894 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 01:59:03 +0300 Subject: [PATCH 2/2] docs: record protected R4.5 acceptance --- docs/BUILD_LOG.md | 6 +++--- docs/PROJECT_STATE.md | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 4343423..d9dbf84 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,14 +2,14 @@ This is a concise chronological record of material implementation work and runtime evidence. It is not a substitute for Git history; it captures intent, verification, and blockers that a commit alone may not explain. -## 2026-07-31 — R4.5 fixture-only Control Pulse local candidate +## 2026-07-31 — R4.5 fixture-only Control Pulse accepted - Implemented exact bounded Change Claim, participant/internal probe, Side Coach capsule/proposal, attempt, and result contracts. Claim, probe, attempt, command result, and probe result identities use their normative domain-separated hashes. - Added one immutable Explain-to-Break catalog input for the reviewed tenant-isolation fixture. The participant commits a prediction before observation; execution can resolve only the catalog-owned `mutated` state and declared tenant-isolation check through `TrustedFixtureRunner`. - Kept controller bindings out of participant serialization and model context. The Side Coach receives only capped participant-visible excerpts with evidence IDs, local paths, and known credential shapes removed; its output cannot become code, paths, arguments, environment, or tests. -- Six R4.5 tests pass locally on Windows. They cover golden hashes, exact projection, evidence/privacy bounds, model proposal isolation, precommit ordering, result invariance to prose, catalog-only execution, replay/cross-project/late rejection, and timeout/cancellation/runner-error invalidation. The complete extension suite passes 63/63; TypeScript, production build, and VSIX packaging pass. Protected Linux/Windows verification is pending. +- Six R4.5 tests pass locally on Windows. They cover golden hashes, exact projection, evidence/privacy bounds, model proposal isolation, precommit ordering, result invariance to prose, catalog-only execution, replay/cross-project/late rejection, and timeout/cancellation/runner-error invalidation. The complete extension suite passes 63/63; TypeScript, production build, and VSIX packaging pass. Protected PR #15 run `30671499103` passed the required Linux, Windows, contract, web, and policy checks, so R4.5 acceptance is complete. -Evidence: `extension/src/pulse/`, `extension/test/control-pulse.test.ts`, and local command/package output on 2026-07-31. +Evidence: `extension/src/pulse/`, `extension/test/control-pulse.test.ts`, local command/package output on 2026-07-31, and protected GitHub Actions run `30671499103` on PR #15. ## 2026-07-31 — R4 Experience Compiler and Phase-A Judge accepted diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index a46c0c3..1f446b5 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,7 +2,7 @@ Last updated: 2026-07-31 -## Current branch milestone — R4.5 fixture-only Control Pulse local candidate +## Current branch milestone — R4.5 fixture-only Control Pulse accepted Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -26,7 +26,7 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - Seven R4 tests cover deterministic compilation, strict projection/schema rejection, unsupported and fixture-drift rejection, protected-path integrity, no-op/unrelated non-execution, three identical clean replays, reveal/abandon honesty, and project-scoped persistent command evidence with tamper detection. The full local Windows extension suite passes 57/57; `npm run check`, production build, and VSIX packaging pass. Protected PR #14 run `30670322126` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R4 acceptance is complete. - R4.5 now implements bounded `ChangeClaim`, participant/internal probe, Side Coach capsule/proposal, attempt, and result contracts with exact schemas and domain-separated hashes. The deterministic no-model path commits a developer prediction before observation and resolves only one extension-owned fixture state/check pair. - Participant serialization contains no fixture binding, command, state, revision, oracle, controller handle, or production path. Capsule construction reopens only participant-visible project evidence, caps and scrubs excerpts, and treats every model proposal as non-executable text plus an optional existing input ID. -- Six R4.5 tests cover cross-platform golden claim/probe/attempt hashes, strict projection and catalog isolation, unstable/cross-project/hidden/malformed evidence rejection, capsule path/secret/ID redaction, precommitted prediction, prose-invariant observation, replay/late/identity failures, and timeout/cancellation/runner-error invalidation. The full local Windows extension suite passes 63/63; `npm run check`, production build, and VSIX packaging pass. Protected branch verification is pending. +- Six R4.5 tests cover cross-platform golden claim/probe/attempt hashes, strict projection and catalog isolation, unstable/cross-project/hidden/malformed evidence rejection, capsule path/secret/ID redaction, precommitted prediction, prose-invariant observation, replay/late/identity failures, and timeout/cancellation/runner-error invalidation. The full local Windows extension suite passes 63/63; `npm run check`, production build, and VSIX packaging pass. Protected PR #15 run `30671499103` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R4.5 acceptance is complete. - The readiness ledger and v0.3 cockpit do not exist yet. R0–R4.5 remain a closed reviewed-fixture mechanism and do not execute arbitrary participant or workspace code. - No skill-retention or speed metric has been measured. Values in the PRD are predeclared R&D targets. - A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization; R0a/R0b now implement and verify that complete substrate. @@ -145,9 +145,9 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Obtain protected Linux/Windows acceptance for the R4.5 Explain-to-Break Control Pulse. -2. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. -3. Add the local readiness ledger and minimal cockpit only after the vertical slice is reliable. +1. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. +2. Select and verify the Windows-capable Phase-B sandbox required by R7; fail closed if none meets ADR-003. +3. Add the local readiness ledger and minimal cockpit only after the R7 gate passes. 4. Run the preregistered delayed-transfer pilot before making any skill-retention claim. ## Recent milestone commits