feat(model): add versioned contracts and validation (Task 1.1) - #4
Conversation
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>
There was a problem hiding this comment.
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))), |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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))); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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')), |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
Fixed together with the P1: fileName rejects < > : " | ? * with tests per character; mutation-checked.
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>
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(plusparseRequirementandparseUploadProvenance) takeunknownand return{ ok: true, value } | { ok: false, error }. They never throw on bad input.error.issueslists every problem with apath(artifacts[1].sha256) and a stablecodefor 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
Requirement(key, mode, title, capabilities),Project(definitions once inrequirements, suites reference keys),Exception(requirement keys, reason, claimed actor, UTC timestamp). Requirement keys are<profile>/<scenario>in lower case.Report.environmentis a required field (measured os/arch/capabilities/tool version), which the plan only described as stored "alongside the report".UploadProvenanceis a separate type because a report cannot attest to its own origin; the claimedactorstays plain data.retryOfonly forbids self-reference, since a retry may live in another report. Resolving retries is Task 1.2.Not covered
project.jsonexists yet, so the project shape is untested against a real one.Test plan
npm ci,npm run typecheck,npm test: 169 passed (Windows 11, Node 24.13)ubuntu-24.04andwindows-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.
parseCandidate,parseProject,parseReport,parseException,parseRequirement,parseUploadProvenance) takeunknownand return a result object; they never throw, even on hostile input (bigint or cyclic schema versions, objects whose getters throw).artifacts[1].sha256) and a stable code for the CLI/UI to display. Sparse array holes are validated, not skipped.Review
retryOfonly forbids self-reference; resolving retries is Task 1.2.project.jsonexists yet, and length limits beyond text fields, file names, and IDs aren't set.Written for commit b0a372b. Summary will update on new commits.