Skip to content

feat(model): add versioned contracts and validation (Task 1.1) - #4

Merged
Andreas-Froyland merged 2 commits into
mainfrom
task-1.1-contracts
Sep 21, 2026
Merged

Andreas-Froyland merged 2 commits into
mainfrom
task-1.1-contracts

Conversation

@Andreas-Froyland

@Andreas-Froyland Andreas-Froyland commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Summary

Task 1.1 of the plan: schema version 1 records and validators, so malformed or future-schema data cannot enter aggregation silently.

parseCandidate, parseProject, parseReport, parseException (plus parseRequirement and parseUploadProvenance) take unknown and return { ok: true, value } | { ok: false, error }. They never throw on bad input. error.issues lists every problem with a path (artifacts[1].sha256) and a stable code for the CLI/UI to display.

Rejected: unknown/future schema versions (reported alone, the rest of the record is not inspected), unknown fields, malformed hashes, duplicate artifacts/requirements/suites/attempts/profiles/markers, path traversal in file names and relative paths (.., absolute, backslashes, drive prefixes, NTFS streams, Windows device names, trailing dot/space, NUL), empty required fields, and references outside a supplied candidate or requirement set.

How it was built

Test-first. the first 169 tests were run against always-rejecting stubs and failed on assertions (168 of 169; only the toolchain smoke test passed). That surfaced 16 vacuous tests (accept-tests that could not tell "accepted" from "rejected with no details", and reject-tests asserting only ok === false), which were tightened before any implementation. Afterwards each rule was mutation-checked by deliberately breaking it and confirming a test fails. Two redundant conditions survived mutation because other rules already covered them; they were removed. The mutation checks were run by hand and are not committed.

Decisions to review

  • Return a result, not a throw, so callers must handle failure.
  • Shapes the plan left open are my design: Requirement (key, mode, title, capabilities), Project (definitions once in requirements, suites reference keys), Exception (requirement keys, reason, claimed actor, UTC timestamp). Requirement keys are <profile>/<scenario> in lower case.
  • Report.environment is a required field (measured os/arch/capabilities/tool version), which the plan only described as stored "alongside the report". UploadProvenance is a separate type because a report cannot attest to its own origin; the claimed actor stays plain data.
  • retryOf only forbids self-reference, since a retry may live in another report. Resolving retries is Task 1.2.
  • Strict objects: unknown fields are rejected rather than ignored.
  • Hand-rolled validators instead of a schema library, because the plan needs specific named codes. That is about 250 lines of my own code to review.

Not covered

  • "Parses identically on Windows and Linux": there is no OS-specific logic, so the same suite runs on both CI runners; that is the evidence. An earlier CRLF round-trip test could never fail (JSON.parse ignores line endings) and was removed after review. Tests now put real line breaks into text fields, and validation rejects Windows-only path hazards on every OS, so a record is judged by the same rules everywhere.
  • No real consumer project.json exists yet, so the project shape is untested against a real one.
  • No length limits beyond text fields, file names and IDs.

Test plan

  • Clean npm ci, npm run typecheck, npm test: 169 passed (Windows 11, Node 24.13)
  • CI on ubuntu-24.04 and windows-2025

🤖 Generated with Claude Code


Summary by cubic

Adds schema version 1 contracts and validators so malformed or future-schema data can't enter aggregation silently.

  • Six parsers (parseCandidate, parseProject, parseReport, parseException, parseRequirement, parseUploadProvenance) take unknown and return a result object; they never throw, even on hostile input (bigint or cyclic schema versions, objects whose getters throw).
  • Errors list every problem with a path (e.g. artifacts[1].sha256) and a stable code for the CLI/UI to display. Sparse array holes are validated, not skipped.
  • Rejects unknown or future schema versions (reported alone, rest of the record not inspected), unknown fields, malformed hashes, duplicate entries, path traversal in file names and paths, empty required fields, and references outside a supplied candidate or requirement set. All path segments share one check that catches Windows reserved characters, device names, NTFS streams, drive prefixes, and trailing dots or spaces.

Review

  • Validators return a result instead of throwing so callers must handle failure; shapes the plan left open (Requirement, Project, Exception, Report) are new design decisions.
  • Hand-rolled validators (~250 lines) instead of a schema library to get named error codes.
  • retryOf only forbids self-reference; resolving retries is Task 1.2.
  • Built test-first with 169 tests and mutation checks; no real consumer project.json exists yet, and length limits beyond text fields, file names, and IDs aren't set.

Written for commit b0a372b. Summary will update on new commits.

Review in cubic

Add schema version 1 records with validators that never throw on bad input
and report every problem with a path and a stable code: candidate, project,
requirement, report, exception and upload provenance.

Validators reject unknown or future schema versions (without inspecting the
rest of the record), unknown fields, malformed hashes, duplicate artifacts,
requirements, suites and attempts, path traversal in file names and paths
(including Windows device names, drive prefixes and alternate data streams),
empty required fields, and references that fall outside a supplied candidate
or requirement set.

Built test-first: 169 tests, watched failing against always-rejecting stubs,
and each rule then mutation-checked so a test fails when the rule is broken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/qa/src/model/requirement.ts">

<violation number="1" location="packages/qa/src/model/requirement.ts:27">
P2: When `capabilities` is a sparse JavaScript array, `.map()` skips the missing slots and this parser accepts the malformed requirement. Use `Array.from` or an indexed loop so every slot is validated as a required capability name.</violation>
</file>

<file name="packages/qa/src/model/exception.ts">

<violation number="1" location="packages/qa/src/model/exception.ts:21">
P2: When `schemaVersion` is a `bigint` (or another value that `JSON.stringify` cannot serialize), `parseException` throws instead of honoring its non-throwing `unknown` input contract. Make the shared version-error formatting total, or catch this path and return a validation issue.</violation>
</file>

<file name="packages/qa/src/model/candidate.ts">

<violation number="1" location="packages/qa/src/model/candidate.ts:66">
P2: On Windows, `parseCandidate` accepts artifact names containing `<`, `>`, `"`, `|`, `?`, or `*`. Reject Windows-reserved filename characters in `fileName` before accepting the candidate.</violation>
</file>

<file name="packages/qa/src/model/result.ts">

<violation number="1" location="packages/qa/src/model/result.ts:133">
P1: A report can pass validation with evidence such as `evidence/trace:secret`, which Windows interprets as an alternate data stream. Make `relativePath` reject Windows-reserved characters, device names, and trailing dots or spaces in every path segment.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

id,
requirement: c.requirementKey(rec.requirement, at(path, 'requirement')),
outcome: c.oneOf(rec.outcome, at(path, 'outcome'), OUTCOMES),
evidence: (c.array(rec.evidence, evidencePath) ?? []).map((v, i) => c.relativePath(v, item(evidencePath, i))),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A report can pass validation with evidence such as evidence/trace:secret, which Windows interprets as an alternate data stream. Make relativePath reject Windows-reserved characters, device names, and trailing dots or spaces in every path segment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/result.ts, line 133:

<comment>A report can pass validation with evidence such as `evidence/trace:secret`, which Windows interprets as an alternate data stream. Make `relativePath` reject Windows-reserved characters, device names, and trailing dots or spaces in every path segment.</comment>

<file context>
@@ -0,0 +1,145 @@
+    id,
+    requirement: c.requirementKey(rec.requirement, at(path, 'requirement')),
+    outcome: c.oneOf(rec.outcome, at(path, 'outcome'), OUTCOMES),
+    evidence: (c.array(rec.evidence, evidencePath) ?? []).map((v, i) => c.relativePath(v, item(evidencePath, i))),
+  };
+  if (retryOf !== undefined) attempt.retryOf = retryOf;
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. relativePath now runs every segment through the same check as fileName: Windows reserved characters (including ':' for NTFS streams and drive prefixes), device names, trailing dot/space and control characters. Tests cover evidence/trace:secret, CON as a directory, nul.txt, trailing dot/space directories and the other reserved characters, and I mutation-checked each reserved character (dropping any one makes tests fail).

}

function readRequirementFields(c: Collector, rec: Record<string, unknown>, path: string): Requirement {
const capabilities = (c.array(rec.capabilities, at(path, 'capabilities')) ?? []).map((v, i) => c.name(v, item(at(path, 'capabilities'), i)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When capabilities is a sparse JavaScript array, .map() skips the missing slots and this parser accepts the malformed requirement. Use Array.from or an indexed loop so every slot is validated as a required capability name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/requirement.ts, line 27:

<comment>When `capabilities` is a sparse JavaScript array, `.map()` skips the missing slots and this parser accepts the malformed requirement. Use `Array.from` or an indexed loop so every slot is validated as a required capability name.</comment>

<file context>
@@ -0,0 +1,38 @@
+}
+
+function readRequirementFields(c: Collector, rec: Record<string, unknown>, path: string): Requirement {
+  const capabilities = (c.array(rec.capabilities, at(path, 'capabilities')) ?? []).map((v, i) => c.name(v, item(at(path, 'capabilities'), i)));
+  return {
+    key: c.requirementKey(rec.key, at(path, 'key')),
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. array() now returns Array.from(value), so holes are visited and validated. Tests cover a sparse capabilities array and a sparse attempts array; reverting to the raw array makes both fail.

const SPEC: FieldSpec = { required: ['id', 'candidateId', 'requirements', 'reason', 'actor', 'createdAt'] };

export function parseException(input: unknown, context: ReferenceContext = {}): ParseResult<Exception> {
return parseVersioned(input, SPEC, (c, rec) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When schemaVersion is a bigint (or another value that JSON.stringify cannot serialize), parseException throws instead of honoring its non-throwing unknown input contract. Make the shared version-error formatting total, or catch this path and return a validation issue.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/exception.ts, line 21:

<comment>When `schemaVersion` is a `bigint` (or another value that `JSON.stringify` cannot serialize), `parseException` throws instead of honoring its non-throwing `unknown` input contract. Make the shared version-error formatting total, or catch this path and return a validation issue.</comment>

<file context>
@@ -0,0 +1,39 @@
+const SPEC: FieldSpec = { required: ['id', 'candidateId', 'requirements', 'reason', 'actor', 'createdAt'] };
+
+export function parseException(input: unknown, context: ReferenceContext = {}): ParseResult<Exception> {
+  return parseVersioned(input, SPEC, (c, rec) => {
+    const candidateId = c.id(rec.candidateId, 'candidateId');
+    checkCandidateId(c, context, candidateId, 'candidateId');
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The schema-version error text is built by a total describe() (try/catch around JSON.stringify), and parsing is wrapped so anything else that throws, such as an object with throwing getters, becomes an invalid-type issue. Tests cover a bigint, a circular object and a throwing Proxy for all four records.

if (rec === undefined) return undefined;
return {
profile: c.profileId(rec.profile, at(path, 'profile')),
name: c.fileName(rec.name, at(path, 'name')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On Windows, parseCandidate accepts artifact names containing <, >, ", |, ?, or *. Reject Windows-reserved filename characters in fileName before accepting the candidate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/candidate.ts, line 66:

<comment>On Windows, `parseCandidate` accepts artifact names containing `<`, `>`, `"`, `|`, `?`, or `*`. Reject Windows-reserved filename characters in `fileName` before accepting the candidate.</comment>

<file context>
@@ -0,0 +1,71 @@
+  if (rec === undefined) return undefined;
+  return {
+    profile: c.profileId(rec.profile, at(path, 'profile')),
+    name: c.fileName(rec.name, at(path, 'name')),
+    sha256: c.sha256(rec.sha256, at(path, 'sha256')),
+    assetId: c.int(rec.assetId, at(path, 'assetId')),
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed together with the P1: fileName rejects < > : " | ? * with tests per character; mutation-checked.

Comment thread packages/qa/test/model/contracts.test.ts Outdated
relativePath and fileName now share one segment check that rejects Windows
reserved characters (< > : " | ? * and both separators), device names,
trailing dots or spaces and control characters, so evidence such as
"trace:secret" (an NTFS alternate data stream) is refused.

Arrays are copied densely so holes in a sparse array are validated instead of
skipped. Parsing is wrapped so a schema version that JSON.stringify cannot
serialize (bigint, cyclic) or an object whose getters throw yields a
validation failure, honouring the never-throws contract. Control characters
are detected by character code so the source holds no raw control bytes.

The CRLF round-trip test could never fail (JSON.parse ignores line endings)
and is replaced by tests that put real line breaks into text fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Andreas-Froyland
Andreas-Froyland merged commit cc5d0c5 into main Sep 21, 2026
4 checks passed
@Andreas-Froyland
Andreas-Froyland deleted the task-1.1-contracts branch September 21, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant