Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8bcf075
test(lab): cover CL-02 post-merge regressions
Wibias Aug 9, 2026
fe96830
docs(lab): track CL-02 post-merge hardening
Wibias Aug 9, 2026
fbb1f85
fix(lab): harden chunked ledger replay
Wibias Aug 9, 2026
bf828c8
fix(lab): reject embedded raw filesystem paths
Wibias Aug 9, 2026
2137bc6
fix(lab): classify malformed contract artifacts as mismatches
Wibias Aug 9, 2026
88c5cba
fix(lab): harden sensitive purge defaults and durability reporting
Wibias Aug 9, 2026
54cd879
fix(lab): preserve idempotent concurrent artifact publication
Wibias Aug 9, 2026
7c9ead9
fix(lab): type conformance execution timestamps
Wibias Aug 9, 2026
36ab2cd
fix(lab): carry execution timestamps from conformance runner
Wibias Aug 9, 2026
739d5f9
fix(lab): fail closed at conformance persistence seam
Wibias Aug 9, 2026
b81a984
docs(lab): record CL-02 merge and hardening follow-up
Wibias Aug 9, 2026
eaeff07
test(lab): cover freshness and unsupported projection paths
Wibias Aug 9, 2026
35fdcbd
test(lab): cover latest post-merge review bypasses
Wibias Aug 9, 2026
759c7fe
fix(lab): reject malformed contract UTF-8
Wibias Aug 9, 2026
16a8a5a
fix(lab): reject raw POSIX path bypasses
Wibias Aug 9, 2026
06232f4
fix(lab): use canonical export directory in purge
Wibias Aug 9, 2026
1ad5419
fix(lab): restore canonical exportDir path
Wibias Aug 9, 2026
4e4b9a4
fix(lab): allow empty purge targets for directory-scoped actions
Wibias Aug 9, 2026
0da8c27
fix(lab): address CodeRabbit path, put, and test findings
Wibias Aug 9, 2026
953e75f
fix(lab): reject root-only and doubled-slash POSIX paths
Wibias Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ independent review, blockers, and whether a later phase is authorized.
|---|---|---|---|---|---|
| CL-00 | `feat/cl-00-compatibility-contracts` | `3ad5bb6bd3f76f6879d84b78ea39edd3e01ec296` | `c014464237fd3c95bda08bc18bfab8ba8f532308` | [#1286](https://github.com/lidge-jun/opencodex/pull/1286) | ACCEPTED AFTER CODERABBIT REMEDIATION (merged to `dev` at `243c3f4905797aa11c62ba933bb03d6d721266fd`) |
| CL-01 | `feat/cl-01-conformance-harness` | `c2113ca47b8a05c5a5f90679e4eaa640ca2c6a66` | `22d608c82d82e2746c0cef9cd761db19a8e465ee` | [#1320](https://github.com/lidge-jun/opencodex/pull/1320) | MERGED TO `dev` at `4bb249b756abd468c675d2d92fffe4da95ad3e2a` |
| CL-02 | `feat/cl-02-evidence-ledger` | `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | (phase-2 review fixes in progress) | [draft #1333](https://github.com/lidge-jun/opencodex/pull/1333) | IMPLEMENTATION COMPLETE — PHASE-2 REVIEW FIXES — NOT INDEPENDENTLY ACCEPTED |
| CL-02 | `feat/cl-02-evidence-ledger` | `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | NOT RECORDED | [#1333](https://github.com/lidge-jun/opencodex/pull/1333) | MERGED TO `dev` at `025c37916225dd685d9217e5b40190600f06d278`; POST-MERGE HARDENING [#1343](https://github.com/lidge-jun/opencodex/pull/1343) IN PROGRESS |
| CL-03 | — | — | — | — | NOT STARTED |

The CL-01 starting SHA is the exact CL-00 tip recorded when CL-01 began. Its
Expand Down Expand Up @@ -99,6 +99,9 @@ Independent CL-00 acceptance review is frozen at

- **Branch:** `feat/cl-02-evidence-ledger`
- **Starting/base SHA:** `4bb249b756abd468c675d2d92fffe4da95ad3e2a` (CL-01 merge via #1320)
- **Merged source head:** `1eed4ffbc9772c64f4f22e37869ccb0b9efa90e1`
- **Merged to `dev`:** `025c37916225dd685d9217e5b40190600f06d278` via upstream [#1333](https://github.com/lidge-jun/opencodex/pull/1333).
- **Accepted head:** not recorded. #1333 was merged before an independent-acceptance state was observed in this programme log.
- **Scope:** append-only JSONL evidence ledger with an explicit sensitive-purge
exception: when the `ledger` purge action is requested, targeted evidence is
physically removed by atomic ledger rewrite and a `purge_tombstone` remains as
Expand All @@ -120,27 +123,25 @@ sensitive purge with shared-artifact retention, recursive event admission
ceilings, and unusable-evidence exclusion from projection are implemented.
Claims cannot produce `PROBED`/`VERIFIED`.

### CL-02 validation status (2026-08-09 phase-2 review fixes)
### CL-02 validation and post-merge hardening status (2026-08-09)

- **Prior accepted review-fix head:** `cf626d14c823413fbcd6ac2625d1da16bbac714e`
- **Phase-2 scope:** eleven independent-review blockers (artifact dirfd I/O,
purge scratch/export + explicit sensitive artifacts, streaming JSONL replay,
zero-applicable UNKNOWN, `newest-required-observation-v1`, multi-surface
applicability, historical manifest no-substitution, closed event admission,
corrupt superseding claims, ArtifactStore lifecycle, frozen behaviour
fingerprint).
- **Previous local validation:** `bun x tsc --noEmit`, `bun run privacy:scan`,
`tests/lab-evidence-ledger.test.ts` (41/41), `tests/lab-conformance-harness.test.ts`
(17/17), `tests/repo-hygiene.test.ts` (11/11), `git diff --check` green on
Windows host before the current CodeRabbit remediation pass.
- **Current CodeRabbit remediation:** committed on draft PR #1333; current CI and
review reconciliation are required before this head may be recorded as accepted.
- **Independent acceptance:** not yet — draft PR #1333 remains open for review.
- **Final #1333 source head:** `1eed4ffbc9772c64f4f22e37869ccb0b9efa90e1`
- **Merge commit:** `025c37916225dd685d9217e5b40190600f06d278`
- A final CodeRabbit review batch arrived immediately before the #1333 merge and
identified additional hardening work in ledger replay, artifact publication,
sensitive purge, event privacy admission, contract-artifact error
classification, conformance execution timestamps, and regression coverage.
- **Post-merge hardening branch:** `fix/cl-02-post-merge-hardening`
- **Post-merge hardening PR:** [#1343](https://github.com/lidge-jun/opencodex/pull/1343), based exactly on merge commit `025c37916225dd685d9217e5b40190600f06d278`.
- The follow-up preserves frozen CL-00 semantics and does not add CL-03 work.
- Current CI/review reconciliation for #1343 must complete before the hardening
follow-up is considered closed.
- **CL-03:** not started.

## Authorization

- CL-00: **ACCEPTED** (merged #1286).
- CL-01: **MERGED** via #1320 at `4bb249b756abd468c675d2d92fffe4da95ad3e2a`.
- CL-02: **IMPLEMENTATION COMPLETE — PHASE-2 REVIEW FIXES — NOT INDEPENDENTLY ACCEPTED** on `feat/cl-02-evidence-ledger` (draft #1333).
- CL-03: **NOT STARTED**.
- CL-02: **MERGED** via #1333 at `025c37916225dd685d9217e5b40190600f06d278`; post-merge hardening is tracked in #1343.
- CL-03: **NOT STARTED** pending completion/reconciliation of the CL-02 post-merge hardening follow-up.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# CL-02 Post-Merge Hardening

CL-02 merged to `dev` in upstream PR #1333 at merge commit `025c37916225dd685d9217e5b40190600f06d278`.

A final CodeRabbit review batch arrived immediately before that merge and identified additional post-merge hardening work. This follow-up stays within CL-02 implementation and regression coverage; CL-03 is not started here.

## Confirmed remediation scope

- Preserve UTF-8 byte ordering and ownership across chunked ledger replay.
- Bound memory and corruption accounting for oversized unterminated JSONL lines.
- Keep content-addressed artifact publication idempotent under concurrent writers while preserving symlink/hardlink rejection and final digest verification.
- Classify malformed contract artifacts as artifact mismatches rather than generic harness failures.
- Reject embedded raw POSIX filesystem paths in persisted event strings.
- Include `export` in the default sensitive-evidence purge action set.
- Fail closed on unmapped conformance failure classifications.
- Make execution timestamps a typed `ScenarioRunResult` producer output instead of relying on a cast at the CL-02 persistence seam.
- Add focused regressions for each behavior above and reconcile the remaining post-merge review findings without changing frozen CL-00 semantics.

## Base

- Upstream base: `dev`
- Base commit: `025c37916225dd685d9217e5b40190600f06d278`
- Follow-up branch: `fix/cl-02-post-merge-hardening`
25 changes: 16 additions & 9 deletions src/lab/artifacts/secure-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,12 @@ function isMissingArtifactError(err: unknown): boolean {
return isRawMissingError(err) || (err instanceof ArtifactFsError && err.code === "artifact_missing");
}

function assertArtifactTargetCreatable(dir: TrustedArtifactDir, name: string): void {
/**
* Returns true when a clean regular digest target appeared after the caller's
* missing read. The final readback still verifies byte count and digest, so a
* concurrent or hostile wrong-content regular file fails closed.
*/
function artifactTargetAlreadyPublished(dir: TrustedArtifactDir, name: string): boolean {
revalidateDir(dir);
assertRelativeName(name);
try {
Expand All @@ -280,9 +285,9 @@ function assertArtifactTargetCreatable(dir: TrustedArtifactDir, name: string): v
harnessFailure("artifact target is a symbolic link", "artifact_unsafe_target");
}
assertRegularFileStats(stats, "artifact create target");
harnessFailure("artifact target exists but is not reusable", "artifact_unsafe_target");
return true;
} catch (err) {
if (isRawMissingError(err)) return;
if (isRawMissingError(err)) return false;
if (err instanceof ArtifactFsError) throw err;
harnessFailure(
`artifact create target check failed: ${err instanceof Error ? err.message : String(err)}`,
Expand Down Expand Up @@ -402,9 +407,10 @@ export function putArtifactBytes(
if (!isMissingArtifactError(err)) throw err;
}

assertArtifactTargetCreatable(dir, digestFileName(digest));
const tmpName = `.tmp-${digest}-${process.pid}-${Date.now()}.partial`;
writeTempArtifact(dir, tmpName, bytes, digest, artifactBytesDigest);
if (!artifactTargetAlreadyPublished(dir, digestFileName(digest))) {
const tmpName = `.tmp-${digest}-${process.pid}-${Date.now()}.partial`;
writeTempArtifact(dir, tmpName, bytes, digest, artifactBytesDigest);
}
return readArtifactBytes(dir, digest, bytes.byteLength);
}

Expand All @@ -429,9 +435,10 @@ export function putNamedDigestBytes(
if (!isMissingArtifactError(err)) throw err;
}

assertArtifactTargetCreatable(dir, digestFileName(digest));
const tmpName = `.tmp-${digest}-${process.pid}-${Date.now()}.partial`;
writeTempArtifact(dir, tmpName, bytes, digest, contentDigest);
if (!artifactTargetAlreadyPublished(dir, digestFileName(digest))) {
const tmpName = `.tmp-${digest}-${process.pid}-${Date.now()}.partial`;
writeTempArtifact(dir, tmpName, bytes, digest, contentDigest);
}
return readArtifactBytes(dir, digest, { expectedByteCount: bytes.byteLength, contentDigest });
}

Expand Down
41 changes: 33 additions & 8 deletions src/lab/artifacts/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,21 @@ function normalizeReadOptions(value?: number | ArtifactReadOptions): ArtifactRea
return typeof value === "number" ? { expectedByteCount: value } : value ?? {};
}

function parseContractJson(bytes: Uint8Array): unknown {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
}

function jsonDigest(
digest: (value: Record<string, unknown>) => string,
): (bytes: Uint8Array) => string {
return (bytes) => digest(JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>);
return (bytes) => {
try {
return digest(parseContractJson(bytes) as Record<string, unknown>);
} catch (err) {
if (err instanceof ArtifactFsError) throw err;
throw new ArtifactFsError("artifact_mismatch", "artifact content is not valid contract JSON");
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function digestForArtifactClass(artifactClass: ArtifactClass): (bytes: Uint8Array) => string {
Expand All @@ -78,8 +89,13 @@ function digestForArtifactClass(artifactClass: ArtifactClass): (bytes: Uint8Arra
return jsonDigest(suiteManifestDigest);
case "claim_source_manifest":
return (bytes) => {
const parsed = JSON.parse(new TextDecoder().decode(bytes));
return claimSourceManifestDigest(validateClaimSourceManifest(parsed).manifest);
try {
const parsed = parseContractJson(bytes);
return claimSourceManifestDigest(validateClaimSourceManifest(parsed).manifest);
} catch (err) {
if (err instanceof ArtifactFsError) throw err;
throw new ArtifactFsError("artifact_mismatch", "claim-source artifact failed validation");
}
};
default:
return artifactBytesDigest;
Expand Down Expand Up @@ -154,7 +170,16 @@ export function createArtifactStore(artifactsDir: string): ArtifactStore {
let stored;
if (isContractClass(input.artifactClass)) {
const contractClass = input.artifactClass;
const computedDigest = computeContractDigest(contractClass, bytes, redacted);
let computedDigest: string;
try {
computedDigest = computeContractDigest(contractClass, bytes, redacted);
} catch (err) {
if (err instanceof ArtifactFsError) throw err;
throw new ArtifactFsError(
"artifact_mismatch",
err instanceof Error ? err.message : "contract artifact failed validation",
);
}
if (input.expectedDigest !== undefined && computedDigest !== input.expectedDigest) {
throw new ArtifactFsError("artifact_mismatch", "contract artifact digest mismatch");
}
Expand Down Expand Up @@ -211,16 +236,16 @@ function computeContractDigest(
return fixtureDigest(bytes);
case "scenario_manifest":
return scenarioManifestDigest(
typeof redacted === "object" && redacted ? (redacted as Record<string, unknown>) : JSON.parse(new TextDecoder().decode(bytes)),
typeof redacted === "object" && redacted ? (redacted as Record<string, unknown>) : parseContractJson(bytes) as Record<string, unknown>,
);
case "suite_manifest":
return suiteManifestDigest(
typeof redacted === "object" && redacted ? (redacted as Record<string, unknown>) : JSON.parse(new TextDecoder().decode(bytes)),
typeof redacted === "object" && redacted ? (redacted as Record<string, unknown>) : parseContractJson(bytes) as Record<string, unknown>,
);
case "claim_source_manifest": {
const parsed = typeof redacted === "object" && redacted
? redacted
: JSON.parse(new TextDecoder().decode(bytes));
: parseContractJson(bytes);
return claimSourceManifestDigest(validateClaimSourceManifest(parsed).manifest);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
default: {
Expand Down Expand Up @@ -266,7 +291,7 @@ export function loadClaimSourceManifest(
if (!isSha256Hex(digest)) return { ok: false, manifest: null, corruption: "invalid digest" };
try {
const bytes = store.get(digest, { artifactClass: "claim_source_manifest" });
const parsed = JSON.parse(new TextDecoder().decode(bytes));
const parsed = parseContractJson(bytes);
const { manifest, digest: recomputed } = validateClaimSourceManifest(parsed);
if (recomputed !== digest) return { ok: false, manifest, corruption: "claim-source digest mismatch" };
if (manifest.subjectId !== expected.subjectId) return { ok: false, manifest, corruption: "claim-source subjectId mismatch" };
Expand Down
21 changes: 15 additions & 6 deletions src/lab/conformance/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,15 @@ export async function executeScenario(caseRecord: CaseRecord): Promise<Normalize
export async function runScenario(caseRecord: CaseRecord): Promise<ScenarioRunResult> {
const diagnostics: string[] = [];
const executionContext = resolveProtocolExecutionContext(caseRecord);
const startedAt = Date.now();
const complete = (
result: Omit<ScenarioRunResult, "startedAt" | "completedAt">,
): ScenarioRunResult => ({
...result,
startedAt,
completedAt: Math.max(startedAt, Date.now()),
});

try {
const observation = await executeScenario(caseRecord);
const assertionResults = evaluateAssertions(caseRecord.assertions, observation);
Expand All @@ -688,7 +697,7 @@ export async function runScenario(caseRecord: CaseRecord): Promise<ScenarioRunRe
}
const controlPassed = listed.every((id) => assertionResults.find((r) => r.id === id)?.passed === true);
const expectedFailureMatched = controlPassed && requiredFailures.length === 0;
return {
return complete({
scenarioId: caseRecord.id,
suite: caseRecord.suite,
passed: expectedFailureMatched,
Expand All @@ -702,11 +711,11 @@ export async function runScenario(caseRecord: CaseRecord): Promise<ScenarioRunRe
expectedFailureMatched,
diagnostics,
executionContext,
};
});
}

const passed = requiredFailures.length === 0;
return {
return complete({
scenarioId: caseRecord.id,
suite: caseRecord.suite,
passed,
Expand All @@ -715,10 +724,10 @@ export async function runScenario(caseRecord: CaseRecord): Promise<ScenarioRunRe
assertionResults,
diagnostics,
executionContext,
};
});
} catch (error) {
diagnostics.push(String(error));
return {
return complete({
scenarioId: caseRecord.id,
suite: caseRecord.suite,
passed: false,
Expand All @@ -727,6 +736,6 @@ export async function runScenario(caseRecord: CaseRecord): Promise<ScenarioRunRe
assertionResults: [],
diagnostics,
executionContext,
};
});
}
}
2 changes: 2 additions & 0 deletions src/lab/conformance/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export interface ProtocolExecutionContextV1 {
export interface ScenarioRunResult {
scenarioId: string;
suite: string;
startedAt: number;
completedAt: number;
passed: boolean;
classification: FailureClassification;
secondaryCode?: string;
Expand Down
5 changes: 4 additions & 1 deletion src/lab/events/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const FORBIDDEN_EXACT_KEYS = new Set([
"rawBytes",
]);

const RAW_POSIX_PATH_RE =
/(?:^|[^A-Za-z0-9._~/])\/(?:(?=$|[^A-Za-z0-9._~/])|(?!\/)(?![ \t\r\n])(?:\/|[^/\0\r\n]+)+\/?(?=$|[^A-Za-z0-9._~/]))/u;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function fieldPath(base: string, key: string | number): string {
return base ? `${base}.${String(key)}` : String(key);
}
Expand Down Expand Up @@ -77,7 +80,7 @@ export function enforceEventStructureLimits(
}
if (
/^[A-Za-z]:\\/.test(value) ||
/(?:^|[\s"'([])\/(?:home|Users|tmp|var|etc|root|mnt)\//.test(value) ||
RAW_POSIX_PATH_RE.test(value) ||
value.includes("\\Users\\")
) {
throw new LabValidationError("raw_path", `${path} contains raw filesystem path`);
Expand Down
14 changes: 11 additions & 3 deletions src/lab/events/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,6 @@ function validatePurge(raw: Record<string, unknown>): PurgeTombstoneEvent {
"targetArtifactDigests",
{ nonEmpty: false, max: MAX_INVALIDATION_TARGETS },
);
if (targetEventIds.length === 0 && targetArtifactDigests.length === 0) {
throw new LabValidationError("empty_purge_targets", "at least one purge target required");
}
if (raw.reason !== "sensitive_evidence") {
throw new LabValidationError("invalid_purge_reason", "reason must be sensitive_evidence");
}
Expand All @@ -520,6 +517,17 @@ function validatePurge(raw: Record<string, unknown>): PurgeTombstoneEvent {
throw new LabValidationError("unsorted_purge_actions", "purgeActions must be sorted");
}
}
// Directory-scoped actions (scratch/export) are meaningful without event or
// artifact ids — default sensitive purge wipes those trees and still records
// a tombstone. Ledger/sqlite/artifact-only tombstones still need a target.
const hasDirectoryPurge = purgeActions.includes("scratch") || purgeActions.includes("export");
if (
targetEventIds.length === 0
&& targetArtifactDigests.length === 0
&& !hasDirectoryPurge
) {
throw new LabValidationError("empty_purge_targets", "at least one purge target required");
}
return {
schemaVersion: LAB_EVENT_SCHEMA_VERSION,
eventId: assertString(raw.eventId, "eventId"),
Expand Down
Loading
Loading