Plan 01-05 summary: frozen schemas and SCHEMA_INVALID validation wrapper (FOUND-02, QUAL-01)
Status: complete. Built TDD: four test files written first, RED recorded, then the three schemas and validate.ts written, GREEN recorded. All four scripts exit 0. No banned directive from D-08 appears in any file written. No package was installed and package.json is unchanged.
Files changed
| File | Change |
|---|---|
src/core/policy.schema.json | New. Exactly the D-15 keys, defaults applied by the wrapper, every object additionalProperties: false. |
src/contract/tools.schema.json | New. Map keyed by the six D-13 tool names. Path rules at schema level. |
src/contract/session-record.schema.json | New. D-14 fields, containerId string or null, six-state enum, no schemaVersion. |
src/contract/validate.ts | New. The only module that imports ajv. Named-schema wrapper returning a typed value or a SchemaInvalidError. |
test/core/policy.schema.test.ts | New. Defaults, fail-closed acceptance, mode rejection, zero-budget rejection, unknown-key rejection, egress rules. |
test/contract/tools.schema.test.ts | New. Six tools by name, argv and shell exec forms, path rules (absolute, .., Windows forms), ifMatchSha256, maxBytes boundary, empty and unknown-key inputs. |
test/contract/session-record.schema.test.ts | New. Null and string containerId, required fields, empty object, the six states cross-checked against STATES, forbidden keys. |
test/contract/validate.test.ts | New. Typed value, SCHEMA_INVALID shape, detail naming, unknown key, non-object input, and the secret-value leak test. |
.planning/phases/01-scaffold-and-frozen-contracts/01-05-SUMMARY.md | This file. |
src/core/errors.ts, src/core/states.ts, package.json, tsconfig.json, and docs/ are untouched.
TDD evidence
RED. npm test after writing the four test files and before any implementation. tsc fails on the missing module, so the node test runner never starts:
> tsc -p tsconfig.json && node --test "dist/test/**/*.test.js"
test/contract/session-record.schema.test.ts(7,8): error TS2307: Cannot find module '../../src/contract/validate.js' or its corresponding type declarations.
test/contract/tools.schema.test.ts(7,8): error TS2307: Cannot find module '../../src/contract/validate.js' or its corresponding type declarations.
test/contract/validate.test.ts(7,8): error TS2307: Cannot find module '../../src/contract/validate.js' or its corresponding type declarations.
test/core/policy.schema.test.ts(7,8): error TS2307: Cannot find module '../../src/contract/validate.js' or its corresponding type declarations.
(followed by TS2571/TS18046 "unknown" errors from the unresolved import)
EXIT=2GREEN. Same command after the schemas and validate.ts were written:
ℹ tests 155
ℹ pass 155
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
EXIT=0Final gate run, all after GREEN:
| Script | Command | Exit |
|---|---|---|
| typecheck | npm run typecheck | 0 |
| lint | npm run lint | 0 |
| test | npm test (155 pass, 0 fail, 0 cancelled, reporter-listed skipped and todo counts 0) | 0 |
| build | npm run build | 0 |
Failures hit on the way to GREEN, all fixed without suppression:
TS2351: This expression is not constructableon the default import ofajv/dist/2020.js. Under NodeNext the CJS default import is typed as the module namespace. Fixed by the named import (see Decisions).- Runtime
strictRequirederror on theexecif/then/else. Fixed by settingstrictRequired: falseon the ajv instance (see Decisions). - Lint
@typescript-eslint/no-dynamic-deletein thewithout()test helper. Fixed by rebuilding the object withObject.fromEntries.
Banned-directive sweep (D-08) over src/ and test/ returned no matches. A known-positive query over the same paths returned the expected lines, so the search ran.
Decisions
Error reason versus D-12 (approved by Main). D-12 requires the
SCHEMA_INVALIDreason to name the offending key or path.RealmError(code)takes its reason only from the catalogue, and the plan forbids editingerrors.ts. Applied, as approved:validate.tsreturnsSchemaInvalidError extends RealmError, with codeSCHEMA_INVALIDand the catalogue reason unchanged. It adds adetailfield, andmessageis the catalogue reason plusdetail.detailis the JSON pointer and a fixed rule phrase (for example/write/path: matches a forbidden form,/sessionId: required key is missing). The cataloguereasonon its own does not name the path; the path is indetailandmessage. Clean fix for later: an optional detail argument onRealmError, which toucheserrors.tsand needs its own decision.No value ever appears in a failure (approved by Main).
detailis built only from the pointer, the key name foradditionalPropertiesandrequiredfailures, and a fixed phrase per ajv keyword. ajv's own message text is never used, because it can include values. Testa secret-shaped offending value never appears in the errorfeeds a secret-shaped string into nine invalid positions and asserts it is absent frommessage,detail,reason,JSON.stringify(error), andString(error).Tool call shape (D-13 map). A tool call is an object with exactly one key, the tool name, whose value is that tool's input:
{ "write": { "path": "a.txt", "content": "x" } }.minProperties: 1andmaxProperties: 1reject an empty envelope and an envelope holding two tools. Plan wording left this open; this shape is the reading of "a map keyed by tool name".execform. A single object withargv(array,minItems: 1),shell(const: true), andscript(non-empty string). Anif: { required: ["shell"] }selects the shell form, whosethenrequiresscriptand forbidsargv. Otherwiseelserequiresargv. Both forms are schema-valid. Policy gating of the shell form (SHELL_NOT_ALLOWED) stays in Phase 2. AoneOfover two forms was tried first and rejected: when both branches fail it reports only/exec, which hides the/exec/argvpath the plan names.strictRequired: false. ajv's strictstrictRequiredcheck flagsrequirednames inif,then, andelsesubschemas that are declared only in the parentproperties. That is the normal shape of conditional subschemas. Every other strict check stays on. The option is set on the single ajv instance invalidate.tswith a comment.ajv import form.
import { Ajv2020, type ErrorObject, type SchemaObject, type ValidateFunction } from "ajv/dist/2020.js";. Draft 2020-12 needs the/2020entry, because the rootajventry is draft-07 only. The default import fails typecheck under NodeNext (TS2351), so the named export is used.ajv/dist/2020.d.tsdeclaresexport declare class Ajv2020, andajv/dist/2020.jssetsmodule.exports.Ajv2020, so Node's CJS lexer detects it at runtime.Schema loading.
tscdoes not copy JSON intodist/, andtsconfig.jsonhas noresolveJsonModule, so the schemas are read withreadFileSyncat runtime.validate.tswalks up from its own module location to the directory holdingpackage.json, then readssrc/core/…orsrc/contract/…. This works fromsrc/and fromdist/src/.Defaults. ajv
useDefaultsfills defaults on astructuredCloneof the input, so the caller's object is never mutated and each result holds fresh arrays. Covered by tests.Recorded numeric defaults for the Phase 7 policy reference (Claude's Discretion).
startupBudgetMsdefault 10000;toolQueueMsdefault 2000. Both are positive integers,minimum: 1. Hard-coded nowhere else.Bounds added beyond the plan's wording. Each is a plain validity bound, flagged here so Main can reject any of them:
read.maxBytes: integer,minimum: 1,maximum: 1048576. Negative and zero values are rejected.list.depth: integer,minimum: 0.egressAllow[].port: integer, 1 to 65535.egressAllow[].host, session ids,workspaceRoot,controlSocketPath,exec.script: non-empty strings.write.ifMatchSha256:^[0-9a-f]{64}$, as the plan requires.
containerIdis optional, not required. The plan lists five required fields (realmId,sessionId,workspaceRoot,state,controlSocketPath) and sayscontainerIdis nullable. The schema follows that list exactly:containerIdmay be absent, a string, or null. D-14 says it is always written, starting as null. Phase 2 should always write it. Open for Main if you want it required.Path rule. Workspace-relative paths are non-empty, do not start with
/or\or a drive letter ([A-Za-z]:), and have no..segment in either separator style. Names that only contain dots (..hidden,a..b,dir/..x,x/.) pass. Tests cover both sides. Canonicalization stays in Phase 2.Session record. No
schemaVersion, token, secret, or environment key. Those keys are rejected byadditionalProperties: false. Tests cover each one. Thestateenum is cross-checked againstSTATESfromsrc/core/states.tsin the session-record test.
Open questions
ajv is in devDependencies, but
validate.tsimports it at runtime. Per the plan,package.jsonis unchanged. Main should moveajv@8.20.0todependencies. Until then the built output would not run outside a dev install.Dependency direction for
PolicyandSessionRecordtypes. Spec n2 sayssrc/contract/depends only onsrc/core/. The value types are hand-written invalidate.ts, because JSON schemas cannot be imported under the currenttsconfig(noresolveJsonModule). Phase 2 modules that needPolicywould import fromsrc/contract/, which runs against n2. The right home issrc/core/, which needs a new file outside this plan's scope. The types mirror the schemas by hand; the enum cross-check againstSTATESis the only automated drift guard so far.validate()throws on non-JSON input.structuredClonethrows for functions and symbols. Input is JSON from the host boundary, so this cannot happen there. A TypeScript caller passing such values would get an uncaughtDataCloneError, notSCHEMA_INVALID.
Rework 1
Scope: findings M-1 and m-2 from 01-REVIEW.md (gate CHANGES_REQUESTED). Nothing else changed. M-2 and the other minor items are deferred (see below).
M-1: the exec argv form accepted script (fixed)
src/contract/tools.schema.json: theelsebranch (argv form) now sets"properties": { "script": false, "shell": false }. An argv call that carriesscriptorshellno longer validates. Theifand the shell-formthenare unchanged.- Tests added to
test/contract/tools.schema.test.ts: argv withscriptis rejected and the detail names/exec/script; argv withshell: trueand no script is rejected; argv withshell: falseis rejected. Existing tests still cover shell with argv, mixed forms, and both valid forms.
m-2: validate() threw on non-JSON input (fixed)
src/contract/validate.ts: thestructuredClonecall is wrapped. A clone failure returns aSchemaInvalidError(codeSCHEMA_INVALID) with detailinput holds a value that is not JSON-compatible. The thrownDataCloneErroris discarded, not echoed: V8 builds its message from the value itself (RED output showed() => SECRET could not be cloned.andSymbol(sk-live-SECRET-4242-sym) could not be cloned.).- The detail names the rule, not a JSON pointer. Naming the pointer would need a walker with cycle protection; not added.
- Tests added to
test/contract/validate.test.ts: a function input at the root, and a symbol nested in an object. Both returnSCHEMA_INVALID, and the secret text appears in neithermessage,detail,String(error)norJSON.stringify(error).
TDD evidence
RED: npm test after the tests were added and before any source change. Exit 1. Tests 173, pass 169, fail 3, skipped 1. Failing:
exec argv form with a script key is rejected and the key is named(schema accepted it)a function input returns SCHEMA_INVALID instead of throwing(DataCloneError)a symbol nested in the input returns SCHEMA_INVALID and never echoes its description(DataCloneError)
The two argv-with-shell negatives already passed at RED. Those cases were rejected by the then branch, so they are regression guards, not holes.
GREEN: after both fixes, every gate exits 0:
| Script | Command | Exit | Result |
|---|---|---|---|
| typecheck | npm run typecheck | 0 | clean |
| lint | npm run lint | 0 | clean |
| test | npm test | 0 | tests 173, pass 172, fail 0, skipped 1 |
| build | npm run build | 0 | clean |
| integration | npm run test:integration | 0 | tests 12, pass 11, fail 0, skipped 1 |
The one skip in each run is the Podman gate. The host has Podman 4.9.3, which the gate records as PODMAN_UNSUPPORTED_VERSION (01-06 deviation 2). It was skipped at RED too, so it is not caused by this rework.
Open question 3 above (validate() throws on non-JSON input) is resolved by this rework.
Deferred (not in this rework)
- M-2:
ajvunderdevDependencieswhile imported at runtime. Owner decision;package.jsonis untouched. - m-1:
containerIdoptional in the session-record schema. - m-3:
STATESandERROR_CODESnot frozen at runtime. - m-4: session-record state enum not tied to
STATES. - m-5:
engines>=22admits Node versions that the installed eslint rejects. - m-6: schemas are not copied into
dist/. - m-7: plan 01-06 has no PLAN file.
- m-8: integration notes cited an unnamed earlier note. Resolved in the fix-up pass: each citation is replaced by an official URL or "not documented".
- n-1 to n-4: nits, as listed in the review.
Fix-up pass decisions
- dist self-containment (m-6) deferred to Phase 6. Schemas still load from
src/core/andsrc/contract/at runtime, sodist/is not self-contained. Phase 6 packaging must bundle the schemas intodist/. Not implemented in Phase 1. - Podman gate override (accepted).
PODMAN_UNSUPPORTED_VERSIONis added to the error catalogue, which now has 21 codes: the 20 codes listed above plus this one.npm run test:integrationexists and runs the Podman gate, which records the host's Podman 4.9.3 as unsupported and skips. This overrides D-10 (the 20-code catalogue) and D-05 (no change to the host Podman). - containerId stays optional (m-1). The session record is written before the container is created, so
containerIdis optional insession-record.schema.jsonand accepts a string or null. Phase 2 must always write it, starting as null (D-14).