Skip to content

<objective> Freeze the three JSON schemas and the validation wrapper, each proven by schema-rejection tests written first. Honors D-02: the session record holds one container reference per session (nullable containerId), with no per-workspace or shared-container field.

Purpose: Every later module validates tool input, session records, and policy against these exact files. Output: Three schemas, the validate.ts wrapper, and four contract test files, all green. </objective>

<execution_context> @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md </execution_context>

<context> @.planning/PROJECT.md @.planning/phases/01-scaffold-and-frozen-contracts/01-CONTEXT.md @.planning/phases/01-scaffold-and-frozen-contracts/01-PATTERNS.md @package.json @src/core/errors.ts </context>

<feature> <name>Frozen schemas and SCHEMA_INVALID validation wrapper</name> <files>src/core/policy.schema.json, src/contract/tools.schema.json, src/contract/session-record.schema.json, src/contract/validate.ts, test/core/policy.schema.test.ts, test/contract/tools.schema.test.ts, test/contract/session-record.schema.test.ts, test/contract/validate.test.ts</files> <behavior> Cases: validate(policySchema, {mode advisory defaults}) returns the typed value with mode advisory, workspaceRoots [], credentialAllow [], egressAllow [], shellEnabled false, startupBudgetMs 10000, toolQueueMs 2000; validate(toolsSchema, {write with path ../escape}) is rejected with SCHEMA_INVALID naming the path; validate(toolsSchema, {exec argv form}) and {exec shell form} are both accepted; validate(toolsSchema, {read with extra unknown key}) is rejected with SCHEMA_INVALID; validate(sessionRecordSchema, {record with containerId null}) is accepted; validate(sessionRecordSchema, {record missing sessionId}) is rejected; validate(anySchema, {read with maxBytes 1048577}) is rejected while maxBytes 1048576 is accepted. Edge coverage: QUAL-01 boundary resolves to the maxBytes and positive-integer bounds tests; QUAL-01 empty resolves to the empty-object rejection plus containerId-null acceptance; FOUND-02 unclassified resolves to the per-schema invalid-input rejection tests; QUAL-01 precision is an assumption (all numeric fields are JSON Schema integers, no floats, no rounding). </behavior> <implementation>Write the three schemas with additionalProperties false on every object: policy.schema.json with exactly the D-15 keys and defaults (mode advisory default, workspaceRoots [], credentialAllow [], egressAllow [], shellEnabled false, startupBudgetMs default 10000, toolQueueMs default 2000, all positive-integer bounded per Claude's Discretion); tools.schema.json as a map keyed by tool name (exec, read, write, list, status, retry) so each tool is addressable by name with the D-13 field shapes, workspace-relative paths enforced at schema level, read.maxBytes integer with maximum 1048576, ifMatchSha256 a 64-character lowercase hex string; session-record.schema.json with the D-14 required fields, containerId string-or-null, state enum of the six states, and no token, secret, environment, or schemaVersion field. Write src/contract/validate.ts importing RealmError from src/core/errors.ts and the plan 01-03 installed validator dependency (read package.json for its resolved name; never install here): validate(input) against a named schema returns the typed value or a RealmError with code SCHEMA_INVALID whose reason names the offending key or path. RED: write the four test files first and watch them fail. GREEN: write minimal schemas and wrapper to pass. REFACTOR only if tests stay green. Reversibility one-way per D-14 and D-15: the session record is persisted on disk and policy is user-authored config, so shape changes need migration.</implementation> </feature>

<tasks>

<task type="auto" tdd="true"> <name>Task 1: Freeze policy.schema.json with its contract test</name> <files>src/core/policy.schema.json, test/core/policy.schema.test.ts</files> <read_first> - .planning/phases/01-scaffold-and-frozen-contracts/01-CONTEXT.md (D-15 keys and defaults; one-way reversibility; D-01 advisory default) - package.json (resolved validator dependency name for the test harness import) </read_first> <behavior> - Test: defaults resolve to mode advisory, workspaceRoots [], credentialAllow [], egressAllow [], shellEnabled false, startupBudgetMs 10000, toolQueueMs 2000 - Test: mode fail-closed accepted; any other mode string rejected; startupBudgetMs 0 rejected; unknown key rejected </behavior> <action>Write test/core/policy.schema.test.ts first asserting the D-15 defaults including startupBudgetMs 10000 and toolQueueMs 2000 as the Claude's Discretion short bounded positive integers, then write src/core/policy.schema.json with exactly the D-15 keys, all objects additionalProperties false, mode enum advisory and fail-closed with advisory default, workspaceRoots array default [], credentialAllow array of glob strings default [], egressAllow array of host-port objects default [], shellEnabled boolean default false, and the two budget fields as positive integers with the stated defaults; run red then green. Record the chosen numeric defaults in the task output for the Phase 7 policy reference and hard-code them nowhere else.</action> <acceptance_criteria> - src/core/policy.schema.json contains exactly the keys mode, workspaceRoots, credentialAllow, egressAllow, shellEnabled, startupBudgetMs, toolQueueMs with the D-15 defaults - test/core/policy.schema.test.ts asserts the defaults, a fail-closed acceptance, a mode-string rejection, a zero-budget rejection, and an unknown-key rejection - npm test, npm run typecheck, and npm run lint all exit 0 </acceptance_criteria> <verify> <automated>npm test -- test/core/policy.schema.test.ts</automated> <fails_when>non-zero exit, or "0 passed" in the summary line</fails_when> </verify> <done>Policy schema frozen with D-15 keys and recorded numeric defaults; RED then GREEN observed.</done> </task>

<task type="auto" tdd="true"> <name>Task 2: Freeze tools.schema.json and session-record.schema.json with their tests</name> <files>src/contract/tools.schema.json, src/contract/session-record.schema.json, test/contract/tools.schema.test.ts, test/contract/session-record.schema.test.ts</files> <read_first> - .planning/phases/01-scaffold-and-frozen-contracts/01-CONTEXT.md (D-13 six tools and field shapes, D-14 session fields with containerId null; one-way reversibility) - src/core/policy.schema.json (sibling schema conventions to mirror: additionalProperties false, enums) </read_first> <behavior> - Test: exec argv form and exec shell form both accepted; exec with empty argv rejected - Test: read/write/list with absolute or dotdot paths rejected; write ifMatchSha256 with non-64-hex rejected - Test: session record with containerId null accepted; missing sessionId rejected; unknown key rejected; six-state enum enforced </behavior> <action>Write both test files first, then write src/contract/tools.schema.json as a map keyed by tool name (Claude's Discretion shape: top-level properties exec, read, write, list, status, retry, each addressable by name, additionalProperties false throughout) with the D-13 field shapes: exec argv form { argv string array minItems 1 } and shell form { shell true, script } both schema-valid with later SHELL_NOT_ALLOWED gating left to Phase 2, read { path, maxBytes } with maxBytes integer maximum 1048576 (Claude's Discretion upper bound), write { path, content, ifMatchSha256? } with ifMatchSha256 64-character lowercase hex, list { path, depth }, status and retry {}, every path a non-empty workspace-relative string that is not absolute and has no dotdot segment enforced at schema level; write src/contract/session-record.schema.json with required realmId, sessionId, workspaceRoot, state (six-state enum), controlSocketPath, containerId string-or-null defaulting to the null-until-container case, additionalProperties false, and no token, secret, environment, or schemaVersion field per D-14. Run red then green.</action> <acceptance_criteria> - src/contract/tools.schema.json addresses all six tools by name with the D-13 field shapes and workspace-relative path rules - src/contract/session-record.schema.json requires the five D-14 fields, accepts containerId null, and rejects unknown keys - Both test files assert the behavior cases above including absolute/dotdot rejection and null-containerId acceptance </acceptance_criteria> <verify> <automated>npm test -- test/contract/tools.schema.test.ts test/contract/session-record.schema.test.ts</automated> <fails_when>non-zero exit, or "0 passed" in the summary line</fails_when> </verify> <done>Tool and session-record schemas frozen with rejection coverage; RED then GREEN observed.</done> </task>

<task type="auto" tdd="true"> <name>Task 3: Write the src/contract/validate.ts wrapper with its test</name> <files>src/contract/validate.ts, test/contract/validate.test.ts</files> <read_first> - src/core/errors.ts (RealmError shape and SCHEMA_INVALID code the wrapper returns) - package.json (resolved validator dependency name; this plan never runs an install) - src/contract/tools.schema.json (named-schema addressing the wrapper resolves) </read_first> <behavior> - Test: valid input against a named schema returns the typed value - Test: invalid input returns a RealmError with code SCHEMA_INVALID whose reason names the offending key or path - Test: unknown-key input is rejected with SCHEMA_INVALID (D-12) </behavior> <action>Write test/contract/validate.test.ts first covering typed-value success, SCHEMA_INVALID with the offending key or path named in reason, and unknown-key rejection; then write src/contract/validate.ts per D-12 importing RealmError from src/core/errors.ts and the plan 01-03 installed validator dependency by its resolved package.json name, exposing validation of an input against a named schema (map-keyed tool names for tools.schema.json) that returns either the typed value or the SCHEMA_INVALID RealmError; run red then green. Keep src/contract/ depending only on src/core/ per spec n2. Run no install command in this plan.</action> <acceptance_criteria> - src/contract/validate.ts imports RealmError from src/core/errors.ts and the resolved validator dependency only - test/contract/validate.test.ts asserts typed-value success, SCHEMA_INVALID with the key or path in reason, and unknown-key rejection - npm test, npm run typecheck, and npm run lint all exit 0 </acceptance_criteria> <verify> <automated>npm test -- test/contract/validate.test.ts</automated> <fails_when>non-zero exit, or "0 passed" in the summary line</fails_when> </verify> <done>Wrapper returns typed values or SCHEMA_INVALID naming the key or path; RED then GREEN observed.</done> </task>

</tasks>

<threat_model>

Trust Boundaries ​

BoundaryDescription
untrusted input -> validate.tsTool input crosses here; schemas are the enforcement point

STRIDE Threat Register ​

Threat IDCategoryComponentSeverityDispositionMitigation Plan
T-01-09T (Tampering)tools.schema.json path rulemediummitigateSchema-level absolute/dotdot rejection tests; canonicalization itself is Phase 2 and stays out
T-01-10I (Information Disclosure)session-record.schema.jsonmediummitigateSchema holds no token, secret, or environment field; test asserts rejection of unknown keys
T-01-11E (Elevation)exec shell formmediummitigateShell form is schema-valid per D-13 but gated by SHELL_NOT_ALLOWED in Phase 2; prohibition recorded above
T-01-SCT (Tampering)npm installshighmitigateNo installs in this plan; validator comes from plan 01-03's approved install
</threat_model>

<verification> npm run typecheck, npm run lint, and npm test all exit 0; each schema rejects a sample invalid input including an unknown key; no installs ran in this plan. </verification>

<success_criteria> Three schemas frozen with D-13/D-14/D-15 shapes, wrapper returns typed values or SCHEMA_INVALID naming the key or path, boundary and empty-input edges covered, numeric defaults recorded for Phase 7, no suppressions. </success_criteria>

<output> Create .planning/phases/01-scaffold-and-frozen-contracts/01-05-SUMMARY.md when done </output>

Artifacts this phase produces ​

  • src/core/policy.schema.json (D-15 keys; startupBudgetMs default 10000, toolQueueMs default 2000; one-way reversibility)
  • src/contract/tools.schema.json (map keyed by tool name; six D-13 tools; maxBytes maximum 1048576; one-way contract)
  • src/contract/session-record.schema.json (D-14 fields; containerId string-or-null; no schemaVersion; one-way)
  • src/contract/validate.ts (named-schema validation returning typed value or SCHEMA_INVALID RealmError)
  • test/core/policy.schema.test.ts, test/contract/tools.schema.test.ts, test/contract/session-record.schema.test.ts, test/contract/validate.test.ts (rejection coverage incl. unknown keys, bounds, empty input, null containerId)

AGPL-3.0-only. Copyright (C) 2026 Spectrum Web Co/swcstudiospace.