From ff2ebf843638cbfc954f85faac6c6162538aa1c5 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 10:51:10 -0700 Subject: [PATCH 1/7] Add the validator rule language PRP, design spec, and implementation plan --- ...-09-validator-rule-language-advisor-log.md | 52 +++ ...2026-09-09-validator-rule-language-plan.md | 73 ++++ ...09-09-validator-rule-language-run-state.md | 26 ++ ...2026-09-09-validator-rule-language-spec.md | 277 +++++++++++++++ .../prp/2026-09-09-validator-rule-language.md | 332 ++++++++++++++++++ 5 files changed, 760 insertions(+) create mode 100644 docs/plans/2026-09-09-validator-rule-language-advisor-log.md create mode 100644 docs/plans/2026-09-09-validator-rule-language-plan.md create mode 100644 docs/plans/2026-09-09-validator-rule-language-run-state.md create mode 100644 docs/plans/2026-09-09-validator-rule-language-spec.md create mode 100644 docs/prp/2026-09-09-validator-rule-language.md diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md new file mode 100644 index 0000000..10944f8 --- /dev/null +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -0,0 +1,52 @@ +# Advisor and verifier log — validator rule language + +Session `autonomous-validator-rule-language-2026-09-09`. Every decision and every verdict, in order. + +### Decision 1: PRP acceptance (human) +**Skill:** autonomous-advisor +**Question:** Does `docs/prp/2026-09-09-validator-rule-language.md` stand as the source of truth? +**Owner decided:** Approved, start the run. +**Reasoning:** Owner review of the draft on 2026-09-09; no edits requested. + +### Decision 2: MemBerry bootstrap (orchestrator) +**Skill:** autonomous-advisor activation +**Question:** Invoke `memberry-setup` to write a project config into CLAUDE.md? +**Decided:** Skip; store run decisions under `project:kernel-zero` without a project bootstrap. +**Reasoning:** MemBerry is reachable and the store works without the config; editing CLAUDE.md is outside PRP section 3 scope and the skill treats MemBerry as optional. + +### Verdict 1: DESIGN_APPROVAL on the design spec (revision 1) +**Verifier:** autonomous-verifier on a different model +**Verdict:** REJECT, ten required fixes. +**Substance:** two self-policy rules would fail on the current tree (opaque-spread rule versus `evidence.ts` `where` literals; the proposed route edit was not provable); new fixtures under `fixtures/checks/**` would move the golden digest; layer references leaked into `scope`; `findUnique` contradiction; only one verb rule; unsafe-receiver detection unreachable after `unwrapExpression`; FR-DOG rows missing; TM-V03 discharge unstated; closure escape grammar unfixed. + +### Decision 3: spec revision 2 (orchestrator, advisor role) +**Question:** how to satisfy the ten fixes. +**Decided:** harmless-spread semantics (spreads after the key are provable only when their operands are literal-keyed object literals or conditionals of them, none naming the key) instead of editing `evidence.ts`; `export const POST = factory()` is a proof failure, and the route becomes an exported `POST` function with a no-argument dependency accessor and `vi.mock`-based tests; five verb rules; fixtures move to `fixtures/kinds/`; `findUnique` dropped; rule-only glob aliases so `scope` stays glob-only; unsafe receiver judged on the raw receiver before unwrapping, optional receivers unprovable; FR-DOG rows added; TM-V03 discharge stated; `escape:closure` literal. +**Reasoning:** PRP principle 3 (identity stable), FR-ING-006 as written, SC-V05, SC-V06 (forced edits must be named), and the fail-closed principle in PRP section 2. + +### Verdict 2: DESIGN_APPROVAL on the design spec (revision 2) +**Verdict:** REJECT, five required fixes: ingress reader chain mismatch (`dependencies.resolveSubmission` not in any list); `db["policy"]` fixture expectation false (string-literal element access resolves); the layer-reference union could not produce its own message; `profiles` layer dropped without amending FR-LAY-005; harmless-spread recursion ambiguity. + +### Decision 4: spec revision 3 (orchestrator, advisor role) +**Decided:** `readerCalls` names `dependencies.resolveSubmission`; route shape written out with its untrusted set and the `vi.mock` test plan; `db[key]` fixture added with the unresolved outcome and `db["policy"]` corrected to `CALL_ARGUMENT_MISSING`; the reference union is dropped and one policy `superRefine` emits three distinct messages (not a slug, not allowed in scope, not declared); harmless-spread judged on each branch literal's own top-level members only (getters, setters, methods count as members); PRP FR-LAY-005 amended to six layers with the reason recorded in the PRP text. +**Reasoning:** each fix is a correctness defect the verifier cited with file:line evidence; the FR-LAY-005 amendment removes an unexercised layer under PRP section 2 item 4 and adds no scope. + +### Verdict 3: DESIGN_APPROVAL on the design spec (revision 3) +**Verdict:** REJECT, four fixes: FR-ING-006 reader/allowed values diverged from the PRP without amendment; FR-ING-005 "reassignment is a proof failure" contradicted the route trace; "carries untrusted" did not cover reader-call results; removing the route's dependency-injection seam needs an owner checkpoint before the edit. Fixes 1 to 5 of revision 2 confirmed resolved; FR-LAY, FR-ARG, FR-STA judged clean. + +### Decision 5: spec revision 4 (orchestrator, advisor role) +**Decided:** PRP FR-ING-005 and FR-ING-006 amended in the PRP text with reasons; reader-call results carry untrusted by definition; local reassignment widens `U` monotonically; an owner checkpoint is inserted before the route edit in PRP section 12, spec section 9, and plan task 4.4, with the run stopping after step 3 if confirmation is absent. +**Reasoning:** the amendments remove a contradiction between the rule and the only route it governs; the checkpoint is the CLAUDE.md refactoring rule and the skill's "delete existing functionality" guardrail. + +### Verdict 4: DESIGN_APPROVAL on the design spec (revision 4) +**Verdict:** PASS. Risks accepted: four vacuous verb rules on this tree; FR-ING-002 wording (cleaned up afterwards, no behaviour change); escape detection enumerated rather than closed (plan task 4.2 now adds the defensive default); `allowedCalls` results trusted unconditionally (to be recorded in the FR-ING ADR). + +### Verdict 5: DESIGN_APPROVAL on the implementation plan (revision 1) +**Verdict:** REJECT, three fixes: the baseline digest was quoted from an earlier run, not measured on the branch; task 5.2 edited `CLAUDE.md` and the skill outside the spec with a grep as acceptance; the two spec-mandated ADR notes for `require-call-argument` had no task. + +### Decision 6: plan revision 2 (orchestrator) +**Decided:** re-measure the baseline with a fresh `npm run verify` on the branch and paste the values; drop the `CLAUDE.md` edit and add the skill doc to spec §8 and FR-DOG-003 as a deliverable with the step gate as acceptance; make the ADR notes named deliverables of task 2.5. +**Reasoning:** law 2 (prove, don't claim); PRP section 3.2 (no files outside the spec unless forced or listed); the skill listing kinds is documentation of the shipped schema, which FR-DOG-003 already covers in spirit. + +### Verdict 6: DESIGN_APPROVAL on the implementation plan (revision 2) +**Verdict:** PASS. Verifier independently re-ran unit, integration, and architecture checks and matched the digests on disk. diff --git a/docs/plans/2026-09-09-validator-rule-language-plan.md b/docs/plans/2026-09-09-validator-rule-language-plan.md new file mode 100644 index 0000000..4e25b97 --- /dev/null +++ b/docs/plans/2026-09-09-validator-rule-language-plan.md @@ -0,0 +1,73 @@ +# Implementation plan — validator rule language + +Spec: `docs/plans/2026-09-09-validator-rule-language-spec.md` (revision 4). PRP: `docs/prp/2026-09-09-validator-rule-language.md`. Branch `feat/validator-rule-language`. Node 22 for every command (`export PATH="/c/Users/Guerr/AppData/Roaming/fnm/node-versions/v22.22.3/installation:$PATH"`). + +Every task lists its files and the command whose exit 0 is its acceptance. Tasks inside one step may run in parallel where they touch disjoint files; steps run in order. Each step ends with `npm run verify`, a `kz-checker` verdict, and one commit in plain developer language. + +Baseline on the branch before task 1, measured 2026-09-09 with `npm run verify` on `feat/validator-rule-language` (Node 22): exit 0; unit `45 passed (45)` files, `264 passed (264)` tests; architecture `1 passed`; integration `3 passed (3)` files, `7 passed (7)` tests; self-policy `kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 91 files)`; self-policy `integrity.digest` `sha256:435c3f708ebb44571c674ad6ab6f3f8abc1a32bd10d8d27846b3f399e9ed3bb8`; `policy.digest` `sha256:006a0a332433797dac544fd3b42c6f11d87f8c28e625611aed17c2d39419dec2`; golden `filesScanned` 12. + +## Step 1 — named layers (FR-LAY, ADR `docs/adr/2026-09-09-policy-layers.md`) + +| # | Task | Files | Acceptance | +| --- | --- | --- | --- | +| 1.1 | Schema: `LayerNameSchema`, `RuleGlobList`/`OptionalRuleGlobList`, `layers`, the three-message `superRefine` (spec §1) | `packages/profile-software-architecture/src/policy.ts`, `policy.test.ts` | `npx vitest run packages/profile-software-architecture/src/policy.test.ts` with new cases: layers accepted, malformed reference message, scope reference message, undeclared message, 51 layers rejected | +| 1.2 | `resolvePolicyLayers`, `isLayerReference`, barrel export | `packages/profile-software-architecture/src/layers.ts` (new), `layers.test.ts` (new, incl. fast-check order property), `src/index.ts` | `npx vitest run packages/profile-software-architecture/src/layers.test.ts` | +| 1.3 | Expand in `findingCompatibilityReason`; engine guard; runner expansion after digest | `compatibility.ts`, `compatibility.test.ts`, `packages/validator/src/engine.ts`, `engine.test.ts` (guard case), `packages/validator/src/runner.ts`, `runner.test.ts` (layered policy resolves) | `npm run test:validator` and `npx vitest run packages/profile-software-architecture` | +| 1.4 | Self-policy rewrite to layers (spec §1 table) | `kernel-zero.policy.json` | `npm run validator:self` twice, identical digest; the ADR records the old integrity digest (the baseline value quoted at the top of this plan, measured fresh) and the new one | +| 1.5 | Contracts and docs for layers | `scripts/generate-contracts.ts` (README paragraph), regenerated `docs/contracts/**`, `packages/validator/README.md`, `docs/validator-and-hooks.md` | `npm run contracts:generate && npm run contracts:check` | +| 1.6 | Golden pin | `packages/validator/src/golden.test.ts` (assert `filesScanned === 12` and the pinned `manifestDigest`) | `npx vitest run packages/validator/src/golden.test.ts` | +| gate | | | `npm run verify` exit 0; `git diff --stat -- packages/validator/fixtures/golden` empty; kz-checker PASS; commit | + +## Step 2 — argument prover and `require-call-argument` (FR-ARG, ADR `docs/adr/2026-09-09-require-call-argument.md`) + +| # | Task | Files | Acceptance | +| --- | --- | --- | --- | +| 2.1 | Prover: `proveObjectPath`, `resolveCallChain` (raw-receiver unsafety), `chainMatches`, `couldMatchCallee` (spec §2) | `packages/validator/src/checks/argument-shape.ts` (new), `argument-shape.test.ts` (new: every `PathProof` branch, harmless vs opaque spreads, nested-branch literal, unsafe and optional receivers, both-ends heuristic) | `npx vitest run packages/validator/src/checks/argument-shape.test.ts` | +| 2.2 | Schema member, `CalleeGlobSchema`, `DottedPathSchema`; message codes and strings; compatibility grammar | `policy.ts`, `policy.test.ts`, `evidence.ts`, `evidence.test.ts`, `compatibility.ts`, `compatibility.test.ts` (profile package) | `npx vitest run packages/profile-software-architecture` | +| 2.3 | Evaluator, raw codes, engine dispatch and `ruleClaimsPath`, public mapping | `checks/call-argument.ts` (new), `findings.ts`, `engine.ts`, `runner.ts` | `npx tsc --noEmit -p tsconfig.json` | +| 2.4 | Fixtures and the finding matrix (spec §3 list, 12 cases) | `packages/validator/fixtures/kinds/call-argument/queries.ts` (new), `checks/call-argument.test.ts` (new) | `npx vitest run packages/validator/src/checks/call-argument.test.ts` | +| 2.5 | Self-policy rule `tenant-queries-carry-workspace`; bite proof (delete one `workspaceId` in `exceptions.ts`, exit 1, restore). ADR `docs/adr/2026-09-09-require-call-argument.md` must contain, as named sections: the bite proof output; the schema-default digest note (spec §3: `argument`/`allowFrom` defaults move the digest of a policy that omits them, no stored policy affected, CLAUDE.md rule 5); the `findUnique` exclusion rationale (spec §7: compound-unique selectors carry the tenant key inside the unique-key object and are scoped by the composite index) | `kernel-zero.policy.json`, `docs/adr/2026-09-09-require-call-argument.md` | `npm run validator:self` exit 0 twice; bite exit 1 recorded in the ADR; `grep -c` is not acceptance, the kz-checker reads the ADR sections at the step gate | +| 2.6 | Benchmark rule, contracts, README paragraph, docs examples | `scripts/benchmark-validator.ts`, `scripts/generate-contracts.ts`, `docs/contracts/**`, `packages/validator/README.md`, `docs/validator-and-hooks.md` | `npm run benchmark:validator` exit 0 with numbers quoted; `npm run contracts:check` | +| gate | | | `npm run verify`; golden diff empty; kz-checker PASS; commit | + +## Step 3 — `restrict-state-transition` (FR-STA, ADR `docs/adr/2026-09-09-restrict-state-transition.md`) + +| # | Task | Files | Acceptance | +| --- | --- | --- | --- | +| 3.1 | Schema member with `transitions`; codes, strings, compatibility (two subject forms) | profile `policy.ts`, `policy.test.ts`, `evidence.ts`, `compatibility.ts`, `compatibility.test.ts` | `npx vitest run packages/profile-software-architecture` | +| 3.2 | Evaluator (spec §4 steps 1–6), raw codes, engine wiring (`ruleClaimsPath` true), public mapping | `checks/state-transition.ts` (new), `findings.ts`, `engine.ts`, `runner.ts` | `npx tsc --noEmit -p tsconfig.json` | +| 3.3 | Fixtures and matrix (spec §4: allowed writer 6 cases, rogue writer 1 case) | `fixtures/kinds/state-transition/allowed/writer.ts`, `elsewhere/rogue.ts`, `checks/state-transition.test.ts` | `npx vitest run packages/validator/src/checks/state-transition.test.ts` | +| 3.4 | Self-policy rule `policy-revision-state-is-governed`; two bite proofs (rogue write in `evidence.ts`; `draft->active` literal in `policies.ts`), both restored | `kernel-zero.policy.json` | `npm run validator:self` exit 0 twice; both bites exit 1 in the ADR | +| 3.5 | Benchmark rule, contracts, docs | as in 2.6 | `npm run benchmark:validator`; `npm run contracts:check` | +| gate | | | `npm run verify`; golden diff empty; kz-checker PASS; commit | + +## Step 4 — `require-ingress-parse` (FR-ING, ADR `docs/adr/2026-09-09-require-ingress-parse.md`) + +| # | Task | Files | Acceptance | +| --- | --- | --- | --- | +| 4.1 | Schema member; codes, strings, compatibility (three subject forms, `return`/`closure` literals) | profile `policy.ts`, `policy.test.ts`, `evidence.ts`, `compatibility.ts`, `compatibility.test.ts` | `npx vitest run packages/profile-software-architecture` | +| 4.2 | Evaluator (spec §5: export resolution incl. factory-bound const as proof failure, untrusted set, carrying, sinks, closures, loops), raw codes, engine wiring, public mapping. Defensive default: an untrusted value reaching any sink shape not enumerated in spec §5 (`new`, tagged template, `throw`, property assignment on an outer object, `yield`) is `INGRESS_UNRESOLVED`. The ADR records that `allowedCalls` results are trusted unconditionally (laundering hole, intentional) beside the intra-procedural `// ponytail:` ceiling | `checks/ingress.ts` (new), `findings.ts`, `engine.ts`, `runner.ts` | `npx tsc --noEmit -p tsconfig.json` | +| 4.3 | Fixtures and matrix (spec §5: POST pass, GET return, PUT log, PATCH loop, DELETE missing, HEAD factory, ignored non-export) | `fixtures/kinds/ingress/handlers.ts`, `checks/ingress.test.ts` | `npx vitest run packages/validator/src/checks/ingress.test.ts` | +| 4.4 | **Owner checkpoint first** (guardrail: removes the `createEvidencePostHandler` injection seam; the run pauses with AUTONOMOUS MODE PAUSED and proceeds only on confirmation). Then the forced route edit (spec §7 sketch): exported `POST`, `evidenceDependencies()` accessor, delete `createEvidencePostHandler`; tests via `vi.mock` for 401, 201, 200, 415 (the 415 case sends `x-correlation-id` so the exact envelope assertion holds) | `apps/control/src/app/api/evidence/v1/runs/route.ts`, `route.test.ts` | `npx vitest run apps/control/src/app/api/evidence/v1/runs/route.test.ts`; `npm run validator:self` still passes `transport-does-not-import-repositories` | +| 4.5 | Five self-policy rules `route-handlers-parse-their-input-{get,post,put,patch,delete}`; bite proof (replace `readEvidenceRequest` with `request.json()` in the route, exit 1, restore) | `kernel-zero.policy.json` | `npm run validator:self` exit 0 twice; bite exit 1 in the ADR | +| 4.6 | Benchmark rule, contracts, docs | as in 2.6 | `npm run benchmark:validator`; `npm run contracts:check` | +| gate | | | `npm run verify`; browser suite unaffected (route unchanged in behaviour; `npm run test:browser` run once here); kz-checker PASS; commit | + +## Step 5 — dogfood closure and documentation (FR-DOG) + +| # | Task | Files | Acceptance | +| --- | --- | --- | --- | +| 5.1 | Traceability groups FR-LAY, FR-ARG, FR-STA, FR-ING, FR-DOG; regenerate | `scripts/generate-traceability.ts`, `docs/TRACEABILITY.md` | `npm run traceability:generate` | +| 5.2 | Skill doc: `kz-policy-rule` existing-kinds list gains the three kinds and a `layers` note (added to spec §8 and PRP FR-DOG-003 as a deliverable; no `CLAUDE.md` edit) | `.claude/skills/kz-policy-rule/SKILL.md` | step gate: `npm run verify` and kz-checker review of the diff against spec §8 | +| 5.3 | Final determinism and benchmark quotes into the run-state | `docs/plans/2026-09-09-validator-rule-language-run-state.md` | `npm run validator:self` twice identical; `npm run benchmark:validator` | +| gate | | | `npm run verify` exit 0 with counts quoted; `git diff --stat -- packages/validator/fixtures/golden` empty; `git diff --stat -- packages/domain packages/contracts packages/persistence` empty; `git diff --stat -- apps/control` shows only `route.ts` and `route.test.ts`; kz-checker PASS; commit | + +## Step 6 — pull request (SC-V08) + +`git push -u origin feat/validator-rule-language`, then `gh pr create` against `main` with a body listing the four ADRs, the digest changes, the benchmark numbers, and the two owner checkpoints (route DI seam removal, validator version bump deferred). Record the PR URL in the run-state. No merge. + +## Task execution rules + +- Maker and checker differ: each step's implementation is done by a dispatched implementer agent (or inline) and verified by `kz-checker`; the orchestrator never marks a step green from its own summary. +- Any red gate goes into the run-state Failed Attempts table with a do-not-retry note before a different approach is tried. +- No new dependency; no edits outside the files named here except those the self-policy forces, which are then named in the step's ADR. diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md new file mode 100644 index 0000000..869e2f9 --- /dev/null +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -0,0 +1,26 @@ +# Autonomous Run State — KERNEL ZERO validator rule language + +## Status +- **PRP:** docs/prp/2026-09-09-validator-rule-language.md +- **Session:** autonomous-validator-rule-language-2026-09-09 +- **Branch:** feat/validator-rule-language (off main at 618b4cc) +- **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope +- **Current phase:** 3-implement +- **In-flight:** step 1 (named layers), implementer dispatched; tasks 1.1-1.6 +- **Next action:** Phase 3 step 1 — implementer returns, run kz-checker, commit; then step 2 + +## Phase Gates +| Phase | Gate | Result | Evidence (command + exit / artifact path) | +|-------|------|--------|-------------------------------------------| +| 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | +| 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | +| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | pending | | +| 4-branch | PR URL recorded | pending | | +| 5-optimize | optimization loop termination | pending | | + +## Failed Attempts +| # | Phase/Task | What was tried | Why it failed | Do-not-retry note | +|---|-----------|----------------|---------------|-------------------| + +## Decisions +Full log: docs/plans/2026-09-09-validator-rule-language-advisor-log.md diff --git a/docs/plans/2026-09-09-validator-rule-language-spec.md b/docs/plans/2026-09-09-validator-rule-language-spec.md new file mode 100644 index 0000000..fdb8c3b --- /dev/null +++ b/docs/plans/2026-09-09-validator-rule-language-spec.md @@ -0,0 +1,277 @@ +# Design spec — validator rule language (named layers and three check kinds) + +PRP: `docs/prp/2026-09-09-validator-rule-language.md`. Revision 4 (after verifier +rejections 1, 2, and 3). This spec fixes the shapes, algorithms, subject grammars, and file +layout the implementation must follow. Where the PRP leaves a choice, the choice +is made here and the reason stated. + +## 1. Named layers (FR-LAY) + +### Schema (`packages/profile-software-architecture/src/policy.ts`) + +- `LayerNameSchema = SlugSchema(2, 40)` (the same grammar `metadata.name` uses). +- `RuleGlobList = uniqueArray(RelativeGlobSchema, 1, 100)` and `OptionalRuleGlobList = uniqueArray(RelativeGlobSchema, 0, 100)`: new aliases, same element schema as today (`RelativeGlobSchema` already accepts the string `layer:x` syntactically). They exist only so rule fields and `scope` can be told apart; there is no union and no per-element reference schema. +- Every check field that names files (`from`, `files`, `allowFrom`, `declarationFiles`) switches to the rule-only aliases. **`scope.include` and `scope.exclude` keep the glob-only `GlobList`/`OptionalGlobList`**, and the `superRefine` below rejects any `layer:` entry found there, so discovery never sees one. +- `RepositoryPolicySchema` gains `layers: z.record(LayerNameSchema, uniqueArray(RelativeGlobSchema, 1, 100)).optional()` refined to at most 50 keys. Layer values are globs only (nesting deferred, PRP section 9). +- One policy-level `superRefine` is the whole enforcement. It walks `scope.include`, `scope.exclude`, and every rule's four file-naming fields; for each entry starting with `layer:` it adds exactly one issue: `"Layer reference is not a slug: "` when the remainder fails `LayerNameSchema`; `"Layer reference is not allowed in scope: "` for scope fields; `"Layer is not declared: "` (with the rule id and field) when the name is absent from `layers`. Absent `layers` plus any rule reference is therefore a schema error (validator exit 2). TM-V01 asserts each of the three messages. + +### Expansion (`packages/profile-software-architecture/src/layers.ts`, new) + +```ts +export const LAYER_REFERENCE_PREFIX = "layer:"; +export function isLayerReference(value: string): boolean; +/** Equal policy whose rule file lists contain only globs. Idempotent. `layers` and `scope` untouched. */ +export function resolvePolicyLayers(policy: RepositoryPolicy): RepositoryPolicy; +``` + +One list expands by replacing each reference with the layer's globs in declaration order, then de-duplicating while preserving first occurrence. Output is frozen. Field discovery is by name on the check object (`from`, `files`, `allowFrom`, `declarationFiles`), so a future kind that reuses those names inherits layers. + +### Where expansion happens + +- `packages/validator/src/runner.ts`: digest over the parsed document as today (`canonicalSha256(policy)` at the current line), then `evaluatePolicyChecks(resolvePolicyLayers(policy), repository)`. `explain`/`renderPolicy` keep the unexpanded document. +- `packages/profile-software-architecture/src/compatibility.ts`: `findingCompatibilityReason` calls `resolvePolicyLayers` on entry. `diffRules` does not expand (a layer edit diffs as a rule change). +- `packages/validator/src/engine.ts`: `evaluatePolicyChecks` throws `RepositoryProgramError("Policy layers must be resolved before evaluation.")` if any rule file list still holds a reference. Programming-error guard, not a finding. + +### Digest, fingerprints, golden + +The digest covers the parsed document, references included, so a policy without `layers` is byte-identical to today. Subjects never contain layer names. The golden fixture has no layers. + +### Self-policy rewrite + +`kernel-zero.policy.json` declares exactly the layers at least one rule references. FR-LAY-005 originally listed a seventh, `profiles`; no rule names profile files (the profile rules are module denials), so PRP FR-LAY-005 is amended to six layers on the same principle as PRP section 2 item 4 (nothing ships unexercised). The amendment is recorded in the PRP text and the advisor log. + +``` +ui: apps/control/src/app/**/page.tsx, apps/control/src/app/**/layout.tsx +transport: apps/control/src/app/api/**/*.ts +service: apps/control/src/server/**/*.ts +persistence: packages/persistence/src/**/*.ts +kernel: packages/domain/**/*.ts, packages/contracts/**/*.ts, packages/persistence/**/*.ts +validator: packages/validator/**/*.ts +``` + +`ui-does-not-import-persistence` → `from: ["layer:ui"]`; `transport-does-not-import-repositories` → `from: ["layer:transport"]`; `server-modules-declare-boundary` → `files: ["layer:service", "layer:persistence"]`; `kernel-does-not-import-profiles` → `from: ["layer:kernel"]`; `validator-stays-network-and-database-free` → `from: ["layer:validator"]`; the three new rules use `layer:persistence` and `layer:transport`. `raw-database-client-is-contained` keeps its explicit globs (its `from` spans apps and three packages with no matching layer). The digest changes; the ADR records old and new. + +## 2. Shared argument-shape prover (`packages/validator/src/checks/argument-shape.ts`, new) + +Never emits findings; mirrors `static-registry.ts`. + +```ts +export type PathProof = + | { readonly kind: "present"; readonly value: ts.Expression; readonly literal: string | undefined } + | { readonly kind: "missing" } + | { readonly kind: "unprovable"; readonly reason: "not-literal" | "spread" | "computed" | "cycle" }; + +export function proveObjectPath(argument: ts.Expression | undefined, path: readonly string[], checker: ts.TypeChecker, sourceFile: ts.SourceFile): PathProof; + +export type CallChain = { readonly chain: string; readonly unsafeReceiver: boolean }; +/** The textual callee chain restrict-call-site matches, plus whether the innermost receiver expression has an unsafe type. */ +export function resolveCallChain(call: ts.CallExpression, checker: ts.TypeChecker): CallChain | undefined; + +export function chainMatches(chain: string, globs: readonly string[]): boolean; // matchesGlob over the chain; `*` spans dots because the chain has no `/` + +/** Unresolved-callee heuristic shared by both call kinds; see below. */ +export function couldMatchCallee(call: ts.CallExpression, globs: readonly string[]): string | undefined; +``` + +`proveObjectPath` rules: + +1. `argument === undefined` → `missing`. +2. Unwrap parentheses, `as`, `satisfies`, non-null (`unwrapExpression`). `Object.freeze()` unwraps to `` (new behaviour in this module; `asObjectLiteral` today handles only a direct literal argument). +3. Identifier: resolve the symbol; if its single declaration is a `const` `VariableDeclaration` in the same source file with an initializer, recurse with a `seen` set (cycle → `unprovable:cycle`). Any other binding → `unprovable:not-literal`. +4. Object literal, looking for `path[0]`: + - A computed key that is not a string literal → `unprovable:computed`. + - Find the property named `path[0]` (`propertyName`); shorthand counts. + - If the key is present: every spread **after** it at this level must be harmless, else `unprovable:spread`. A spread is harmless when its operand (unwrapped) is an object literal, or a conditional whose branches are both object literals, and each such literal's **own top-level property list** has no spread, no computed key, and no member named `path[0]`. Property assignments, shorthand properties, methods, getters, and setters all count as members with their names. Nested values are not inspected: a spread of `{ generatedAt: { ...a, ...b } }` adds only the key `generatedAt` at this level, so it is harmless for any other key. This is what makes `{ workspaceId, ...(cond ? {} : { status }) }` and `evidence.ts`'s `where` literals provable. Spreads **before** the key are always harmless (the later literal key wins). + - If the key is absent and any spread is present → `unprovable:spread`; absent with no spread → `missing`. + - With segments remaining, recurse into the initializer; a non-object initializer with segments left → `unprovable:not-literal`. +5. Leaf: the identifier `undefined` or `void 0` → `missing`; otherwise `present`, with `literal` set for string literals and no-substitution templates. +6. A conditional at any level in the key's own position → `unprovable:not-literal` (ponytail: branch-wise proof is the upgrade). + +`resolveCallChain`: `chain = resolveCalleeName(call.expression, checker, new Set())`; `undefined` stays `undefined`. `unsafeReceiver` is computed on the **raw** callee expression: descend `PropertyAccessExpression.expression` (and `ElementAccessExpression.expression`) without unwrapping until the innermost expression, then `containsUnsafeType(checker.getTypeAtLocation(innermost))`. `(db as any).policy.findMany` therefore reports an unsafe receiver even though the chain resolves to `db.policy.findMany`. An optional receiver (`db?.policy`) has `undefined` in its type and is also unsafe; that is intended (ponytail: an optional receiver cannot be proven to reach the callee). + +`couldMatchCallee` (unresolved heuristic): only when the callee expression is a `PropertyAccessExpression`; take `root = expressionRootName(...)` and `last = the accessed name`; return the first glob whose last segment equals `last` (or is `*`) and whose first segment is `*` or equals `root`. Requires both ends to agree, unlike `calls.ts`' root-only test, so `*.findMany` does not fire on every unresolvable call. + +## 3. `require-call-argument` (FR-ARG) + +### Schema + +```ts +z.strictObject({ + kind: z.literal("require-call-argument"), + files: RuleGlobList, + callee: uniqueArray(CalleeGlobSchema, 1, 100), + argument: z.number().int().min(0).max(9).default(0), + requiredPath: DottedPathSchema, + allowFrom: OptionalRuleGlobList.default([]), +}) +``` + +`CalleeGlobSchema`: `z.string().min(1).max(200).regex(/^[A-Za-z_$*][\w$*]*(?:\.[A-Za-z_$*][\w$*]*)*$/u)`. `DottedPathSchema`: 1..8 identifier segments joined by `.`. + +Defaults note (CLAUDE.md rule 5): `argument` and `allowFrom` defaults move the digest of a policy that omits them. No stored policy can contain this kind yet, so nothing existing moves; the ADR says so. + +### Evaluator (`checks/call-argument.ts`) + +For each file in `files` minus `allowFrom` (skipping `failedPaths`), for every `CallExpression`: + +1. `resolved = resolveCallChain(node)`. If `undefined`: when `rule.level === "error"` and `glob = couldMatchCallee(node, callee)` is defined, push raw `CALL_ARGUMENT_UNRESOLVED` with subject `call::argument::`; continue. +2. If `!chainMatches(resolved.chain, callee)` continue. +3. If `resolved.unsafeReceiver` → raw `CALL_ARGUMENT_UNPROVABLE`. +4. `proof = proveObjectPath(node.arguments[argument], path)`: `present` → nothing; `missing` → raw `CALL_ARGUMENT_MISSING`; `unprovable` → raw `CALL_ARGUMENT_UNPROVABLE`. + +Subject `call::argument::`; location the call expression. Public mapping in `runner.ts`: `CALL_ARGUMENT_MISSING → CALL_ARGUMENT_MISSING`; `CALL_ARGUMENT_UNPROVABLE | CALL_ARGUMENT_UNRESOLVED → CALL_ARGUMENT_PROOF_FAILED`. (`CALL_RESOLUTION_FAILED` stays bound to `restrict-call-site`.) + +### Compatibility + +Codes `["CALL_ARGUMENT_MISSING", "CALL_ARGUMENT_PROOF_FAILED"]`. Subject grammar: `call:` + a chain matching one of `callee` via `chainMatches` (a glob itself is accepted, since the unresolved case reports the glob) + `:argument:` + the configured index + `:` + `requiredPath`. + +### Messages + +- `CALL_ARGUMENT_MISSING`: "A call to a governed operation omits a required argument field." +- `CALL_ARGUMENT_PROOF_FAILED`: "A call to a governed operation could not be proven to carry a required argument field." + +### Fixtures (`packages/validator/fixtures/kinds/call-argument/`) + +`queries.ts`: `declare const db: { policy: { findMany(a: unknown): void; updateMany(a: unknown): void }; other: { findMany(a: unknown): void } }`, `declare const workspaceId: string`, `declare const flag: boolean`, `declare function buildSelector(): unknown`. Cases: literal `where.workspaceId` (pass); `where` without it (missing); `const selector = { where: { workspaceId } }` then `db.policy.findMany(selector)` (pass); `{ where: { workspaceId, ...(flag ? {} : { status: "x" }) } }` (pass, harmless spread after); `{ where: { ...base, workspaceId } }` with `declare const base: object` (pass, spread before); `{ where: { workspaceId, ...base } }` (proof failed, opaque spread after); `{ where: { ...base } }` (proof failed, absent with spread); `{ where: { workspaceId: undefined } }` (missing); `db.policy.findMany(buildSelector())` (proof failed, not literal); `(db as any).policy.findMany({ where: { workspaceId } })` (proof failed, unsafe receiver); `db.other.findMany({})` under callee `db.policy.*` (no finding); `db["policy"].findMany({})` (`propertyChainName` resolves string-literal element access, so the chain is `db.policy.findMany` and `{}` yields `CALL_ARGUMENT_MISSING`); `db[key].findMany({})` with `declare const key: string` (chain unresolvable; `couldMatchCallee` sees root `db` and last `findMany`, glob `db.policy.*` has first segment `db` and last `*`, so raw `CALL_ARGUMENT_UNRESOLVED` → `CALL_ARGUMENT_PROOF_FAILED` with subject `call:db.policy.*:argument:0:where.workspaceId`). + +## 4. `restrict-state-transition` (FR-STA) + +### Schema + +```ts +z.strictObject({ + kind: z.literal("restrict-state-transition"), + callee: uniqueArray(CalleeGlobSchema, 1, 100), + argument: z.number().int().min(0).max(9).default(0), + field: DottedPathSchema, // e.g. data.state + allowFrom: OptionalRuleGlobList.default([]), + transitions: uniqueArray(z.strictObject({ from: z.union([IdentifierSchema, z.literal("*")]), to: IdentifierSchema }), 0, 100).default([]), +}) +``` + +Predicate path: `field` with its first segment replaced by `where`. `ruleClaimsPath` returns `true` for every file. + +### Evaluator (`checks/state-transition.ts`) + +For every call in every file (skipping `failedPaths`): + +1. `resolved = resolveCallChain(node)`; if undefined and `couldMatchCallee` names a glob at `error` level → raw `STATE_TRANSITION_UNPROVABLE`, subject `transition::`; continue. If the chain does not match, continue. If `unsafeReceiver` → `STATE_TRANSITION_UNPROVABLE`. +2. `write = proveObjectPath(arg, field)`: `missing` → not a write of this field, continue; `unprovable` → raw `STATE_TRANSITION_UNPROVABLE`, subject `transition::`. +3. File not in `allowFrom` → raw `STATE_TRANSITION_DENIED_WRITER`, subject `transition::`; continue. +4. `transitions` empty → done. +5. `to = write.literal`; `from = proveObjectPath(arg, predicatePath)`. If `to === undefined`, or `from.kind !== "present"`, or `from.literal === undefined` → `STATE_TRANSITION_UNPROVABLE`, chain subject. +6. No transition with `(from === "*" || from === fromLiteral) && to === toLiteral` → raw `STATE_TRANSITION_DENIED_PAIR`, subject `transition::->`. + +Public mapping: `STATE_TRANSITION_DENIED_WRITER | STATE_TRANSITION_DENIED_PAIR → STATE_TRANSITION_DENIED`; `STATE_TRANSITION_UNPROVABLE → STATE_TRANSITION_PROOF_FAILED`. + +Compatibility: `STATE_TRANSITION_DENIED` subject is `transition::` or `transition::->` (identifiers or `*`); `STATE_TRANSITION_PROOF_FAILED` accepts only the chain form. `` is the last segment of `field`. The two forms separate because a chain segment cannot contain `-`. + +Messages: `STATE_TRANSITION_DENIED`: "A governed state field is written outside its allowed writer or through an unlisted transition." `STATE_TRANSITION_PROOF_FAILED`: "A write to a governed state field could not be proven against the allowed transitions." + +Fixtures (`packages/validator/fixtures/kinds/state-transition/`): `allowed/writer.ts` with `draft→approved` (pass), `approved→active` (pass), `draft→active` (denied pair), `where` without `state` (proof failed), `data: { ...patch }` (proof failed), `data: { canonicalJson: "{}" }` (no finding); `elsewhere/rogue.ts` with a `data.state` write (denied writer). + +## 5. `require-ingress-parse` (FR-ING) + +### Schema + +```ts +z.strictObject({ + kind: z.literal("require-ingress-parse"), + files: RuleGlobList, + symbols: NonemptyExactStringSchema, // glob over exported function names; the glob grammar has no `{a,b}` alternation, so the self-policy declares one rule per HTTP verb + parserCalls: ExactList, + readerCalls: ExactList.default([]), + allowedCalls: ExactList.default([]), +}) +``` + +### Evaluator (`checks/ingress.ts`) + +Exported symbols matching `symbols` are found through `checker.getExportsOfModule`. For each match: + +- If the export is not a function declaration with a body or an exported `const` whose initializer is an arrow or function expression (`collectExportedFunctions` semantics), it is an ingress the rule cannot see inside: raw `INGRESS_UNRESOLVED`, subject `symbol::proof`, location the export. `export const POST = factory(...)` is therefore a proof failure, never a silent skip. + +For a provable function: + +1. Untrusted set `U`: every parameter binding. Single forward pass over the body in source order: + - `const`/`let` declarations, and assignments to locals, whose right side *carries* untrusted (below) → add the binding to `U`. Membership is monotone: a later assignment of a trusted value never removes a binding from `U` (PRP FR-ING-005 as amended). + - One-level destructuring of a `U` value → add the bindings. +2. An expression **carries** untrusted when it is a `U` symbol; a member or element access on a carrying expression; a call whose resolved chain is in `readerCalls` (the reader result is untrusted by definition); an `await`, parenthesised, `as`, `satisfies`, or non-null wrapper of a carrying expression; or an object or array literal any of whose values carries untrusted (recursive). Results of `parserCalls` and `allowedCalls` calls are trusted. +3. For every `CallExpression` in the body with at least one argument that carries untrusted: `chain = resolveCallChain(...)?.chain`. `parserCalls` → `parsed = true`; `readerCalls` or `allowedCalls` → fine; `undefined` → raw `INGRESS_UNRESOLVED` (`symbol::proof`); otherwise raw `INGRESS_ESCAPE`, subject `symbol::escape:`. +4. A `return` whose expression carries untrusted → `INGRESS_ESCAPE`, `escape:return`. Assignment of a carrying value to a binding declared outside the function → `INGRESS_ESCAPE`, `escape:`. A carrying value captured by a nested function or arrow (any reference to a `U` symbol inside it) → `INGRESS_ESCAPE`, `escape:closure`, unless the nested function is itself an argument to an `allowedCalls` call. +5. `for`/`while`/`do` statements, and destructuring deeper than one level of a `U` value → `INGRESS_UNRESOLVED`; evaluation of that function stops at the first proof failure. Reassignment of a local is not a proof failure; it widens `U` per step 1. +6. After the pass, if `parsed` is false and nothing was recorded → raw `INGRESS_PARSE_MISSING`, subject `symbol::parser`, location the function name. + +`catch (error)` bindings are not parameters and are trusted. `try`/`catch`/`if` are walked normally. + +Public mapping: `INGRESS_PARSE_MISSING → INGRESS_PARSE_MISSING`; `INGRESS_ESCAPE → INGRESS_ESCAPE`; `INGRESS_UNRESOLVED → INGRESS_PROOF_FAILED`. + +Compatibility: subjects `symbol::parser`, `symbol::escape:`, `symbol::proof`, where the qualified name (or its last segment) matches `symbols` and `` is an identifier chain, the literal `return`, or the literal `closure`. + +Messages: `INGRESS_PARSE_MISSING`: "An ingress function never passes its input through a required parser." `INGRESS_ESCAPE`: "Unparsed ingress input reaches a call, return, or binding outside the allowed set." `INGRESS_PROOF_FAILED`: "Ingress input flow could not be proven within the function." + +Fixtures (`packages/validator/fixtures/kinds/ingress/`): `handlers.ts` exporting `POST` (reader → parser → allowed submit with an object literal carrying context, pass), `GET` (returns `request`, escape:return), `PUT` (`log(request.headers)`, escape:log), `PATCH` (`for` over `request`, proof), `DELETE` (never parses, parser missing), `HEAD` (`export const HEAD = factory()`, proof), plus a non-exported function that would fail and is ignored. + +## 6. Engine wiring + +- `engine.ts`: three `case`s in `evaluateRule`; `ruleClaimsPath`: `require-call-argument` → `files`; `restrict-state-transition` → `true`; `require-ingress-parse` → `files`. +- `findings.ts` `RawFindingMessageCode` gains `CALL_ARGUMENT_MISSING`, `CALL_ARGUMENT_UNPROVABLE`, `CALL_ARGUMENT_UNRESOLVED`, `STATE_TRANSITION_DENIED_WRITER`, `STATE_TRANSITION_DENIED_PAIR`, `STATE_TRANSITION_UNPROVABLE`, `INGRESS_PARSE_MISSING`, `INGRESS_ESCAPE`, `INGRESS_UNRESOLVED`. +- `runner.ts` `publicMessageCode` gains the mappings above; the exhaustive switch enforces completeness. +- Profile `evidence.ts` message table and `FindingMessageCode` gain the seven public codes; `createEvidenceSchema` closes to them automatically. + +## 7. Self-policy additions and the edits they force + +``` +tenant-queries-carry-workspace require-call-argument files [layer:persistence], callee [*.findFirst, *.findMany, *.updateMany, *.deleteMany, *.count], requiredPath where.workspaceId, allowFrom [packages/persistence/src/audit.ts] +policy-revision-state-is-governed restrict-state-transition callee [*.policyRevision.updateMany], field data.state, allowFrom [packages/persistence/src/policies.ts], transitions draft->approved, approved->active, active->superseded +route-handlers-parse-their-input-* require-ingress-parse (five rules, suffixes get/post/put/patch/delete) files [layer:transport], symbols , parserCalls [readEvidenceRequest], readerCalls [dependencies.resolveSubmission], allowedCalls [dependencies.service.submit, errorResponse] +``` + +Expected outcome on the current tree, verified against the sources: + +- `findUnique` is not in the callee list: compound-unique selectors (`where: { workspaceId_runId: { runId, workspaceId } }` in `evidence.ts`) put the tenant key inside the unique-key object, and the composite unique index already scopes them. The ADR records this. +- `evidence.ts` `listEvidenceRuns` and `listEvidenceFindings` build `where` as `{ workspaceId, ...conditional spreads }`; every spread operand is a conditional of object literals with known keys, so section 2 rule 4 proves them without edits. `deleteExpiredEvidence` carries `workspaceId` literally. +- `policies.ts` writes `state` only with a literal `where.state` (`draft→approved` in `freezeDraftRevision`; `active→superseded` and `approved→active` in `activatePolicyRevision` and `retirePolicyPack`); all listed. No other file writes `policyRevision.updateMany` `data.state`. +- The evidence route is `export const POST = createEvidencePostHandler(productionDependencies)`, which section 5 reports as `INGRESS_PROOF_FAILED`. Forced edit, named under SC-V06 and, because it removes the dependency-injection seam, flagged for the owner in the ADR: `route.ts` becomes + + ```ts + export async function POST(request: Request): Promise { + const dependencies = evidenceDependencies(); // no arguments; trusted + let correlationId = "unavailable"; + try { + const context = await dependencies.resolveSubmission(request); // readerCalls: result untrusted + if (context === null) return errorResponse(401, "UNAUTHENTICATED", "Authentication is required.", correlationId); + correlationId = context.correlationId; // local now untrusted + const document = await readEvidenceRequest(request); // parserCalls: parsed = true, result trusted + const result = await dependencies.service.submit({ correlationId: context.correlationId, document, workspaceId: context.workspaceId }); // allowedCalls + return Response.json(result, { status: result.kind === "created" ? 201 : 200 }); // result trusted, no untrusted argument + } catch (error) { // catch binding trusted + if (error instanceof EvidenceIngressError) return errorResponse(error.status, error.code, error.message, correlationId); // allowedCalls + return errorResponse(500, "INTERNAL_ERROR", "The request could not be completed.", correlationId); + } + } + ``` + + Untrusted set after the pass: `request`, `context` (bound from a `readerCalls` call), `correlationId` (assigned from `context.correlationId`). Every callee is a textual property chain or an identifier bound to a declaration, so `resolveCalleeName` resolves all of them (`dependencies.resolveSubmission`, `errorResponse`, `readEvidenceRequest`, `dependencies.service.submit`, `Response.json`). `evidenceDependencies()` is a module-level accessor returning the production object (runtime config for `resolveRequestContext`, `EvidenceService` over `createEvidenceRepository`). `createEvidencePostHandler` and the `EvidenceRouteDependencies` injection type are deleted. `route.test.ts` keeps its three cases with `vi.mock` of `../../../../../server/runtime` (config with a known token and workspace, prisma stub) and of `../../../../../server/evidence/evidence-service` (submit resolving `created` / `duplicate`): 401 when the bearer header is absent, 201 then 200 for created and duplicate, 415 for `content-encoding: br` through the real `readEvidenceRequest`. + +## 8. Tests, fixtures, documentation + +| Area | File | Cases | +| --- | --- | --- | +| Schema (TM-V01) | `policy.test.ts` | layers accept/reject, malformed layer name, undeclared reference, reference in `scope` rejected, each new kind accept/reject, bad callee glob, bad dotted path | +| Layers (TM-V02) | `layers.test.ts` (new) | expansion, idempotence, identity without layers, `scope` untouched, fast-check property that reference order never changes the sorted glob set | +| Prover | `checks/argument-shape.test.ts` (new) | every `PathProof` branch incl. harmless and opaque spreads, unsafe and optional receivers, `couldMatchCallee` both-ends rule | +| Kinds (TM-V03) | `checks/call-argument.test.ts`, `checks/state-transition.test.ts`, `checks/ingress.test.ts` (new) | exact finding lists with subject and location. TM-V03 names `engine.test.ts`; this repo discharges per-kind matrices in `checks/*.test.ts` (as `context.test.ts`, `property-write.test.ts`, `registry.test.ts` do) and that is the deliberate reading. | +| Compatibility (TM-V04) | `compatibility.test.ts` | code and subject mismatch per new code | +| Golden (TM-V05) | `golden.test.ts` | unchanged and green; additionally asserts `filesScanned === 12` and the pinned `manifestDigest`, since new fixtures live under `fixtures/kinds/`, outside the golden `include` | +| Self-policy (TM-V06) | `runner.test.ts` | unchanged; bite proofs are manual steps recorded per ADR | +| Benchmark (TM-V07) | `scripts/benchmark-validator.ts` | one rule of each new kind added to the policy; numbers quoted in the ADR | +| Contracts (TM-V08, FR-DOG-002) | `scripts/generate-contracts.ts`, `docs/contracts/README.md` | `contracts:generate` then `contracts:check` exit 0; README gains one paragraph per kind in the register of the existing kinds: config, codes, subject grammar, proof-failure conditions; the layers paragraph goes under "RepositoryPolicy v1" | +| Docs (FR-DOG-003) | `packages/validator/README.md`, `docs/validator-and-hooks.md`, `.claude/skills/kz-policy-rule/SKILL.md` | one policy example per kind and one `layers` example; the skill's existing-kinds list names the three new kinds and layers | +| Traceability (FR-DOG-004) | `scripts/generate-traceability.ts` | groups FR-LAY (5), FR-ARG (4), FR-STA (5), FR-ING (6), FR-DOG (5); `docs/TRACEABILITY.md` regenerated | + +## 9. Sequence + +Matches PRP section 12: layers, prover plus call-argument, state-transition, ingress, dogfood/docs, PR. One commit and one ADR per step. + +**Owner checkpoint (PRP section 12 step 4).** Before `route.ts` is edited in the ingress step, the run pauses and asks the owner to confirm removal of `createEvidencePostHandler` and the `EvidenceRouteDependencies` injection seam (CLAUDE.md refactoring rule: existing functionality is confirmed before removal). Without that confirmation the run stops after the state-transition step with layers, `require-call-argument`, and `restrict-state-transition` delivered and the ingress kind undelivered, and the run-state records the block. diff --git a/docs/prp/2026-09-09-validator-rule-language.md b/docs/prp/2026-09-09-validator-rule-language.md new file mode 100644 index 0000000..fc82376 --- /dev/null +++ b/docs/prp/2026-09-09-validator-rule-language.md @@ -0,0 +1,332 @@ +# KERNEL ZERO validator rule language — Product Requirements Plan + +Status: DRAFT for owner review, 2026-09-09. Supersedes the validator sections of +`clean-room/PRP.md` (sections 5, 8, 13, 14, 15 as they concern the validator). +Everything else in that document is historical after ADR +`docs/adr/2026-09-08-single-operator-kernel.md`. + +## 1. Goal + +Make the policy rule language expressive enough that the six architectural +sentences below are each one deterministic, fail-closed rule in +`kernel-zero.policy.json`, proven by the standalone validator with no network, +clock, or runtime instrumentation: + +1. Only the repository layer may call the database client. +2. Every external payload is schema-validated before it reaches a service. +3. Every tenant query carries the tenant identifier. +4. A given state transition can happen only through one service. +5. A given function cannot be called from named architectural layers. +6. Code from any author, human or agent, must satisfy all of the above before merge. + +Sentences 1, 5, and 6 are already satisfied (`forbid-import-edge`, +`restrict-call-site`, and the protected `verify` check on `main`). This PRP +delivers the rest by adding one policy-level concept (named layers) and three +check kinds, and by making the repository's own policy use every one of them. + +## 2. Product principles + +1. Deterministic proof or fail closed. A rule either proves the property from + the TypeScript program or emits a `*_PROOF_FAILED` finding; it never guesses. +2. Rules are data, never code. No regular expressions from policy documents, no + plugins, no user-authored evaluators. +3. Evidence identity is stable. Existing finding fingerprints, existing policy + digests, and the golden fixture bytes do not move. +4. The kernel dogfoods every kind. A kind that the self-policy does not use + does not ship. +5. The console is out of scope. Effort goes to `packages/validator` and + `packages/profile-software-architecture`. + +## 3. Scope + +### 3.1 In scope + +- Named layers in the policy schema and their expansion before evaluation. +- Check kind `require-call-argument` (argument-shape proof for calls). +- Check kind `restrict-state-transition` (field writes and value transitions + through calls, restricted to named writers). +- Check kind `require-ingress-parse` (every exported ingress function parses + before its input escapes). +- A shared argument-shape prover in the validator, used by the two call kinds. +- Fixtures, engine tests, compatibility rules, message codes, generated + contracts, `docs/contracts/README.md`, validator README, traceability rows. +- Self-policy rules exercising each addition, each proven to bite. +- Benchmark coverage for the new kinds. + +### 3.2 Out of scope + +- Any change under `apps/control` beyond edits the self-policy forces. +- Cross-function or cross-file data flow. Every proof is intra-procedural. +- JavaScript sources, decorators, or reflection. +- Runtime enforcement, ESLint integration, editor integration. +- Bumping the validator version. `tool.version` stays `0.1.0` in this PRP + because it sits inside the evidence digest composition; a release commit + bumps it separately. +- New profiles, console pages, custody, identity, billing, or anything the + 2026-09-08 ADR removed. + +## 4. Existing seams this builds on + +- `packages/profile-software-architecture/src/policy.ts`: `PolicyCheckSchema` + discriminated union, `GlobList`, `ExactList`, `IdentifierSchema`, + `RelativeTypeScriptFileSchema`, `TypeReferenceSchema`. +- `packages/profile-software-architecture/src/compatibility.ts`: + `messageCodesByKind` and the per-kind subject grammar. +- `packages/validator/src/engine.ts`: dispatch and `ruleClaimsPath`; + `checks/*.ts` evaluators typed as `CheckEvaluator`; `findings.ts` + `rawFinding`, `nodeLocation`, `forEachMatchingSource`, `matchesGlob`; + `ast.ts` helpers (`walk`, `resolveCalleeName`, `propertyChainName`, + `asObjectLiteral`, `objectLiteralKeys`, `isProofBlockingProperty`, + `collectExportedFunctions`, `containsUnsafeType`). +- `static-registry.ts` is the model for a shared prover that never emits + findings; the argument prover follows it. +- `runner.ts` `publicMessageCode` maps raw codes to public codes. +- Skills: `kz-grill` (one ADR per kind), `kz-policy-rule` branch B (the eight + steps), `kz-checker` after each kind. + +## 5. Functional requirements + +### FR-LAY — named layers + +- **FR-LAY-001** `RepositoryPolicySchema` gains optional `layers`: a strict + object of 0..50 entries keyed by a slug (2..40 chars) with a `GlobList` value. + Absent `layers` parses exactly as today. +- **FR-LAY-002** Every glob-list field of every check (`from`, `files`, + `allowFrom`, `declarationFiles`) accepts `layer:` entries alongside + globs. A reference to an undeclared layer is a schema error (validator exit 2, + "Policy input does not satisfy the public contract"). +- **FR-LAY-003** Expansion happens in the profile, once, after parse and before + the engine sees the policy: `resolvePolicyLayers(policy)` returns a policy + whose glob lists contain only globs. The engine and every evaluator stay + glob-only. The policy digest is computed over the parsed, unexpanded document, + so a policy without `layers` keeps its digest byte for byte. +- **FR-LAY-004** Subject strings never contain layer names; fingerprints are + unchanged by expansion. `findingCompatibilityReason` runs against the expanded + policy. +- **FR-LAY-005** The self-policy declares layers `ui`, `transport`, `service`, + `persistence`, `kernel`, `validator` and rewrites every existing rule to + reference them where a glob list names a layer today. Its digest changes + (the document changed); that is recorded in the ADR. (Amended during the + autonomous run on 2026-09-09: `profiles` was dropped because no rule names + profile files and section 2 item 4 forbids shipping unexercised policy.) + +### FR-ARG — `require-call-argument` + +- **FR-ARG-001** Schema: + `{ kind: "require-call-argument", files: GlobList, callee: uniqueArray(CalleeGlob, 1, 100), argument: int 0..9 (default 0), requiredPath: DottedPath, allowFrom: OptionalGlobList (default []) }`. + `CalleeGlob` is a glob over the resolved callee chain that + `restrict-call-site` already computes (for example `*.findMany`, + `prisma.*.updateMany`). `DottedPath` is 1..8 identifier segments joined by + `.` (for example `where.workspaceId`). +- **FR-ARG-002** For every call in `files` (minus `allowFrom`) whose resolved + callee matches, the argument at `argument` must prove `requiredPath`: + the argument is an object literal, or an identifier bound by `const` in the + same function or module to an object literal; each path segment is a + non-computed, non-spread property; the leaf value is any expression other + than the literal `undefined`. +- **FR-ARG-003** Codes and subjects, all under subject + `call::argument::`: + - `CALL_ARGUMENT_MISSING` (public) when the literal is provable and the path + is absent or `undefined`. + - `CALL_ARGUMENT_PROOF_FAILED` (public) when the argument is not a provable + literal, a spread or computed key sits on the path, or the callee resolves + to a matching chain only through an unsafe (`any`/`unknown`) receiver. + - Unresolvable callees on `error` rules reuse the `restrict-call-site` + behaviour: raw `CALL_RESOLUTION_FAILED` when the expression root could be a + matching callee, mapped to `CALL_ARGUMENT_PROOF_FAILED`. +- **FR-ARG-004** Self-policy rule `tenant-queries-carry-workspace`: every + `*.findFirst`, `*.findMany`, `*.updateMany`, `*.deleteMany`, `*.count` + call in layer `persistence` must carry `where.workspaceId`, with + `allowFrom` naming `packages/persistence/src/audit.ts` (audit rows use + `workspaceOpaqueId`) and nothing else. Proven to bite by deleting one + `workspaceId` from a selector. + +### FR-STA — `restrict-state-transition` + +- **FR-STA-001** Schema: + `{ kind: "restrict-state-transition", callee: uniqueArray(CalleeGlob, 1, 100), argument: int 0..9 (default 0), field: DottedPath, allowFrom: OptionalGlobList, transitions: uniqueArray({ from: Identifier | "*", to: Identifier }, 0, 100) (default []) }`. + `field` is the path of the written value inside the argument (for example + `data.state`); the predicate path is the same leaf under `where` (for + example `where.state`). +- **FR-STA-002** Every matching call anywhere in scope whose argument writes + `field` (provably, per FR-ARG-002) must sit in `allowFrom`; otherwise + `STATE_TRANSITION_DENIED` with subject `transition::`. +- **FR-STA-003** When `transitions` is non-empty, each allowed writer's call + must have a string-literal `to` value at `field` and a string-literal `from` + value at the predicate path; the pair must be listed (or `from: "*"`). + A listed pair passes; an unlisted pair is `STATE_TRANSITION_DENIED` with + subject `transition::->`; a non-literal value or a + missing predicate is `STATE_TRANSITION_PROOF_FAILED` with the callee subject. +- **FR-STA-004** Writes that do not touch `field` are ignored; writes through + spreads or computed keys at any level of the argument are + `STATE_TRANSITION_PROOF_FAILED`. +- **FR-STA-005** Self-policy rule `policy-revision-state-is-governed`: + `*.policyRevision.updateMany` writing `data.state` is allowed only from + `packages/persistence/src/policies.ts`, with transitions + `draft->approved`, `approved->active`, `active->superseded`. Proven to bite by + adding an `updateMany` state write in `evidence.ts` and, separately, by + changing one transition literal to `draft->active`. + +### FR-ING — `require-ingress-parse` + +- **FR-ING-001** Schema: + `{ kind: "require-ingress-parse", files: GlobList, symbols: NonemptyExactStringSchema (glob over exported function names), parserCalls: ExactList, readerCalls: ExactList (default []), allowedCalls: ExactList (default []) }`. +- **FR-ING-002** For every exported symbol matching `symbols` in `files`: + only direct function declarations and arrow or function expressions bound by + an exported `const` are provable; any other export shape (for example + `export const X = createHandler(...)`) is a proof failure, never a silent + skip. Every parameter of a provable function is untrusted. Within the function + body, an untrusted value is the parameter, any property access on it, any + `await` of it, and the result of any `readerCalls` call that receives it. +- **FR-ING-003** The body must contain at least one call to `parserCalls` + whose argument is untrusted; otherwise `INGRESS_PARSE_MISSING` with subject + `symbol::parser`. +- **FR-ING-004** An untrusted value passed as an argument to any call that is + not in `parserCalls`, `readerCalls`, or `allowedCalls`, or returned, or + assigned to an exported or module-level binding, is `INGRESS_ESCAPE` with + subject `symbol::escape:`. +- **FR-ING-005** Loops, destructuring into more than one level, or a callee + that cannot be resolved are `INGRESS_PROOF_FAILED` with subject + `symbol::proof`. Assigning an untrusted value to a local + binding (declaration or reassignment) widens the untrusted set to that + binding; membership is monotone, so a later trusted reassignment never + downgrades it. (Amended 2026-09-09 during the autonomous run: the original + text made any reassignment a proof failure, which would have flagged the + route's own correlation-id handling and made the rule unusable on the one + route the kernel has.) +- **FR-ING-006** Self-policy rules `route-handlers-parse-their-input-{get,post,put,patch,delete}` + (five rules; the glob grammar has no alternation): files `layer:transport`, + symbols the verb, `parserCalls` `readEvidenceRequest`, `readerCalls` + `dependencies.resolveSubmission`, `allowedCalls` `dependencies.service.submit` + and `errorResponse`. If the existing evidence route does not pass, the route + is corrected, not the rule. (Amended 2026-09-09 during the autonomous run: + `resolveRequestContext` moves behind a no-argument `evidenceDependencies()` + accessor so the exported `POST` can be proven intra-procedurally; the reader + and allowed chains are therefore the accessor's members. Correcting the route + removes the `createEvidencePostHandler` dependency-injection seam, which is an + owner checkpoint before the edit, see section 12 step 4.) + +### FR-DOG — dogfooding, contracts, documentation + +- **FR-DOG-001** `kernel-zero.policy.json` uses layers and all three kinds; the + ADR records the new self-policy digest and each bite proof. +- **FR-DOG-002** `docs/contracts/repository-policy-v1.schema.json` and + `repository-evidence-v1.schema.json` regenerate; `contracts:check` is clean; + `docs/contracts/README.md` documents each kind, its codes, and its subject + grammar in the same register as the existing kinds. +- **FR-DOG-003** `packages/validator/README.md` and `docs/validator-and-hooks.md` + show one example of each kind, and `.claude/skills/kz-policy-rule/SKILL.md` + lists the new kinds (amended 2026-09-09 during the autonomous run so the + builder skill does not fall behind the schema). +- **FR-DOG-004** `scripts/generate-traceability.ts` gains FR-LAY, FR-ARG, + FR-STA, FR-ING, FR-DOG groups; `docs/TRACEABILITY.md` regenerates. +- **FR-DOG-005** The benchmark policy gains one rule of each new kind so the + 30-second and 1 GiB limits are measured with them active. + +## 6. Public contract impact + +- Policy `kernel-zero.dev/v1`: additive. `layers` is optional with no default; + three new discriminated-union members. Every stored policy parses unchanged + and keeps its digest. The self-policy's own digest changes because its + document changes. +- Evidence `kernel-zero.dev/evidence/v1`: additive. Seven new public message + codes in the closed `messageCode` enum: + `CALL_ARGUMENT_MISSING`, `CALL_ARGUMENT_PROOF_FAILED`, + `STATE_TRANSITION_DENIED`, `STATE_TRANSITION_PROOF_FAILED`, + `INGRESS_PARSE_MISSING`, `INGRESS_ESCAPE`, `INGRESS_PROOF_FAILED`, plus the + message strings for each. Existing codes, strings, and fingerprints are + untouched, so every stored run still re-validates. +- Exception bundles: unchanged. +- Golden fixture: `packages/validator/fixtures/golden/**` stays byte-identical. + +## 7. Non-functional requirements + +- **NFR-DET-001** Two runs over an unchanged tree produce identical + `integrity.digest` for the self-policy and for every fixture. +- **NFR-PERF-001** `npm run benchmark:validator` with one rule of each new + kind active stays under 30,000 ms per run and 1 GiB peak RSS on the 5,000 + file corpus; both numbers are quoted in the ADR. +- **NFR-SAFE-001** No new dependency. No network, clock, or environment reads + in any evaluator (the existing `validator-stays-network-and-database-free` + rule remains green). +- **NFR-TYPE-001** `strictTypeChecked` lint clean; no `any`; `unknown` only at + parse boundaries and type guards. + +## 8. Technology and pattern constraints + +- Node 22, npm workspaces, TypeScript 5 compiler API, Zod 4, Vitest, + fast-check where a property is natural (path expansion, glob matching). +- One evaluator file per kind under `packages/validator/src/checks/`, exported + as `CheckEvaluator`; a shared `checks/argument-shape.ts` prover that + never emits findings, mirroring `static-registry.ts`. +- Findings only through `rawFinding`; raw codes mapped in + `runner.ts` `publicMessageCode`. +- `// ponytail:` comments for any deliberate ceiling (for example + "intra-procedural only; inter-procedural flow if a consumer needs it"). +- Each kind goes through `kz-grill` (its own ADR under `docs/adr/`) and + `kz-policy-rule` branch B, then `kz-checker`. +- Commit per kind, plain developer language, no attribution trailers, on a + branch off `main`. `main` is protected and PR-only; the run ends with a + pull request, never a merge. + +## 9. Rejected and deferred + +Rejected: regular expressions in policy, user evaluators, runtime hooks, +inter-procedural data flow "just for the evidence route", any console work. + +Deferred: layer inheritance, a `layer-allowed-dependencies` matrix kind, +`require-call-argument` value constraints beyond presence, validator version +bump and npm publication (owner's release checkpoint). + +## 10. Test matrix + +| ID | Layer | Required proof | +| --- | --- | --- | +| TM-V01 | Schema | Each new kind and `layers` accepts a canonical example and rejects unknown fields, bad slugs, undeclared layer references, invalid dotted paths, and out-of-range indices. | +| TM-V02 | Profile | `resolvePolicyLayers` expands references, is idempotent, leaves a layer-free policy identical, and a fast-check property shows expansion order never changes the sorted glob set. | +| TM-V03 | Engine | Per kind: a passing fixture, a failing fixture, and a proof-failure fixture, with `engine.test.ts` asserting the exact finding list including `subject` and `location`. | +| TM-V04 | Compatibility | Every new code has a `rule_code_mismatch` and `rule_subject_mismatch` case. | +| TM-V05 | Golden | `golden.test.ts` byte-identical before and after. | +| TM-V06 | Self-policy | Each new rule proven to bite (exit 1, one finding with its rule ID) and restored (exit 0); digest identical across two runs. | +| TM-V07 | Benchmark | Numbers quoted under limits with the new rules active. | +| TM-V08 | Contracts | `contracts:generate` then `contracts:check` exit 0; README sections present. | +| TM-V09 | Gate | `npm run verify` exit 0 with unit, architecture, and integration counts quoted. | + +## 11. Success criteria + +- [ ] **SC-V01** Sentences 2, 3, and 4 in section 1 are each one rule in + `kernel-zero.policy.json`, and sentence 5's existing rule uses a layer name. +- [ ] **SC-V02** Every FR-ID in section 5 has a row in `docs/TRACEABILITY.md` + naming executable evidence. +- [ ] **SC-V03** `npm run verify` exit 0; unit, architecture, and integration + counts quoted; self-policy pass with an identical digest on two runs. +- [ ] **SC-V04** Benchmark under limits with the new kinds active, numbers quoted. +- [ ] **SC-V05** Golden fixture unchanged; `git diff --stat -- packages/validator/fixtures/golden` empty. +- [ ] **SC-V06** `git diff --stat -- packages/domain packages/contracts packages/persistence apps/control` shows only edits the self-policy forced, each named in an ADR. +- [ ] **SC-V07** One ADR per kind under `docs/adr/`, each with the bite proof + and digest. +- [ ] **SC-V08** Work lands as one pull request against `main` whose `verify` + check is green; no merge, no version bump, no publication. + +## 12. Implementation sequence and gates + +1. Layers (FR-LAY). Gate: TM-V01, TM-V02, TM-V05, TM-V06 for the rewritten + self-policy, `npm run verify`. +2. Argument prover plus `require-call-argument` (FR-ARG). Gate: TM-V01, TM-V03, + TM-V04, TM-V06, TM-V07, TM-V08, `npm run verify`. +3. `restrict-state-transition` (FR-STA), reusing the prover. Same gate. +4. `require-ingress-parse` (FR-ING). Same gate. **Owner checkpoint before + the route edit:** removing `createEvidencePostHandler` and its injection + type deletes shipped functionality; the run pauses and asks the owner to + confirm that removal before touching `route.ts`. If confirmation is + absent the run stops at the end of step 3 with steps 1 to 3 delivered. +5. Dogfooding and documentation (FR-DOG). Gate: TM-V08, TM-V09, SC-V01..V07. +6. Pull request (SC-V08). + +Each step is one commit on the branch, one ADR, one `kz-checker` verdict. + +## 13. Publication boundary + +Completion means a green pull request. Merge, validator version bump, npm +publication, and any change to branch protection are the owner's separate +decisions. From e63e977e62c347613d43b1d2c66d55d7e1c1533c Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 11:18:20 -0700 Subject: [PATCH 2/7] Add named layers to the repository policy schema and dogfood them in the self-policy --- docs/adr/2026-09-09-policy-layers.md | 189 +++++++++++++++++ docs/contracts/README.md | 2 + .../repository-policy-v1.schema.json | 190 +++++++++++------- ...-09-validator-rule-language-advisor-log.md | 3 + ...09-09-validator-rule-language-run-state.md | 6 +- docs/validator-and-hooks.md | 24 +++ kernel-zero.policy.json | 38 +++- .../src/compatibility.test.ts | 14 ++ .../src/compatibility.ts | 4 +- .../src/index.ts | 1 + .../src/layers.test.ts | 117 +++++++++++ .../src/layers.ts | 60 ++++++ .../src/policy.test.ts | 49 +++++ .../src/policy.ts | 59 +++++- packages/validator/README.md | 26 +++ packages/validator/src/engine.test.ts | 14 +- packages/validator/src/engine.ts | 8 +- packages/validator/src/golden.test.ts | 4 + packages/validator/src/runner.test.ts | 26 ++- packages/validator/src/runner.ts | 3 +- scripts/generate-contracts.ts | 2 + 21 files changed, 737 insertions(+), 102 deletions(-) create mode 100644 docs/adr/2026-09-09-policy-layers.md create mode 100644 packages/profile-software-architecture/src/layers.test.ts create mode 100644 packages/profile-software-architecture/src/layers.ts diff --git a/docs/adr/2026-09-09-policy-layers.md b/docs/adr/2026-09-09-policy-layers.md new file mode 100644 index 0000000..e5403c2 --- /dev/null +++ b/docs/adr/2026-09-09-policy-layers.md @@ -0,0 +1,189 @@ +# Named layers in the repository policy + +## Context + +`kernel-zero.policy.json` repeats the same glob lists across rules: the UI +pages, the transport routes, the server modules, the kernel packages, and the +validator each appear verbatim in one or more `from` and `files` lists. A +consumer policy that wants to say "a given function cannot be called from +named architectural layers" (PRP `docs/prp/2026-09-09-validator-rule-language.md`, +sentence 5) has no way to name a layer once, so every rule restates the +paths and the rules drift apart when a path moves. + +The policy schema lives in `packages/profile-software-architecture/src/policy.ts`; +the validator engine (`packages/validator/src/engine.ts`) and every evaluator +match file paths against globs only. The policy digest is +`canonicalSha256` of the schema-parsed document (`packages/validator/src/runner.ts`), +so any schema default that adds a key to the parsed document moves the digest +of every existing policy. + +## Decision + +Add one policy-level concept, named layers, expanded by the profile before +the engine sees the policy. The engine and evaluators stay glob-only. + +- `RepositoryPolicySchema` gains `layers`, an `exactOptional` record of at + most 50 slug names (`LayerNameSchema = SlugSchema(2, 40)`) to glob lists of + 1 to 100 entries. A layer value is a glob list only; a `layer:` entry inside a + layer value is rejected by the value schema (nesting is deferred, PRP section 9). + `exactOptional` keeps an absent key absent in the parsed document, so a + policy without `layers` parses to the same object and keeps its digest. +- The rule fields that name files (`from`, `files`, `allowFrom`, + `declarationFiles`) use the `RuleGlobList` / `OptionalRuleGlobList` aliases + (same element schema as before) and may carry `layer:` entries. + `scope.include` and `scope.exclude` keep the glob-only `GlobList` / + `OptionalGlobList`. +- One policy-level `superRefine` is the whole enforcement, with three message + shapes: `Layer reference is not a slug: `, + `Layer reference is not allowed in scope: `, and + `Layer is not declared: (rule , field )`. Absent `layers` + plus any rule reference is therefore a schema error (validator exit 2, + "Policy input does not satisfy the public contract"). +- `packages/profile-software-architecture/src/layers.ts` (new) exports + `LAYER_REFERENCE_PREFIX`, `isLayerReference`, `ruleFileLists`, and + `resolvePolicyLayers`. Expansion replaces each reference with the layer's + globs in declaration order, de-duplicates while keeping the first occurrence, + and returns a frozen policy; it is idempotent and leaves `layers`, `scope`, + and rule identity untouched. Field discovery is by name on the check object, + so a future kind that reuses those field names inherits layers. A reference + the schema did not see throws (fail closed) rather than expanding to nothing. +- `packages/validator/src/runner.ts` computes the digest over the parsed, + unexpanded document exactly as before and passes + `resolvePolicyLayers(policy)` to `evaluatePolicyChecks`. `explain` and the + returned `policy` keep the unexpanded document. +- `evaluatePolicyChecks` throws `RepositoryProgramError("Policy layers must be + resolved before evaluation.")` if any rule file list still holds a + reference. That is a programming-error guard, not a finding. +- `findingCompatibilityReason` resolves layers on entry. `diffRules` does not + expand, so a layer edit diffs as a rule change. +- The self-policy declares six layers (`ui`, `transport`, `service`, + `persistence`, `kernel`, `validator`) and rewrites + `ui-does-not-import-persistence`, `transport-does-not-import-repositories`, + `server-modules-declare-boundary`, `kernel-does-not-import-profiles`, and + `validator-stays-network-and-database-free` to reference them. + `raw-database-client-is-contained` keeps its explicit globs because its + `from` spans apps and three packages with no matching layer. The seventh + layer `profiles` from the original FR-LAY-005 is dropped: no rule names + profile files, and PRP section 2 item 4 forbids shipping unexercised policy. + +This is `kz-policy-rule` branch A for the self-policy rewrite (existing kinds, +new references) on top of a schema addition; no new check kind, no engine +dispatch change, no new message code. + +## Invariants touched + +1. Definite policy violations fail closed. A malformed, undeclared, or + misplaced reference is a schema error (exit 2); a reference that reaches + expansion unchecked throws; a reference that reaches the engine throws. + Proof: `policy.test.ts` (three exact messages, 51 layers rejected), + `layers.test.ts` (undeclared reference throws), `engine.test.ts` (guard). +6. UI and transport routes never import persistence or Prisma. The two rules + now reference `layer:ui` and `layer:transport`. Proof: the bite proof below + (a probe route importing `@kernel-zero/persistence` produced one + `transport-does-not-import-repositories` finding). +8. Server modules import `server-only`. `server-modules-declare-boundary` now + reads `files: ["layer:service", "layer:persistence"]`. Proof: the bite proof + below (a probe server module without the import produced one + `server-modules-declare-boundary` finding). +10. Local validation is deterministic and network-free. Expansion is a pure + function of the parsed document; `npm run validator:self` twice produced + the same `integrity.digest`. +11. Public wire formats are versioned and digests are over document content. + The digest still covers the parsed, unexpanded document; no default was + added; a layer-free policy is byte-identical. See Contract impact. +12. Kernel packages never import a profile. Unchanged: + `git diff --stat -- packages/domain packages/contracts packages/persistence` + is empty; the validator (not a kernel package) already imported the + profile and now also imports `resolvePolicyLayers`, `isLayerReference`, + and `ruleFileLists` from it. + +Invariants 2, 4, 5, 7, and 9 are unaffected: no control-plane change, no new +tenant selector, no governed action, no new ingress, no publication. + +## Contract impact + +Policy `kernel-zero.dev/v1`: additive. `layers` is optional with no default +and the rule glob lists accept the same strings as before (`layer:x` was +already syntactically a contained relative glob). Every stored layer-free +policy parses to the same object and keeps its digest; every finding +fingerprint under it is unchanged. Subjects never contain layer names. +`docs/contracts/repository-policy-v1.schema.json` and +`docs/contracts/README.md` regenerated (`layers` in the schema; one paragraph +under "RepositoryPolicy v1"). + +Evidence `kernel-zero.dev/evidence/v1`: unchanged. No message code or string +changed, so every stored run still re-validates. Exception bundles: unchanged. + +Golden fixture `packages/validator/fixtures/golden/**`: byte-identical +(`git diff --stat -- packages/validator/fixtures/golden` empty); +`golden.test.ts` now also pins `filesScanned === 12` and the fixture's +`manifestDigest`. + +Self-policy digests (the document changed, so both moved): + +| | before | after | +| --- | --- | --- | +| `policy.digest` | `sha256:006a0a332433797dac544fd3b42c6f11d87f8c28e625611aed17c2d39419dec2` | `sha256:b9191f8b8f71e78ab39d221b44a8af37635bab55c8999aedb879a99e35c424d9` | +| `integrity.digest` | `sha256:435c3f708ebb44571c674ad6ab6f3f8abc1a32bd10d8d27846b3f399e9ed3bb8` | `sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824` | + +The integrity digest also reflects the manifest gaining one source file +(`packages/profile-software-architecture/src/layers.ts`, 91 to 92 files). + +## FR-IDs + +FR-LAY-001 (schema), FR-LAY-002 (references in every file-naming field, schema +error on undeclared), FR-LAY-003 (expansion in the profile after the digest, +engine glob-only), FR-LAY-004 (subjects and fingerprints unchanged, +compatibility runs on the expanded policy), FR-LAY-005 (self-policy uses six +layers; `profiles` dropped as amended in the PRP). + +## Verification command + +Node 22 (`export PATH="/c/Users/Guerr/AppData/Roaming/fnm/node-versions/v22.22.3/installation:$PATH"`). + +```text +npx vitest run packages/profile-software-architecture + Test Files 6 passed (6) + Tests 85 passed (85) + +npm run test:validator + Test Files 9 passed (9) + Tests 72 passed (72) + +npm run validator:self (run 1) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 92 files) + integrity.digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 +npm run validator:self (run 2) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 92 files) + integrity.digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 + +npm run contracts:generate && npm run contracts:check exit 0 +git diff --stat -- packages/validator/fixtures/golden (empty) +npm run typecheck exit 0 +npm run lint exit 0 +``` + +Bite proof (probe files created, validated, deleted): + +```text +apps/control/src/app/api/kz-probe/route.ts import "@kernel-zero/persistence"; +apps/control/src/server/kz-probe.ts (no server-only import) + +kernel-zero: fail (2 errors, 0 warnings, 0 excepted, 94 files) +error server-modules-declare-boundary apps/control/src/server/kz-probe.ts:1:1 REQUIRED_IMPORT_MISSING file +error transport-does-not-import-repositories apps/control/src/app/api/kz-probe/route.ts:1:1 DENIED_IMPORT @kernel-zero/persistence +exit 1 + +(after deleting both probes) +kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 92 files) +exit 0 +``` + +`npm run verify` on this tree: exit 0. + +```text +kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 92 files) +unit Test Files 46 passed (46) Tests 287 passed (287) +architecture Test Files 1 passed (1) Tests 1 passed (1) +integration Test Files 3 passed (3) Tests 7 passed (7) +``` diff --git a/docs/contracts/README.md b/docs/contracts/README.md index bd57dc3..dab117d 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -10,6 +10,8 @@ A strict, non-executable repository policy. Unknown fields, unknown check kinds, The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, and `restrict-property-write`. +`layers` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (`from`, `files`, `allowFrom`, `declarationFiles`) may carry `layer:` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. `scope.include` and `scope.exclude` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside `scope`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without `layers` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. + `require-context-parameter` proves that exported functions matching `symbols` take a required `parameter` (as a named parameter or a required property of the first object parameter). An optional `expectedType` is either an exact intrinsic (`{ "kind": "intrinsic", "name": "string" }`) or one exported type (`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: `CONTEXT_PARAMETER_INVALID` (subject `symbol::parameter:`) and `CONTEXT_PARAMETER_PROOF_FAILED` (the same subject, or `type:#` when the configured type cannot resolve). `require-closed-registry` proves that one exported registry object (`registryFile` plus `registryExport`, a direct or exact `Object.freeze` object literal) is the only source of declarations made through `declarationCalls` inside `declarationFiles`. Entry IDs are direct identifier or string keys matching `[A-Za-z0-9][A-Za-z0-9._/-]{0,119}`; each entry is a plain object literal carrying every `requiredKeys` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: `CLOSED_REGISTRY_ENTRY_INVALID` (subject `registry::entry::`, where key is a required key or `id`), `UNREGISTERED_DECLARATION` (subject `registry::declaration:`), and `CLOSED_REGISTRY_PROOF_FAILED` (subject `registry::proof:`, `registry::proof:registry`, or `registry::proof:declaration`). The marker `` stands for an ID that cannot be rendered safely. diff --git a/docs/contracts/repository-policy-v1.schema.json b/docs/contracts/repository-policy-v1.schema.json index 17b0ad9..b2cdd2a 100644 --- a/docs/contracts/repository-policy-v1.schema.json +++ b/docs/contracts/repository-policy-v1.schema.json @@ -3,14 +3,42 @@ "type": "object", "properties": { "apiVersion": { + "$ref": "#/$defs/__schema0" + }, + "kind": { + "$ref": "#/$defs/__schema1" + }, + "metadata": { + "$ref": "#/$defs/__schema2" + }, + "scope": { + "$ref": "#/$defs/__schema3" + }, + "layers": { + "$ref": "#/$defs/__schema8" + }, + "rules": { + "$ref": "#/$defs/__schema12" + } + }, + "required": [ + "apiVersion", + "kind", + "metadata", + "scope", + "rules" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { "type": "string", "const": "kernel-zero.dev/v1" }, - "kind": { + "__schema1": { "type": "string", "const": "RepositoryPolicy" }, - "metadata": { + "__schema2": { "type": "object", "properties": { "name": { @@ -37,7 +65,7 @@ ], "additionalProperties": false }, - "scope": { + "__schema3": { "type": "object", "properties": { "languages": { @@ -45,14 +73,14 @@ "maxItems": 2, "type": "array", "items": { - "$ref": "#/$defs/__schema0" + "$ref": "#/$defs/__schema4" } }, "include": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "exclude": { - "$ref": "#/$defs/__schema3" + "$ref": "#/$defs/__schema7" } }, "required": [ @@ -62,53 +90,69 @@ ], "additionalProperties": false }, - "rules": { - "minItems": 1, - "maxItems": 500, - "type": "array", - "items": { - "$ref": "#/$defs/__schema4" - } - } - }, - "required": [ - "apiVersion", - "kind", - "metadata", - "scope", - "rules" - ], - "additionalProperties": false, - "$defs": { - "__schema0": { + "__schema4": { "type": "string", "enum": [ "typescript", "tsx" ] }, - "__schema1": { + "__schema5": { "minItems": 1, "maxItems": 100, "type": "array", "items": { - "$ref": "#/$defs/__schema2" + "$ref": "#/$defs/__schema6" } }, - "__schema2": { + "__schema6": { "type": "string", "minLength": 1, "maxLength": 500 }, - "__schema3": { + "__schema7": { "minItems": 0, "maxItems": 100, "type": "array", "items": { - "$ref": "#/$defs/__schema2" + "$ref": "#/$defs/__schema6" } }, - "__schema4": { + "__schema8": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/__schema9" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema10" + } + }, + "__schema9": { + "type": "string", + "minLength": 2, + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "__schema10": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema11" + } + }, + "__schema11": { + "$ref": "#/$defs/__schema6" + }, + "__schema12": { + "minItems": 1, + "maxItems": 500, + "type": "array", + "items": { + "$ref": "#/$defs/__schema13" + } + }, + "__schema13": { "type": "object", "properties": { "id": { @@ -139,14 +183,14 @@ "const": "forbid-import-edge" }, "from": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "deny": { "minItems": 1, "maxItems": 100, "type": "array", "items": { - "$ref": "#/$defs/__schema5" + "$ref": "#/$defs/__schema14" } } }, @@ -165,10 +209,10 @@ "const": "require-import" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "module": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" }, "allowTypeOnly": { "default": false, @@ -190,10 +234,10 @@ "const": "restrict-call-site" }, "callee": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema16" }, "allowFrom": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "requireResolution": { "default": true, @@ -215,13 +259,13 @@ "const": "require-export-keys" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "exportName": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" }, "requiredKeys": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema16" } }, "required": [ @@ -240,14 +284,14 @@ "const": "require-tenant-parameter" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "symbols": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" }, "parameter": { "default": "workspaceId", - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" } }, "required": [ @@ -265,13 +309,13 @@ "const": "require-boundary-parse" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "boundaryCalls": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema16" }, "parserCalls": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema16" } }, "required": [ @@ -290,24 +334,24 @@ "const": "require-governed-operation" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "registryExport": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" }, "requiredKeys": { "minItems": 1, "maxItems": 5, "type": "array", "items": { - "$ref": "#/$defs/__schema8" + "$ref": "#/$defs/__schema17" } }, "declarationCalls": { "default": [ "defineGovernedAction" ], - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema16" } }, "required": [ @@ -326,13 +370,13 @@ "const": "require-context-parameter" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "symbols": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" }, "parameter": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" }, "expectedType": { "default": null, @@ -370,10 +414,10 @@ "const": "export" }, "file": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema18" }, "exportName": { - "$ref": "#/$defs/__schema10" + "$ref": "#/$defs/__schema19" } }, "required": [ @@ -407,23 +451,23 @@ "const": "require-closed-registry" }, "registryFile": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema18" }, "registryExport": { - "$ref": "#/$defs/__schema10" + "$ref": "#/$defs/__schema19" }, "declarationFiles": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "declarationCalls": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema16" }, "requiredKeys": { "minItems": 0, "maxItems": 100, "type": "array", "items": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" } } }, @@ -445,16 +489,16 @@ "const": "restrict-property-write" }, "files": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema5" }, "targetType": { "type": "object", "properties": { "file": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema18" }, "exportName": { - "$ref": "#/$defs/__schema10" + "$ref": "#/$defs/__schema19" } }, "required": [ @@ -464,10 +508,10 @@ "additionalProperties": false }, "property": { - "$ref": "#/$defs/__schema10" + "$ref": "#/$defs/__schema19" }, "allowFrom": { - "$ref": "#/$defs/__schema3" + "$ref": "#/$defs/__schema7" } }, "required": [ @@ -496,25 +540,25 @@ ], "additionalProperties": false }, - "__schema5": { + "__schema14": { "type": "string", "minLength": 6, "maxLength": 500 }, - "__schema6": { + "__schema15": { "type": "string", "minLength": 1, "maxLength": 500 }, - "__schema7": { + "__schema16": { "minItems": 1, "maxItems": 100, "type": "array", "items": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema15" } }, - "__schema8": { + "__schema17": { "type": "string", "enum": [ "capability", @@ -524,10 +568,10 @@ "idempotency" ] }, - "__schema9": { - "$ref": "#/$defs/__schema2" + "__schema18": { + "$ref": "#/$defs/__schema6" }, - "__schema10": { + "__schema19": { "type": "string", "minLength": 1, "maxLength": 200, diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md index 10944f8..631f349 100644 --- a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -50,3 +50,6 @@ Session `autonomous-validator-rule-language-2026-09-09`. Every decision and ever ### Verdict 6: DESIGN_APPROVAL on the implementation plan (revision 2) **Verdict:** PASS. Verifier independently re-ran unit, integration, and architecture checks and matched the digests on disk. + +### Verdict 7: kz-checker on step 1 (named layers) +**Verdict:** PASS; all five implementer deviations judged acceptable (exactOptional for the digest type, layer values reject nested references, message carries rule id and field, shared ruleFileLists export, expander throws on an unseen reference). diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index 869e2f9..972fa32 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -6,15 +6,15 @@ - **Branch:** feat/validator-rule-language (off main at 618b4cc) - **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope - **Current phase:** 3-implement -- **In-flight:** step 1 (named layers), implementer dispatched; tasks 1.1-1.6 -- **Next action:** Phase 3 step 1 — implementer returns, run kz-checker, commit; then step 2 +- **In-flight:** step 2 (argument prover, require-call-argument), implementer dispatched; tasks 2.1-2.6 +- **Next action:** Phase 3 step 2 — implementer returns, run kz-checker, commit; then step 3 ## Phase Gates | Phase | Gate | Result | Evidence (command + exit / artifact path) | |-------|------|--------|-------------------------------------------| | 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | | 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | -| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | pending | | +| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md | | 4-branch | PR URL recorded | pending | | | 5-optimize | optimization loop termination | pending | | diff --git a/docs/validator-and-hooks.md b/docs/validator-and-hooks.md index a98c954..4e9156b 100644 --- a/docs/validator-and-hooks.md +++ b/docs/validator-and-hooks.md @@ -17,6 +17,30 @@ without touching the output. Production export requires an authenticated control-plane channel and approved signing-key custody; neither is inferred by the standalone validator. +## Named layers + +`kernel-zero.policy.json` declares its architectural layers once under +`layers` and references them from rule file lists (`from`, `files`, +`allowFrom`, `declarationFiles`) as `layer:`. `scope.include` and +`scope.exclude` accept globs only. The repository self-policy uses six layers: + +```json +"layers": { + "ui": ["apps/control/src/app/**/page.tsx", "apps/control/src/app/**/layout.tsx"], + "transport": ["apps/control/src/app/api/**/*.ts"], + "service": ["apps/control/src/server/**/*.ts"], + "persistence": ["packages/persistence/src/**/*.ts"], + "kernel": ["packages/domain/**/*.ts", "packages/contracts/**/*.ts", "packages/persistence/**/*.ts"], + "validator": ["packages/validator/**/*.ts"] +} +``` + +and `transport-does-not-import-repositories` reads +`"from": ["layer:transport"]`. References are expanded after the policy digest +is computed and before evaluation, so subjects, fingerprints, and the digest +of a layer-free policy are unchanged. An undeclared or malformed reference, or +one placed in `scope`, is a policy contract failure (exit `2`). + ## Agent-facing commands `kernel-zero explain --policy|--evidence ` renders one strictly parsed diff --git a/kernel-zero.policy.json b/kernel-zero.policy.json index 78c374a..52beece 100644 --- a/kernel-zero.policy.json +++ b/kernel-zero.policy.json @@ -22,6 +22,29 @@ "packages/persistence/src/client.ts" ] }, + "layers": { + "ui": [ + "apps/control/src/app/**/page.tsx", + "apps/control/src/app/**/layout.tsx" + ], + "transport": [ + "apps/control/src/app/api/**/*.ts" + ], + "service": [ + "apps/control/src/server/**/*.ts" + ], + "persistence": [ + "packages/persistence/src/**/*.ts" + ], + "kernel": [ + "packages/domain/**/*.ts", + "packages/contracts/**/*.ts", + "packages/persistence/**/*.ts" + ], + "validator": [ + "packages/validator/**/*.ts" + ] + }, "rules": [ { "id": "server-modules-declare-boundary", @@ -30,8 +53,8 @@ "check": { "kind": "require-import", "files": [ - "apps/control/src/server/**/*.ts", - "packages/persistence/src/**/*.ts" + "layer:service", + "layer:persistence" ], "module": "server-only", "allowTypeOnly": false @@ -45,8 +68,7 @@ "check": { "kind": "forbid-import-edge", "from": [ - "apps/control/src/app/**/page.tsx", - "apps/control/src/app/**/layout.tsx" + "layer:ui" ], "deny": [ "module:@kernel-zero/persistence", @@ -62,7 +84,7 @@ "check": { "kind": "forbid-import-edge", "from": [ - "apps/control/src/app/api/**/*.ts" + "layer:transport" ], "deny": [ "module:@kernel-zero/persistence", @@ -150,9 +172,7 @@ "check": { "kind": "forbid-import-edge", "from": [ - "packages/domain/**/*.ts", - "packages/contracts/**/*.ts", - "packages/persistence/**/*.ts" + "layer:kernel" ], "deny": [ "module:@kernel-zero/profiles", @@ -169,7 +189,7 @@ "check": { "kind": "forbid-import-edge", "from": [ - "packages/validator/**/*.ts" + "layer:validator" ], "deny": [ "module:@kernel-zero/persistence", diff --git a/packages/profile-software-architecture/src/compatibility.test.ts b/packages/profile-software-architecture/src/compatibility.test.ts index fff3b5c..579b443 100644 --- a/packages/profile-software-architecture/src/compatibility.test.ts +++ b/packages/profile-software-architecture/src/compatibility.test.ts @@ -98,6 +98,20 @@ describe("evidence rule compatibility", () => { expect(findingCompatibilityReason(policy(PW), finding("PROPERTY_WRITE_PROOF_FAILED", "property:src/domain/job.ts#Task.status"))).toBe("rule_subject_mismatch"); }); + it("resolves layer references before matching so a layered rule accepts its finding", () => { + const layered = RepositoryPolicySchema.parse({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + layers: { source: ["src/**/*.ts"] }, + metadata: { description: "Policy", name: "policy", revision: 1 }, + rules: [{ check: { deny: ["module:blocked-package"], from: ["layer:source"], kind: "forbid-import-edge" }, id: "test-rule", level: "error", remediation: "Fix it.", title: "Test" }], + scope: { exclude: [], include: ["**/*.ts"], languages: ["typescript"] }, + }); + expect(findingCompatibilityReason(layered, finding("DENIED_IMPORT", "blocked-package"))).toBeNull(); + expect(findingCompatibilityReason(layered, finding("DENIED_IMPORT", "other-package"))).toBe("rule_subject_mismatch"); + expect(layered.rules[0]?.check).toMatchObject({ from: ["layer:source"] }); + }); + it("allows parse failures only for error-level resolved rules", () => { const firstCase = checkCases[0]; if (firstCase === undefined) throw new Error("Test cases are required."); diff --git a/packages/profile-software-architecture/src/compatibility.ts b/packages/profile-software-architecture/src/compatibility.ts index 77d6fd4..ec6b2b1 100644 --- a/packages/profile-software-architecture/src/compatibility.ts +++ b/packages/profile-software-architecture/src/compatibility.ts @@ -1,5 +1,6 @@ import type { EvidenceFinding } from "@kernel-zero/contracts"; +import { resolvePolicyLayers } from "./layers"; import type { RepositoryPolicy } from "./policy"; const messageCodesByKind: Readonly> = Object.freeze({ @@ -15,7 +16,8 @@ const messageCodesByKind: Readonly candidate.id === finding.ruleId); if (rule === undefined) return "rule_not_found"; if (finding.level !== rule.level) return "rule_level_mismatch"; diff --git a/packages/profile-software-architecture/src/index.ts b/packages/profile-software-architecture/src/index.ts index d53dcd4..c814fe6 100644 --- a/packages/profile-software-architecture/src/index.ts +++ b/packages/profile-software-architecture/src/index.ts @@ -8,6 +8,7 @@ import { RepositoryPolicySchema, repositoryPolicyJsonSchema, type RepositoryPoli export * from "./compatibility"; export * from "./diff"; export * from "./evidence"; +export * from "./layers"; export * from "./policy"; export const softwareArchitectureProfile: Profile = Object.freeze({ diff --git a/packages/profile-software-architecture/src/layers.test.ts b/packages/profile-software-architecture/src/layers.test.ts new file mode 100644 index 0000000..5108ed4 --- /dev/null +++ b/packages/profile-software-architecture/src/layers.test.ts @@ -0,0 +1,117 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { LAYER_REFERENCE_PREFIX, isLayerReference, resolvePolicyLayers, ruleFileLists } from "./layers"; +import { RepositoryPolicySchema, type RepositoryPolicy } from "./policy"; + +const LAYERS = { + persistence: ["packages/persistence/src/**/*.ts"], + service: ["apps/control/src/server/**/*.ts", "packages/persistence/src/**/*.ts"], + ui: ["apps/control/src/app/**/page.tsx", "apps/control/src/app/**/layout.tsx"], +} as const; + +function policy(overrides: Record = {}, layers: Record | null = LAYERS): RepositoryPolicy { + return RepositoryPolicySchema.parse({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + ...(layers === null ? {} : { layers }), + metadata: { description: "Layered policy", name: "layered", revision: 1 }, + rules: [ + { + check: { deny: ["module:@prisma/client"], from: ["layer:ui", "layer:service", "packages/persistence/src/**/*.ts"], kind: "forbid-import-edge" }, + id: "ui-no-prisma", level: "error", remediation: "Use a service.", title: "UI", + }, + { + check: { allowTypeOnly: false, files: ["layer:service", "layer:persistence"], kind: "require-import", module: "server-only" }, + id: "server-only", level: "error", remediation: "Import server-only.", title: "Server", + }, + { + check: { allowFrom: ["layer:persistence"], callee: ["db.query"], kind: "restrict-call-site", requireResolution: true }, + id: "db-query", level: "warning", remediation: "Call from persistence.", title: "Calls", + }, + ], + scope: { exclude: ["**/*.test.ts"], include: ["apps/**/*.ts", "packages/**/*.ts"], languages: ["typescript"] }, + ...overrides, + }); +} + +describe("policy layers", () => { + it("recognizes references by prefix and discovers rule file lists by field name", () => { + expect(isLayerReference(`${LAYER_REFERENCE_PREFIX}ui`)).toBe(true); + expect(isLayerReference("apps/**")).toBe(false); + expect(policy().rules.map((rule) => ruleFileLists(rule.check).map(([field]) => field))).toEqual([["from"], ["files"], ["allowFrom"]]); + const registry: RepositoryPolicy["rules"][number]["check"] = { declarationCalls: ["define"], declarationFiles: ["src/**"], kind: "require-closed-registry", registryExport: "R", registryFile: "src/r.ts", requiredKeys: [] }; + expect(ruleFileLists(registry).map(([field]) => field)).toEqual(["declarationFiles"]); + }); + + it("expands references in declaration order and de-duplicates while keeping the first occurrence", () => { + const resolved = resolvePolicyLayers(policy()); + expect(resolved.rules.map((rule) => rule.check)).toEqual([ + { + deny: ["module:@prisma/client"], + from: ["apps/control/src/app/**/page.tsx", "apps/control/src/app/**/layout.tsx", "apps/control/src/server/**/*.ts", "packages/persistence/src/**/*.ts"], + kind: "forbid-import-edge", + }, + { allowTypeOnly: false, files: ["apps/control/src/server/**/*.ts", "packages/persistence/src/**/*.ts"], kind: "require-import", module: "server-only" }, + { allowFrom: ["packages/persistence/src/**/*.ts"], callee: ["db.query"], kind: "restrict-call-site", requireResolution: true }, + ]); + }); + + it("is idempotent, frozen, and leaves layers, scope, metadata, and rule identity untouched", () => { + const input = policy(); + const once = resolvePolicyLayers(input); + const twice = resolvePolicyLayers(once); + expect(twice).toEqual(once); + expect(Object.isFrozen(once)).toBe(true); + expect(Object.isFrozen(once.rules)).toBe(true); + expect(once.rules.every((rule) => Object.isFrozen(rule) && Object.isFrozen(rule.check))).toBe(true); + expect(once.rules.every((rule) => ruleFileLists(rule.check).every(([, globs]) => Object.isFrozen(globs)))).toBe(true); + expect(once.layers).toEqual(input.layers); + expect(once.scope).toEqual(input.scope); + expect(once.metadata).toEqual(input.metadata); + expect(once.rules.map((rule) => [rule.id, rule.level, rule.title, rule.remediation])).toEqual(input.rules.map((rule) => [rule.id, rule.level, rule.title, rule.remediation])); + expect(input.rules[0]?.check).toMatchObject({ from: ["layer:ui", "layer:service", "packages/persistence/src/**/*.ts"] }); + }); + + it("returns an equal document for a policy without layers", () => { + const plain = policy({ + rules: [{ check: { deny: ["module:x"], from: ["apps/**"], kind: "forbid-import-edge" }, id: "plain", level: "error", remediation: "r", title: "t" }], + }, null); + expect("layers" in plain).toBe(false); + const resolved = resolvePolicyLayers(plain); + expect(resolved).toEqual(plain); + expect("layers" in resolved).toBe(false); + }); + + it("fails closed on a reference the schema did not see", () => { + const handBuilt: RepositoryPolicy = { ...policy(), layers: { ui: ["apps/**"] } }; + expect(() => resolvePolicyLayers(handBuilt)).toThrow("Layer is not declared: service"); + }); + + it("never changes the expanded glob set when reference order changes", () => { + const slug = fc.stringMatching(/^[a-z][a-z0-9]{1,9}$/u); + const glob = fc.stringMatching(/^[a-z][a-z0-9]{0,5}(?:\/[a-z*][a-z0-9*]{0,5}){0,3}\.ts$/u); + const layersArbitrary = fc.dictionary(slug, fc.uniqueArray(glob, { minLength: 1, maxLength: 4 }), { minKeys: 1, maxKeys: 6 }); + fc.assert(fc.property( + layersArbitrary.chain((layers) => { + const names = Object.keys(layers); + const entries = fc.uniqueArray(fc.oneof(fc.constantFrom(...names).map((name) => `${LAYER_REFERENCE_PREFIX}${name}`), glob), { minLength: 1, maxLength: 8 }); + return fc.tuple(fc.constant(layers), entries.chain((list) => fc.tuple(fc.constant(list), fc.shuffledSubarray(list, { minLength: list.length })))); + }), + ([layers, [ordered, shuffled]]) => { + const resolve = (from: readonly string[]) => resolvePolicyLayers({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + layers, + metadata: { description: "Property policy", name: "property", revision: 1 }, + rules: [{ check: { deny: ["module:x"], from: [...from], kind: "forbid-import-edge" }, id: "rule", level: "error", remediation: "r", title: "t" }], + scope: { exclude: [], include: ["**/*.ts"], languages: ["typescript"] }, + }).rules.flatMap((rule) => ruleFileLists(rule.check).flatMap(([, globs]) => globs)); + const expected = [...new Set(ordered.flatMap((entry) => (isLayerReference(entry) ? layers[entry.slice(LAYER_REFERENCE_PREFIX.length)] ?? [] : [entry])))].sort(); + expect([...resolve(shuffled)].sort()).toEqual(expected); + expect([...resolve(ordered)].sort()).toEqual(expected); + expect(resolve(shuffled).some(isLayerReference)).toBe(false); + }, + )); + }); +}); diff --git a/packages/profile-software-architecture/src/layers.ts b/packages/profile-software-architecture/src/layers.ts new file mode 100644 index 0000000..f3ddc5f --- /dev/null +++ b/packages/profile-software-architecture/src/layers.ts @@ -0,0 +1,60 @@ +import type { RepositoryPolicy } from "./policy"; + +export const LAYER_REFERENCE_PREFIX = "layer:"; + +type PolicyCheck = RepositoryPolicy["rules"][number]["check"]; + +// Field discovery is by name so a future check kind that reuses one of these names inherits layer references. +const RULE_FILE_FIELDS = ["from", "files", "allowFrom", "declarationFiles"] as const; + +export type RuleFileField = (typeof RULE_FILE_FIELDS)[number]; + +export function isLayerReference(value: string): boolean { + return value.startsWith(LAYER_REFERENCE_PREFIX); +} + +/** The file-naming lists a check carries, by field name; scope lists are never included. */ +export function ruleFileLists(check: PolicyCheck): readonly (readonly [RuleFileField, readonly string[]])[] { + const record: Record = check; + const lists: (readonly [RuleFileField, readonly string[]])[] = []; + for (const field of RULE_FILE_FIELDS) { + const value = record[field]; + if (isStringArray(value)) lists.push([field, value]); + } + return lists; +} + +/** Equal policy whose rule file lists contain only globs. Idempotent. `layers` and `scope` untouched. */ +export function resolvePolicyLayers(policy: RepositoryPolicy): RepositoryPolicy { + const layers = policy.layers ?? {}; + const rules = policy.rules.map((rule) => { + const check: Record = { ...rule.check }; + for (const [field, globs] of ruleFileLists(rule.check)) { + check[field] = expandGlobList(globs, layers); + } + return Object.freeze({ ...rule, check: Object.freeze(check) as PolicyCheck }); + }); + Object.freeze(rules); + return Object.freeze({ ...policy, rules }); +} + +function expandGlobList(globs: readonly string[], layers: Readonly>): string[] { + const expanded = new Set(); + for (const entry of globs) { + if (!isLayerReference(entry)) { + expanded.add(entry); + continue; + } + const name = entry.slice(LAYER_REFERENCE_PREFIX.length); + const members = layers[name]; + if (members === undefined) throw new Error(`Layer is not declared: ${name}`); + for (const glob of members) expanded.add(glob); + } + const result = [...expanded]; + Object.freeze(result); + return result; +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} diff --git a/packages/profile-software-architecture/src/policy.test.ts b/packages/profile-software-architecture/src/policy.test.ts index 3b723e3..b08278c 100644 --- a/packages/profile-software-architecture/src/policy.test.ts +++ b/packages/profile-software-architecture/src/policy.test.ts @@ -76,4 +76,53 @@ describe("RepositoryPolicy v1 contract", () => { expect(result.success, JSON.stringify(result.error)).toBe(true); } }); + + describe("layers", () => { + const extraLayers = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, index) => [`layer-${String(index)}`, ["apps/**"]])); + const layeredRule = { ...validPolicy.rules[0], check: { kind: "forbid-import-edge", from: ["layer:ui", "packages/**"], deny: ["module:@prisma/client"] } }; + const layered = { + ...validPolicy, + layers: { ui: ["apps/control/src/app/**/page.tsx"], service: ["apps/control/src/server/**/*.ts"] }, + rules: [layeredRule], + }; + + it("accepts declared layers and keeps references unexpanded in the parsed document", () => { + expect(RepositoryPolicySchema.parse(layered)).toEqual(layered); + }); + + it("parses a policy without layers to the same object with no layers key", () => { + const parsed = RepositoryPolicySchema.parse(validPolicy); + expect(parsed).toEqual(validPolicy); + expect("layers" in parsed).toBe(false); + }); + + it.each([ + ["malformed reference", { ...layered, rules: [{ ...layeredRule, check: { ...layeredRule.check, from: ["layer:UI_Layer"] } }] }, "Layer reference is not a slug: layer:UI_Layer"], + ["reference in scope include", { ...layered, scope: { ...layered.scope, include: ["layer:ui"] } }, "Layer reference is not allowed in scope: layer:ui"], + ["reference in scope exclude", { ...layered, scope: { ...layered.scope, exclude: ["layer:ui"] } }, "Layer reference is not allowed in scope: layer:ui"], + ["undeclared reference", { ...layered, rules: [{ ...layeredRule, check: { ...layeredRule.check, from: ["layer:ghost"] } }] }, "Layer is not declared: ghost (rule layers-no-ui-db, field from)"], + ["reference without a layers block", { ...validPolicy, rules: layered.rules }, "Layer is not declared: ui (rule layers-no-ui-db, field from)"], + ["undeclared allowFrom reference", { ...layered, rules: [{ ...layeredRule, check: { kind: "restrict-property-write", files: ["layer:ui"], targetType: { file: "src/job.ts", exportName: "Job" }, property: "status", allowFrom: ["layer:data"] } }] }, "Layer is not declared: data (rule layers-no-ui-db, field allowFrom)"], + ])("rejects a %s with the exact message", (_label, value, message) => { + const result = RepositoryPolicySchema.safeParse(value); + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.message)).toEqual([message]); + }); + + it.each([ + ["a non-slug layer name", { ...layered, layers: { ...layered.layers, "Bad Name": ["apps/**"] } }], + ["a one-character layer name", { ...layered, layers: { ...layered.layers, a: ["apps/**"] } }], + ["an empty layer", { ...layered, layers: { ...layered.layers, empty: [] } }], + ["a layer containing a reference", { ...layered, layers: { ...layered.layers, nested: ["layer:ui"] } }], + ["51 layers", { ...layered, layers: { ...layered.layers, ...extraLayers(49) } }], + ])("rejects %s", (_label, value) => { + expect(RepositoryPolicySchema.safeParse(value).success).toBe(false); + }); + + it("accepts 50 layers", () => { + const layers = { ...layered.layers, ...extraLayers(48) }; + expect(Object.keys(layers)).toHaveLength(50); + expect(RepositoryPolicySchema.safeParse({ ...layered, layers }).success).toBe(true); + }); + }); }); diff --git a/packages/profile-software-architecture/src/policy.ts b/packages/profile-software-architecture/src/policy.ts index 8d4b541..9d7f593 100644 --- a/packages/profile-software-architecture/src/policy.ts +++ b/packages/profile-software-architecture/src/policy.ts @@ -2,10 +2,22 @@ import { z } from "zod"; import { NonemptyExactStringSchema, RelativeGlobSchema, SlugSchema, uniqueArray } from "@kernel-zero/contracts"; +import { LAYER_REFERENCE_PREFIX, isLayerReference, ruleFileLists } from "./layers"; + export const REPOSITORY_POLICY_MEDIA_TYPE = "application/vnd.kernel-zero.policy+json;version=1" as const; +// Scope lists hold globs only; rule lists may also hold `layer:` references, which the policy-level refinement checks. const GlobList = uniqueArray(RelativeGlobSchema, 1, 100); const OptionalGlobList = uniqueArray(RelativeGlobSchema, 0, 100); +const RuleGlobList = GlobList; +const OptionalRuleGlobList = OptionalGlobList; +export const LayerNameSchema = SlugSchema(2, 40); +const MAX_LAYERS = 50; +const LayerGlobSchema = RelativeGlobSchema.refine((value) => !isLayerReference(value), "Layer values must be globs, not layer references."); +const LayersSchema = z.record(LayerNameSchema, uniqueArray(LayerGlobSchema, 1, 100)).refine( + (layers) => Object.keys(layers).length <= MAX_LAYERS, + `At most ${String(MAX_LAYERS)} layers may be declared.`, +); const ExactList = uniqueArray(NonemptyExactStringSchema, 1, 100); const ModuleDenialSchema = z.string().min(6).max(500).refine( (value) => value.startsWith("module:") || value.startsWith("module-prefix:") || value.startsWith("path:"), @@ -23,15 +35,15 @@ export const TypeReferenceSchema = z.discriminatedUnion("kind", [ ]); export const PolicyCheckSchema = z.discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("forbid-import-edge"), from: GlobList, deny: uniqueArray(ModuleDenialSchema, 1, 100) }), - z.strictObject({ kind: z.literal("require-import"), files: GlobList, module: NonemptyExactStringSchema, allowTypeOnly: z.boolean().default(false) }), - z.strictObject({ kind: z.literal("restrict-call-site"), callee: ExactList, allowFrom: GlobList, requireResolution: z.boolean().default(true) }), - z.strictObject({ kind: z.literal("require-export-keys"), files: GlobList, exportName: NonemptyExactStringSchema, requiredKeys: ExactList }), - z.strictObject({ kind: z.literal("require-tenant-parameter"), files: GlobList, symbols: NonemptyExactStringSchema, parameter: NonemptyExactStringSchema.default("workspaceId") }), - z.strictObject({ kind: z.literal("require-boundary-parse"), files: GlobList, boundaryCalls: ExactList, parserCalls: ExactList }), + z.strictObject({ kind: z.literal("forbid-import-edge"), from: RuleGlobList, deny: uniqueArray(ModuleDenialSchema, 1, 100) }), + z.strictObject({ kind: z.literal("require-import"), files: RuleGlobList, module: NonemptyExactStringSchema, allowTypeOnly: z.boolean().default(false) }), + z.strictObject({ kind: z.literal("restrict-call-site"), callee: ExactList, allowFrom: RuleGlobList, requireResolution: z.boolean().default(true) }), + z.strictObject({ kind: z.literal("require-export-keys"), files: RuleGlobList, exportName: NonemptyExactStringSchema, requiredKeys: ExactList }), + z.strictObject({ kind: z.literal("require-tenant-parameter"), files: RuleGlobList, symbols: NonemptyExactStringSchema, parameter: NonemptyExactStringSchema.default("workspaceId") }), + z.strictObject({ kind: z.literal("require-boundary-parse"), files: RuleGlobList, boundaryCalls: ExactList, parserCalls: ExactList }), z.strictObject({ kind: z.literal("require-governed-operation"), - files: GlobList, + files: RuleGlobList, registryExport: NonemptyExactStringSchema, // ponytail: a registry may declare any non-empty subset of the closed key set; the kernel declares three since the SaaS shell left. requiredKeys: uniqueArray(z.enum(["capability", "tenantScope", "quota", "audit", "idempotency"]), 1, 5), @@ -39,7 +51,7 @@ export const PolicyCheckSchema = z.discriminatedUnion("kind", [ }), z.strictObject({ kind: z.literal("require-context-parameter"), - files: GlobList, + files: RuleGlobList, symbols: NonemptyExactStringSchema, parameter: NonemptyExactStringSchema, expectedType: TypeReferenceSchema.nullable().default(null), @@ -48,16 +60,16 @@ export const PolicyCheckSchema = z.discriminatedUnion("kind", [ kind: z.literal("require-closed-registry"), registryFile: RelativeTypeScriptFileSchema, registryExport: IdentifierSchema, - declarationFiles: GlobList, + declarationFiles: RuleGlobList, declarationCalls: ExactList, requiredKeys: uniqueArray(NonemptyExactStringSchema, 0, 100), }), z.strictObject({ kind: z.literal("restrict-property-write"), - files: GlobList, + files: RuleGlobList, targetType: z.strictObject({ file: RelativeTypeScriptFileSchema, exportName: IdentifierSchema }), property: IdentifierSchema, - allowFrom: OptionalGlobList, + allowFrom: OptionalRuleGlobList, }), ]); @@ -82,11 +94,36 @@ export const RepositoryPolicySchema = z.strictObject({ include: GlobList, exclude: OptionalGlobList, }), + // exactOptional: an absent key stays absent in the parsed document, so a layer-free policy keeps its digest and the type stays a JsonValue. + layers: LayersSchema.exactOptional(), rules: uniqueArray(PolicyRuleSchema, 1, 500).superRefine((rules, context) => { if (new Set(rules.map((rule) => rule.id)).size !== rules.length) { context.addIssue({ code: "custom", message: "Rule IDs must be unique." }); } }), +}).superRefine((policy, context) => { + for (const field of ["include", "exclude"] as const) { + for (const entry of policy.scope[field]) { + if (isLayerReference(entry)) { + context.addIssue({ code: "custom", message: `Layer reference is not allowed in scope: ${entry}`, path: ["scope", field] }); + } + } + } + const layers = policy.layers ?? {}; + policy.rules.forEach((rule, index) => { + for (const [field, entries] of ruleFileLists(rule.check)) { + for (const entry of entries) { + if (!isLayerReference(entry)) continue; + const name = entry.slice(LAYER_REFERENCE_PREFIX.length); + const path = ["rules", index, "check", field]; + if (!LayerNameSchema.safeParse(name).success) { + context.addIssue({ code: "custom", message: `Layer reference is not a slug: ${entry}`, path }); + } else if (!Object.hasOwn(layers, name)) { + context.addIssue({ code: "custom", message: `Layer is not declared: ${name} (rule ${rule.id}, field ${field})`, path }); + } + } + } + }); }); export type RepositoryPolicy = z.infer; diff --git a/packages/validator/README.md b/packages/validator/README.md index 1fd2772..4eeea87 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -35,6 +35,32 @@ protected operation. The JSON evidence at `--out` is authoritative; the command also prints one summary line and one deterministic line per finding (level, rule, path:line:column, code, subject) followed by the policy remediation. +## Layers + +A policy may name architectural layers once and reference them from rule +file lists with `layer:`; `scope` stays glob-only. The validator expands +each reference before evaluation, and the policy digest covers the document +as written, so a policy without `layers` keeps its digest. + +```json +{ + "layers": { + "ui": ["src/app/**/page.tsx", "src/app/**/layout.tsx"], + "persistence": ["src/persistence/**/*.ts"] + }, + "rules": [{ + "id": "ui-does-not-import-persistence", + "title": "UI routes use application services", + "level": "error", + "check": { "kind": "forbid-import-edge", "from": ["layer:ui"], "deny": ["module:@prisma/client"] }, + "remediation": "Move persistence access behind a server application service." + }] +} +``` + +A reference to an undeclared layer, a non-slug name, or a reference inside +`scope` fails policy parsing with exit `2`. + ## Explain and init ```text diff --git a/packages/validator/src/engine.test.ts b/packages/validator/src/engine.test.ts index 59b9a6e..455bc26 100644 --- a/packages/validator/src/engine.test.ts +++ b/packages/validator/src/engine.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import type { RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; import { describe, expect, it } from "vitest"; -import { createRepositoryProgram, evaluatePolicyChecks } from "./engine"; +import { RepositoryProgramError, createRepositoryProgram, evaluatePolicyChecks } from "./engine"; const fixtureRoot = fileURLToPath(new URL("../fixtures/checks/", import.meta.url)); const COMPILER_TEST_TIMEOUT_MS = 30_000; @@ -88,6 +88,18 @@ describe("validator compiler and parse-failure model", { timeout: COMPILER_TEST_ }); }); +describe("validator layer guard", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { + it("refuses a policy whose rule file lists still hold a layer reference", () => { + expect(() => evaluate({ + id: "unresolved-layer", + title: "Unresolved layer reference", + level: "error", + check: { kind: "require-import", files: ["layer:x"], module: "server-only", allowTypeOnly: false }, + remediation: "Resolve layers before evaluation.", + }, ["imports/required-value.ts"])).toThrow(new RepositoryProgramError("Policy layers must be resolved before evaluation.")); + }); +}); + describe("validator import checks", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { it("reports exact modules, resolved paths, and nonliteral imports deterministically", () => { const findings = evaluate({ diff --git a/packages/validator/src/engine.ts b/packages/validator/src/engine.ts index 88ab139..954f7bd 100644 --- a/packages/validator/src/engine.ts +++ b/packages/validator/src/engine.ts @@ -1,4 +1,4 @@ -import type { RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; +import { isLayerReference, ruleFileLists, type RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; import { evaluateBoundaryParses } from "./checks/boundary"; import { evaluateRestrictedCalls } from "./checks/calls"; @@ -18,12 +18,16 @@ import { type PolicyRule, type RawValidatorFinding, } from "./findings"; -import type { RepositoryProgram } from "./program"; +import { RepositoryProgramError, type RepositoryProgram } from "./program"; export type { RawFindingLocation, RawFindingMessageCode, RawValidatorFinding } from "./findings"; export { RepositoryProgramError, createRepositoryProgram, type CreateRepositoryProgramOptions, type RepositoryProgram } from "./program"; export function evaluatePolicyChecks(policy: RepositoryPolicy, repository: RepositoryProgram): RawValidatorFinding[] { + // Programming-error guard, not a finding: the runner resolves layers after the digest and before this call. + if (policy.rules.some((rule) => ruleFileLists(rule.check).some(([, globs]) => globs.some(isLayerReference)))) { + throw new RepositoryProgramError("Policy layers must be resolved before evaluation."); + } const findings: RawValidatorFinding[] = []; for (const rule of policy.rules) { diff --git a/packages/validator/src/golden.test.ts b/packages/validator/src/golden.test.ts index 6512fcb..b313a40 100644 --- a/packages/validator/src/golden.test.ts +++ b/packages/validator/src/golden.test.ts @@ -59,6 +59,10 @@ describe("validator golden baseline", { timeout: 60_000 }, () => { delete result.durationMs; await expectGolden("evidence.json", { ...run.evidence, result }); expect(run.outcome).toBe("error"); + // Pins the scanned file set: new fixtures live under fixtures/kinds/, outside the golden include. + const pinned = JSON.parse(await readFile(path.join(goldenDir, "evidence.json"), "utf8")) as unknown; + expect(run.evidence.result.filesScanned).toBe(12); + expect(pinned).toMatchObject({ result: { filesScanned: 12 }, subject: { manifestDigest: run.evidence.subject.manifestDigest } }); }); it("matches an exception grant only by exact rule id and frozen fingerprint", async () => { diff --git a/packages/validator/src/runner.test.ts b/packages/validator/src/runner.test.ts index 5b047c0..cc00a51 100644 --- a/packages/validator/src/runner.test.ts +++ b/packages/validator/src/runner.test.ts @@ -13,7 +13,7 @@ import { runValidation } from "./runner"; const WORKSPACE = "0195f000-0000-7000-8000-000000000002"; const VALIDATOR_TEST_TIMEOUT_MS = 30_000; -async function fixture(source: string) { +async function fixture(source: string, policyPatch: Record = {}) { const root = await mkdtemp(path.join(tmpdir(), "kernel-zero-runner-")); await mkdir(path.join(root, "src")); await writeFile(path.join(root, "src", "entry.ts"), source, "utf8"); @@ -26,6 +26,7 @@ async function fixture(source: string) { id: "no-raw-prisma", level: "error", remediation: "Use the repository boundary.", title: "No raw Prisma", }], scope: { exclude: [], include: ["src/**/*.ts"], languages: ["typescript"] }, + ...policyPatch, }; const policyPath = path.join(root, "policy.json"); const out = path.join(root, "evidence.json"); @@ -67,6 +68,29 @@ describe("validator runner", { timeout: VALIDATOR_TEST_TIMEOUT_MS }, () => { expect(JSON.parse(await readFile(paths.out, "utf8")) as unknown).toEqual(second.evidence); }); + it("resolves layer references after the digest and finds what the expanded twin finds", async () => { + const source = 'import { PrismaClient } from "@prisma/client";\nexport const value = PrismaClient;\n'; + const runtime = { generatedAt: new Date("2026-08-31T12:00:00.000Z"), runId: "0195f000-0000-7000-8000-000000000004" }; + const expanded = await runValidation({ command: "validate", ...(await fixture(source)), workspace: WORKSPACE }, runtime); + const layered = await runValidation({ command: "validate", ...(await fixture(source, { + layers: { source: ["src/**/*.ts"] }, + rules: [{ + check: { deny: ["module:@prisma/client"], from: ["layer:source"], kind: "forbid-import-edge" }, + id: "no-raw-prisma", level: "error", remediation: "Use the repository boundary.", title: "No raw Prisma", + }], + })), workspace: WORKSPACE }, runtime); + + const observable = (finding: (typeof expanded.evidence.findings)[number]) => ({ + level: finding.level, location: finding.location, messageCode: finding.messageCode, path: finding.path, ruleId: finding.ruleId, subject: finding.subject, + }); + expect(layered.outcome).toBe("violations"); + expect(layered.evidence.findings).toHaveLength(1); + expect(layered.evidence.findings.map(observable)).toEqual(expanded.evidence.findings.map(observable)); + // The digest covers the parsed document with its references, so the two documents differ and the returned policy is unexpanded. + expect(layered.evidence.policy.digest).not.toBe(expanded.evidence.policy.digest); + expect(layered.policy.rules[0]?.check).toMatchObject({ from: ["layer:source"] }); + }); + it("returns pass for a conforming repository and error for a claimed parse failure", async () => { const passing = await fixture("export const value = 1;\n"); const passed = await runValidation({ command: "validate", ...passing, workspace: WORKSPACE }); diff --git a/packages/validator/src/runner.ts b/packages/validator/src/runner.ts index fa4f0fb..438bf8f 100644 --- a/packages/validator/src/runner.ts +++ b/packages/validator/src/runner.ts @@ -17,6 +17,7 @@ import { RepositoryEvidenceSchema, RepositoryPolicySchema, findingMessage, + resolvePolicyLayers, type FindingMessageCode, type RepositoryEvidence, type RepositoryPolicy, @@ -57,7 +58,7 @@ export async function runValidation(command: ResolvedValidateCommand, options: V root: command.root, }); const repository = createRepositoryProgram({ rootPath: discovery.root, filePaths: discovery.files.map((file) => file.path) }); - const rawFindings = evaluatePolicyChecks(policy, repository); + const rawFindings = evaluatePolicyChecks(resolvePolicyLayers(policy), repository); const grants = new Map((exceptionBundle?.grants ?? []).map((grant) => [`${grant.ruleId}\0${grant.fingerprint}`, grant.exceptionId])); const findings = sortFindings(rawFindings.map((finding): EvidenceFinding => { const messageCode = publicMessageCode(finding.messageCode); diff --git a/scripts/generate-contracts.ts b/scripts/generate-contracts.ts index d7a9d20..3cef29f 100644 --- a/scripts/generate-contracts.ts +++ b/scripts/generate-contracts.ts @@ -166,6 +166,8 @@ const outputs = new Map([ ["docs/contracts/malformed/repository-evidence-source-content.json", `${JSON.stringify({ ...evidenceExample, source: "private source text" }, null, 2)}\n`], ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, and \`restrict-property-write\`. +\`layers\` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (\`from\`, \`files\`, \`allowFrom\`, \`declarationFiles\`) may carry \`layer:\` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. \`scope.include\` and \`scope.exclude\` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside \`scope\`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without \`layers\` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. + \`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], ]); From 66693f4a32c96ab72db207090ccedea35287a9b4 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 11:51:03 -0700 Subject: [PATCH 3/7] Add the require-call-argument check kind and prove tenant queries carry the workspace --- docs/adr/2026-09-09-require-call-argument.md | 242 ++++++++++++++++++ docs/contracts/README.md | 4 +- .../repository-evidence-v1.schema.json | 2 + .../repository-policy-v1.schema.json | 49 ++++ ...-09-validator-rule-language-advisor-log.md | 3 + ...09-09-validator-rule-language-run-state.md | 12 +- docs/validator-and-hooks.md | 26 ++ kernel-zero.policy.json | 24 ++ .../src/compatibility.test.ts | 25 ++ .../src/compatibility.ts | 12 +- .../src/evidence.test.ts | 14 + .../src/evidence.ts | 4 + .../src/policy.test.ts | 22 +- .../src/policy.ts | 12 + packages/validator/README.md | 26 ++ .../fixtures/kinds/call-argument/queries.ts | 21 ++ .../src/checks/argument-shape.test.ts | 205 +++++++++++++++ .../validator/src/checks/argument-shape.ts | 225 ++++++++++++++++ .../src/checks/call-argument.test.ts | 62 +++++ .../validator/src/checks/call-argument.ts | 47 ++++ packages/validator/src/engine.ts | 5 + packages/validator/src/findings.ts | 5 +- packages/validator/src/runner.ts | 3 + scripts/benchmark-validator.ts | 6 + scripts/generate-contracts.ts | 4 +- 25 files changed, 1051 insertions(+), 9 deletions(-) create mode 100644 docs/adr/2026-09-09-require-call-argument.md create mode 100644 packages/validator/fixtures/kinds/call-argument/queries.ts create mode 100644 packages/validator/src/checks/argument-shape.test.ts create mode 100644 packages/validator/src/checks/argument-shape.ts create mode 100644 packages/validator/src/checks/call-argument.test.ts create mode 100644 packages/validator/src/checks/call-argument.ts diff --git a/docs/adr/2026-09-09-require-call-argument.md b/docs/adr/2026-09-09-require-call-argument.md new file mode 100644 index 0000000..d064022 --- /dev/null +++ b/docs/adr/2026-09-09-require-call-argument.md @@ -0,0 +1,242 @@ +# Argument-shape prover and `require-call-argument` + +## Context + +PRP sentence 3 (`docs/prp/2026-09-09-validator-rule-language.md`): every +tenant query carries the tenant identifier. Today the self-policy proves only +that `tenantSelector` takes a `workspaceId` parameter +(`tenant-selector-requires-workspace`); nothing proves that each Prisma +selector in `packages/persistence` actually spells `where.workspaceId`. +Deleting one `workspaceId` from a `findFirst` selector passed every gate. + +The validator already resolves callee chains for `restrict-call-site` +(`resolveCalleeName` in `packages/validator/src/ast.ts`) and proves object +shapes for closed registries (`packages/validator/src/static-registry.ts`), +but neither proves a dotted path inside a call argument, and the registry +prover treats every spread as opaque, which would reject the conditional +spreads `evidence.ts` uses to build `where` (`{ workspaceId, ...(x === undefined ? {} : { x }) }`). + +This is `kz-policy-rule` branch B (new check kind) plus one branch-A rule in the +self-policy. Spec: `docs/plans/2026-09-09-validator-rule-language-spec.md` +sections 2, 3, 6, 7. + +## Decision + +Add one shared prover and one check kind; the kernel packages are untouched. + +- `packages/validator/src/checks/argument-shape.ts` (new) exports + `proveObjectPath`, `resolveCallChain`, `chainMatches`, and + `couldMatchCallee`. It never emits findings. `proveObjectPath` returns + `present` (with the string-literal value when there is one), `missing`, or + `unprovable` with reason `not-literal`, `spread`, `computed`, or `cycle`. It + unwraps parentheses, `as`, `satisfies`, non-null, and an exact + `Object.freeze()`; follows an identifier only to a same-file `const` + with exactly one declaration and an initializer; and treats a spread after + the searched key as harmless only when its operand is an object literal, or + a conditional whose branches are both object literals, whose own top-level + members (property assignments, shorthand, methods, getters, setters) carry + no spread, no computed key, and no member with the searched name. Spreads + before the key are always harmless because the later literal key wins. A + conditional in the key's own position is `not-literal` + (`// ponytail:` branch-wise proof is the upgrade). +- `resolveCallChain` reuses `resolveCalleeName` for the chain and judges + `unsafeReceiver` on the raw innermost receiver expression, descending + property and element accesses without unwrapping, so `(db as any).policy.findMany` + reports an unsafe receiver even though its chain is `db.policy.findMany`. + An optional root (`db?.policy`) has `undefined` in its type and is unsafe by + design. `couldMatchCallee` fires only for a property-access callee whose + root identifier and accessed name both agree with a glob's first and last + segments, unlike the root-only test in `checks/calls.ts`, so `*.findMany` + does not name every unresolvable call. +- Schema (`packages/profile-software-architecture/src/policy.ts`): member + `require-call-argument` with `files: RuleGlobList`, + `callee: uniqueArray(CalleeGlobSchema, 1, 100)`, + `argument: int 0..9 default 0`, `requiredPath: DottedPathSchema`, + `allowFrom: OptionalRuleGlobList default []`. `CalleeGlobSchema` is + `^[A-Za-z_$*][\w$*]*(?:\.[A-Za-z_$*][\w$*]*)*$`; `DottedPathSchema` is 1 to 8 + identifier segments. Both are exported so compatibility parses the subject + with the same grammar. +- Evaluator (`packages/validator/src/checks/call-argument.ts`): for every call + in `files` minus `allowFrom`, skipping `failedPaths`: unresolved chain at + `error` level with a `couldMatchCallee` glob → raw `CALL_ARGUMENT_UNRESOLVED` + with the glob as the chain; chain not matching → skip; unsafe receiver → raw + `CALL_ARGUMENT_UNPROVABLE`; otherwise `proveObjectPath` on + `arguments[argument]`: `missing` → raw `CALL_ARGUMENT_MISSING`, + `unprovable` → raw `CALL_ARGUMENT_UNPROVABLE`. Subject + `call::argument::`, location the call + expression. `engine.ts` dispatches the kind and `ruleClaimsPath` claims + `files`. `runner.ts` maps `CALL_ARGUMENT_MISSING` to itself and both + `UNPROVABLE` and `UNRESOLVED` to the public `CALL_ARGUMENT_PROOF_FAILED`; + `CALL_RESOLUTION_FAILED` stays bound to `restrict-call-site`. +- Compatibility: `messageCodesByKind["require-call-argument"]` is + `["CALL_ARGUMENT_MISSING", "CALL_ARGUMENT_PROOF_FAILED"]`; a subject is + compatible when it is `call:` + a chain that parses as a callee glob and + matches one of `callee` (a glob itself matches its own pattern, which is how + the unresolved case is accepted) + `:argument:` + the configured index + `:` + + `requiredPath`. +- Fixture `packages/validator/fixtures/kinds/call-argument/queries.ts` + (under `fixtures/kinds`, outside the golden include and the self-policy + scope) with the thirteen cases spec section 3 enumerates; + `checks/call-argument.test.ts` pins the exact finding list with subject and + location in engine order. The spec's prose says "twelve"; its list has + thirteen entries and all thirteen are covered. +- Self-policy rule `tenant-queries-carry-workspace`: `files: ["layer:persistence"]`, + `callee: ["*.findFirst", "*.findMany", "*.updateMany", "*.deleteMany", "*.count"]`, + `requiredPath: "where.workspaceId"`, `allowFrom: ["packages/persistence/src/audit.ts"]` + (audit rows are keyed by `workspaceOpaqueId`). No persistence file needed + an edit: `evidence.ts`'s conditional spreads are provable by the harmless + rule above, and every other selector spells `workspaceId` literally. +- Benchmark policy gains one `require-call-argument` rule + (`callee: ["*.findMany"]`); the corpus has no calls, so it produces no + findings and measures only the walk. + +## Invariants touched + +1. Definite policy violations fail closed. A provable selector without the + path is `CALL_ARGUMENT_MISSING`; anything the prover cannot see through + (opaque spread, computed key, non-literal value, `any` receiver, + unresolvable callee that could match) is `CALL_ARGUMENT_PROOF_FAILED`, + never a pass. Proof: `argument-shape.test.ts` (every `PathProof` branch), + `call-argument.test.ts` (exact matrix), the bite proof below. +3. Every tenant selector carries `workspaceId`. Now proven at every + `findFirst`/`findMany`/`updateMany`/`deleteMany`/`count` call in + `layer:persistence`, not only at `tenantSelector`'s signature. Proof: the + bite proof below. +10. Local validation is deterministic and network-free. The prover is a pure + function of the program; `npm run validator:self` twice produced the same + `integrity.digest` (quoted below). +11. Public wire formats are versioned and digests are over document content. + See Contract impact and the schema-default note. +12. Kernel packages never import a profile. Unchanged: + `git diff --stat -- packages/domain packages/contracts packages/persistence` + is empty after the bite restore. + +Invariants 2, 4, 5, 6, 7, 8, 9 are unaffected: no control-plane change, no +governed action, no new ingress, no UI. + +## Contract impact + +Policy `kernel-zero.dev/v1`: additive. One new discriminated-union member; +every stored policy parses unchanged and keeps its digest. Existing rule +fields, subjects, and fingerprints are untouched. + +Evidence `kernel-zero.dev/evidence/v1`: additive. The closed `messageCode` +enum grows from fifteen to seventeen public codes with +`CALL_ARGUMENT_MISSING` ("A call to a governed operation omits a required +argument field.") and `CALL_ARGUMENT_PROOF_FAILED` ("A call to a governed +operation could not be proven to carry a required argument field."). The PRP +section 6 seven-code list for this feature now has two of its seven delivered; +existing codes, strings, and fingerprints are untouched, so every stored run +still re-validates. Exception bundles: unchanged. + +Generated contracts regenerated: `docs/contracts/repository-policy-v1.schema.json`, +`docs/contracts/repository-evidence-v1.schema.json`, and one paragraph in +`docs/contracts/README.md`; `npm run contracts:check` exit 0. + +Golden fixture `packages/validator/fixtures/golden/**`: byte-identical +(`git diff --stat -- packages/validator/fixtures/golden` empty). + +Self-policy digests (the document changed, so both moved): + +| | before (policy-layers ADR) | after | +| --- | --- | --- | +| `policy.digest` | `sha256:b9191f8b8f71e78ab39d221b44a8af37635bab55c8999aedb879a99e35c424d9` | `sha256:2453c919f39582e2c9bd8c4a0cabd62853b1f7473cf67bd691f2004c46bda5a1` | +| `integrity.digest` | `sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824` | `sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177` | + +The integrity digest also reflects the manifest gaining two source files +(`checks/argument-shape.ts`, `checks/call-argument.ts`; 92 to 94 files). + +## Schema-default digest note + +`argument` defaults to `0` and `allowFrom` to `[]`. The policy digest is +`canonicalSha256` of the schema-parsed document, so a stored policy that +declares this kind and omits either field would parse to a document carrying +both keys and its digest would move (CLAUDE.md rule 5). No stored policy can +contain this kind yet, because the kind did not exist before this change, so +nothing existing moves. The self-policy spells both fields explicitly. + +## findUnique exclusion + +`findUnique` is deliberately not in the callee list. Its selectors are +compound-unique objects (`where: { workspaceId_runId: { runId, workspaceId } }` +in `evidence.ts`), which put the tenant key inside the unique-key object +rather than at `where.workspaceId`, and the composite unique index already +scopes the lookup to one workspace. Listing it would produce a false +`CALL_ARGUMENT_MISSING` on every correct call; proving the nested path would +need a per-model key name the policy cannot know. + +## Bite proof + +Deleted `workspaceId: input.workspaceId` from the `findFirst` selector in +`requestException` (`packages/persistence/src/exceptions.ts` line 24), ran +`npm run validator:self`, restored with `git checkout -- packages/persistence/src/exceptions.ts`, +ran again. + +```text +kernel-zero: fail (1 errors, 0 warnings, 0 excepted, 94 files) +error tenant-queries-carry-workspace packages/persistence/src/exceptions.ts:24:28 CALL_ARGUMENT_MISSING call:tx.policyRevision.findFirst:argument:0:where.workspaceId + remediation: Put workspaceId in the where selector of every tenant-scoped query so a sibling tenant's rows are never reachable. +bite exit 1 + +(after git checkout -- packages/persistence/src/exceptions.ts) +kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 94 files) +exit 0 +git diff --stat -- packages/domain packages/contracts packages/persistence (empty) +``` + +Working-tree note: `git checkout --` rewrote the file with LF per +`.gitattributes` (`eol=lf`) while this checkout held a CRLF copy +(`core.autocrlf=true`), which moved the manifest digest with zero content +change (`git diff` empty in both states). The file is left at LF, the +git-clean state; the two determinism runs quoted below were taken on that +final tree, after every edit in this step. + +## FR-IDs + +FR-ARG-001 (schema with `CalleeGlob` and `DottedPath`, defaults 0 and `[]`), +FR-ARG-002 (literal, frozen, or same-file-const argument; non-computed, +non-spread segments; leaf other than `undefined`), FR-ARG-003 (codes and the +`call::argument::` subject; unresolvable callees at `error` +level map to `CALL_ARGUMENT_PROOF_FAILED` through raw +`CALL_ARGUMENT_UNRESOLVED` rather than reusing `CALL_RESOLUTION_FAILED`, as +spec section 3 fixes), FR-ARG-004 (self-policy rule with `audit.ts` as the +only `allowFrom`, proven to bite). + +## Verification command + +Node 22 (`export PATH="/c/Users/Guerr/AppData/Roaming/fnm/node-versions/v22.22.3/installation:$PATH"`). + +```text +npx vitest run packages/profile-software-architecture + Test Files 6 passed (6) + Tests 106 passed (106) + +npm run test:validator + Test Files 11 passed (11) + Tests 88 passed (88) + +npm run validator:self (run 1) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 94 files) + integrity.digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 +npm run validator:self (run 2) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 94 files) + integrity.digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 + +npm run benchmark:validator + {"deterministic":true,"files":5000,"firstMs":7257,"limitMs":30000,"limitRssBytes":1073741824,"peakObservedRssBytes":479674368,"secondMs":5267} + +npm run contracts:generate && npm run contracts:check exit 0 +git diff --stat -- packages/validator/fixtures/golden (empty) +git diff --stat -- packages/domain packages/contracts packages/persistence (empty) +npm run typecheck exit 0 +npm run lint exit 0 +``` + +`npm run verify` on this tree: exit 0. + +```text +kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 94 files) +unit Test Files 48 passed (48) Tests 324 passed (324) +architecture Test Files 1 passed (1) Tests 1 passed (1) +integration Test Files 3 passed (3) Tests 7 passed (7) +``` diff --git a/docs/contracts/README.md b/docs/contracts/README.md index dab117d..bc6851a 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -8,7 +8,7 @@ Media type: `application/vnd.kernel-zero.policy+json;version=1` A strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256. -The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, and `restrict-property-write`. +The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`, and `require-call-argument`. `layers` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (`from`, `files`, `allowFrom`, `declarationFiles`) may carry `layer:` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. `scope.include` and `scope.exclude` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside `scope`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without `layers` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. @@ -16,6 +16,8 @@ The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-cal `require-closed-registry` proves that one exported registry object (`registryFile` plus `registryExport`, a direct or exact `Object.freeze` object literal) is the only source of declarations made through `declarationCalls` inside `declarationFiles`. Entry IDs are direct identifier or string keys matching `[A-Za-z0-9][A-Za-z0-9._/-]{0,119}`; each entry is a plain object literal carrying every `requiredKeys` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: `CLOSED_REGISTRY_ENTRY_INVALID` (subject `registry::entry::`, where key is a required key or `id`), `UNREGISTERED_DECLARATION` (subject `registry::declaration:`), and `CLOSED_REGISTRY_PROOF_FAILED` (subject `registry::proof:`, `registry::proof:registry`, or `registry::proof:declaration`). The marker `` stands for an ID that cannot be rendered safely. +`require-call-argument` proves that every call in `files` (minus `allowFrom`) whose resolved callee chain matches one of `callee` carries `requiredPath` in the argument at index `argument` (0 to 9, default 0). A callee glob is dotted identifier segments where `*` spans dots (`*.findMany`, `prisma.*.updateMany`); `requiredPath` is 1 to 8 identifier segments (`where.workspaceId`). The argument is proven when it is an object literal, an `Object.freeze` of one, or an identifier bound by a same-file `const` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal `undefined` or `void 0`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: `CALL_ARGUMENT_MISSING` when the argument is provable and the path is absent or `undefined`, and `CALL_ARGUMENT_PROOF_FAILED` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed `any`, `unknown`, or possibly `undefined`, or (at `error` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject `call::argument::`, where `` is the resolved callee chain or, for an unresolved callee, the matching glob itself. + `restrict-property-write` proves that one property of one exported type (`targetType` as `{ file, exportName }` plus `property`) is written only from `allowFrom` files; an empty `allowFrom` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and `delete`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. `Object.assign`, `Reflect.set`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: `PROPERTY_WRITE_DENIED` and `PROPERTY_WRITE_PROOF_FAILED`, both with subject `property:#.`. ## RepositoryEvidence v1 diff --git a/docs/contracts/repository-evidence-v1.schema.json b/docs/contracts/repository-evidence-v1.schema.json index 9396752..d8c32b9 100644 --- a/docs/contracts/repository-evidence-v1.schema.json +++ b/docs/contracts/repository-evidence-v1.schema.json @@ -143,6 +143,8 @@ "type": "string", "enum": [ "BOUNDARY_PARSE_REQUIRED", + "CALL_ARGUMENT_MISSING", + "CALL_ARGUMENT_PROOF_FAILED", "CLOSED_REGISTRY_ENTRY_INVALID", "CLOSED_REGISTRY_PROOF_FAILED", "CONTEXT_PARAMETER_INVALID", diff --git a/docs/contracts/repository-policy-v1.schema.json b/docs/contracts/repository-policy-v1.schema.json index b2cdd2a..19bb048 100644 --- a/docs/contracts/repository-policy-v1.schema.json +++ b/docs/contracts/repository-policy-v1.schema.json @@ -522,6 +522,49 @@ "allowFrom" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "require-call-argument" + }, + "files": { + "$ref": "#/$defs/__schema5" + }, + "callee": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema20" + } + }, + "argument": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 9 + }, + "requiredPath": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*){0,7}$" + }, + "allowFrom": { + "default": [], + "$ref": "#/$defs/__schema7" + } + }, + "required": [ + "kind", + "files", + "callee", + "requiredPath" + ], + "additionalProperties": false } ] }, @@ -576,6 +619,12 @@ "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z_$][\\w$]*$" + }, + "__schema20": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z_$*][\\w$*]*(?:\\.[A-Za-z_$*][\\w$*]*)*$" } } } diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md index 631f349..48e9216 100644 --- a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -53,3 +53,6 @@ Session `autonomous-validator-rule-language-2026-09-09`. Every decision and ever ### Verdict 7: kz-checker on step 1 (named layers) **Verdict:** PASS; all five implementer deviations judged acceptable (exactOptional for the digest type, layer values reject nested references, message carries rule id and field, shared ruleFileLists export, expander throws on an unseen reference). + +### Verdict 8: kz-checker on step 2 (require-call-argument) +**Verdict:** PASS; both bites reproduced; three deviations acceptable; two follow-ups filed in the run-state (F1 digest EOL portability, F2 bare-identifier alias ceiling). diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index 972fa32..055b232 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -6,15 +6,15 @@ - **Branch:** feat/validator-rule-language (off main at 618b4cc) - **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope - **Current phase:** 3-implement -- **In-flight:** step 2 (argument prover, require-call-argument), implementer dispatched; tasks 2.1-2.6 -- **Next action:** Phase 3 step 2 — implementer returns, run kz-checker, commit; then step 3 +- **In-flight:** step 3 (restrict-state-transition), implementer dispatched; tasks 3.1-3.5 +- **Next action:** Phase 3 step 3 — implementer returns, run kz-checker, commit; then step 4 (owner checkpoint before the route edit) ## Phase Gates | Phase | Gate | Result | Evidence (command + exit / artifact path) | |-------|------|--------|-------------------------------------------| | 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | | 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | -| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md | +| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md | | 4-branch | PR URL recorded | pending | | | 5-optimize | optimization loop termination | pending | | @@ -24,3 +24,9 @@ ## Decisions Full log: docs/plans/2026-09-09-validator-rule-language-advisor-log.md + +## Follow-ups filed (not absorbed, law 6) +| # | Found in | Item | +|---|---|---| +| F1 | step 2 check | `manifestDigest` hashes raw working-tree bytes; 26 in-scope files are CRLF in this checkout (`core.autocrlf=true`), so the self-policy integrity digest is deterministic here but differs on a fresh LF clone. Pre-existing. Options: normalize EOL in the manifest digest input (contract change, needs kz-grill) or `git add --renormalize` once. Owner decision. | +| F2 | step 2 check | A bare-identifier alias to a matching callee (`const q = tx.policyRevision.findFirst; q({...})`) resolves to chain `q` and passes `require-call-argument` silently; inherited from `resolveCalleeName`, shared with `restrict-call-site`, pinned by a test. Follow-up: alias-following for bare identifiers, or at least `UNRESOLVED` when the const initializer is a matching chain. Changing it moves golden fixtures, so it is its own ADR. | diff --git a/docs/validator-and-hooks.md b/docs/validator-and-hooks.md index 4e9156b..e6fb9f0 100644 --- a/docs/validator-and-hooks.md +++ b/docs/validator-and-hooks.md @@ -41,6 +41,32 @@ is computed and before evaluation, so subjects, fingerprints, and the digest of a layer-free policy are unchanged. An undeclared or malformed reference, or one placed in `scope`, is a policy contract failure (exit `2`). +## Required call arguments + +The self-policy rule `tenant-queries-carry-workspace` uses +`require-call-argument` to prove that every `*.findFirst`, `*.findMany`, +`*.updateMany`, `*.deleteMany`, and `*.count` call in `layer:persistence` +carries `where.workspaceId` in its first argument; `audit.ts` is the only +`allowFrom` file because audit rows are keyed by `workspaceOpaqueId`. + +```json +"check": { + "kind": "require-call-argument", + "files": ["layer:persistence"], + "callee": ["*.findFirst", "*.findMany", "*.updateMany", "*.deleteMany", "*.count"], + "argument": 0, + "requiredPath": "where.workspaceId", + "allowFrom": ["packages/persistence/src/audit.ts"] +} +``` + +Deleting one `workspaceId` from a selector fails the gate with +`CALL_ARGUMENT_MISSING` at the call; a selector the validator cannot prove +(an opaque spread, a value built elsewhere, a receiver typed `any`) fails with +`CALL_ARGUMENT_PROOF_FAILED`. `findUnique` is not listed: compound-unique +selectors carry the tenant key inside the unique-key object, which the +composite index already scopes. + ## Agent-facing commands `kernel-zero explain --policy|--evidence ` renders one strictly parsed diff --git a/kernel-zero.policy.json b/kernel-zero.policy.json index 52beece..fbd7912 100644 --- a/kernel-zero.policy.json +++ b/kernel-zero.policy.json @@ -199,6 +199,30 @@ ] }, "remediation": "Keep the validator on contracts, domain, and its profile." + }, + { + "id": "tenant-queries-carry-workspace", + "title": "Tenant queries carry the workspace identifier", + "level": "error", + "check": { + "kind": "require-call-argument", + "files": [ + "layer:persistence" + ], + "callee": [ + "*.findFirst", + "*.findMany", + "*.updateMany", + "*.deleteMany", + "*.count" + ], + "argument": 0, + "requiredPath": "where.workspaceId", + "allowFrom": [ + "packages/persistence/src/audit.ts" + ] + }, + "remediation": "Put workspaceId in the where selector of every tenant-scoped query so a sibling tenant's rows are never reachable." } ] } diff --git a/packages/profile-software-architecture/src/compatibility.test.ts b/packages/profile-software-architecture/src/compatibility.test.ts index 579b443..5750922 100644 --- a/packages/profile-software-architecture/src/compatibility.test.ts +++ b/packages/profile-software-architecture/src/compatibility.test.ts @@ -9,6 +9,8 @@ const REG = { declarationCalls: ["defineTool"], declarationFiles: ["src/tools/** const PW = { allowFrom: ["src/dataplane/state/**"], files: ["src/**/*.ts"], kind: "restrict-property-write", property: "status", targetType: { exportName: "Job", file: "src/domain/job.ts" } }; +const CA = { allowFrom: ["src/audit.ts"], argument: 0, callee: ["*.findFirst", "db.policy.*"], files: ["src/**/*.ts"], kind: "require-call-argument", requiredPath: "where.workspaceId" }; + const checkCases: readonly Readonly<{ check: Record; code: FindingMessageCode; @@ -31,6 +33,10 @@ const checkCases: readonly Readonly<{ { check: REG, code: "CLOSED_REGISTRY_PROOF_FAILED", subject: "registry:TOOL_POLICY:proof:search" }, { check: PW, code: "PROPERTY_WRITE_DENIED", subject: "property:src/domain/job.ts#Job.status" }, { check: PW, code: "PROPERTY_WRITE_PROOF_FAILED", subject: "property:src/domain/job.ts#Job.status" }, + { check: CA, code: "CALL_ARGUMENT_MISSING", subject: "call:tx.policyRevision.findFirst:argument:0:where.workspaceId" }, + { check: CA, code: "CALL_ARGUMENT_PROOF_FAILED", subject: "call:db.policy.findMany:argument:0:where.workspaceId" }, + { check: CA, code: "CALL_ARGUMENT_PROOF_FAILED", subject: "call:db.policy.*:argument:0:where.workspaceId" }, + { check: CA, code: "CALL_ARGUMENT_PROOF_FAILED", subject: "call:*.findFirst:argument:0:where.workspaceId" }, ]; function policy(check: Record) { @@ -98,6 +104,25 @@ describe("evidence rule compatibility", () => { expect(findingCompatibilityReason(policy(PW), finding("PROPERTY_WRITE_PROOF_FAILED", "property:src/domain/job.ts#Task.status"))).toBe("rule_subject_mismatch"); }); + it("rejects call-argument subjects with another index, path, or a chain outside the callee globs", () => { + expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:1:where.workspaceId"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:0:where.tenantId"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_MISSING", "call:db.other.findMany:argument:0:where.workspaceId"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_PROOF_FAILED", "call:db.policy.findMany/x:argument:0:where.workspaceId"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_PROOF_FAILED", "call::argument:0:where.workspaceId"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(CA), finding("RESTRICTED_CALL", "db.policy.findMany"))).toBe("rule_code_mismatch"); + expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:0:where.workspaceId"))).toBeNull(); + const layered = RepositoryPolicySchema.parse({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + layers: { persistence: ["src/persistence/**/*.ts"] }, + metadata: { description: "Policy", name: "policy", revision: 1 }, + rules: [{ check: { ...CA, allowFrom: [], files: ["layer:persistence"] }, id: "test-rule", level: "error", remediation: "Fix it.", title: "Test" }], + scope: { exclude: [], include: ["**/*.ts"], languages: ["typescript"] }, + }); + expect(findingCompatibilityReason(layered, finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:0:where.workspaceId"))).toBeNull(); + }); + it("resolves layer references before matching so a layered rule accepts its finding", () => { const layered = RepositoryPolicySchema.parse({ apiVersion: "kernel-zero.dev/v1", diff --git a/packages/profile-software-architecture/src/compatibility.ts b/packages/profile-software-architecture/src/compatibility.ts index ec6b2b1..72e939d 100644 --- a/packages/profile-software-architecture/src/compatibility.ts +++ b/packages/profile-software-architecture/src/compatibility.ts @@ -1,11 +1,12 @@ import type { EvidenceFinding } from "@kernel-zero/contracts"; import { resolvePolicyLayers } from "./layers"; -import type { RepositoryPolicy } from "./policy"; +import { CalleeGlobSchema, type RepositoryPolicy } from "./policy"; const messageCodesByKind: Readonly> = Object.freeze({ "forbid-import-edge": ["DENIED_IMPORT"], "require-boundary-parse": ["BOUNDARY_PARSE_REQUIRED"], + "require-call-argument": ["CALL_ARGUMENT_MISSING", "CALL_ARGUMENT_PROOF_FAILED"], "require-closed-registry": ["CLOSED_REGISTRY_ENTRY_INVALID", "UNREGISTERED_DECLARATION", "CLOSED_REGISTRY_PROOF_FAILED"], "require-context-parameter": ["CONTEXT_PARAMETER_INVALID", "CONTEXT_PARAMETER_PROOF_FAILED"], "require-export-keys": ["REQUIRED_EXPORT_KEY_MISSING"], @@ -76,6 +77,15 @@ export function findingCompatibilityReason(unresolvedPolicy: RepositoryPolicy, f ? null : "rule_subject_mismatch"; } + case "require-call-argument": { + const check = rule.check; + const body = stripPrefix(finding.subject, "call:"); + const suffix = `:argument:${String(check.argument)}:${check.requiredPath}`; + if (!body?.endsWith(suffix)) return "rule_subject_mismatch"; + // The unresolved case reports the glob itself, so a chain may carry `*` segments. + const chain = body.slice(0, -suffix.length); + return CalleeGlobSchema.safeParse(chain).success && globMatches(chain, check.callee) ? null : "rule_subject_mismatch"; + } } } diff --git a/packages/profile-software-architecture/src/evidence.test.ts b/packages/profile-software-architecture/src/evidence.test.ts index be24726..81c5d83 100644 --- a/packages/profile-software-architecture/src/evidence.test.ts +++ b/packages/profile-software-architecture/src/evidence.test.ts @@ -86,6 +86,20 @@ describe("repository evidence contract", () => { expect(RepositoryEvidenceSchema.safeParse({ ...original, findings: [{ ...originalFinding, message: "arbitrary" }] }).success).toBe(false); }); + it("publishes the call-argument codes with their exact messages and accepts them in findings", () => { + expect(findingMessage("CALL_ARGUMENT_MISSING")).toBe("A call to a governed operation omits a required argument field."); + expect(findingMessage("CALL_ARGUMENT_PROOF_FAILED")).toBe("A call to a governed operation could not be proven to carry a required argument field."); + const original = validEvidence(); + const finding = onlyFinding(original); + for (const messageCode of ["CALL_ARGUMENT_MISSING", "CALL_ARGUMENT_PROOF_FAILED"] as const) { + const subject = "call:tx.policyRevision.findFirst:argument:0:where.workspaceId"; + const identity = findingIdentity({ location, messageCode, path: finding.path, policyDigest: original.policy.digest, ruleId: finding.ruleId, subject }); + const rewritten = { ...finding, ...identity, message: findingMessage(messageCode), messageCode, subject }; + const base = { ...original, findings: [rewritten] }; + expect(RepositoryEvidenceSchema.safeParse({ ...base, integrity: { algorithm: "sha256", digest: canonicalEvidenceDigest(base) } }).success).toBe(true); + } + }); + it("excludes run metadata and diagnostic duration from integrity", () => { const original = validEvidence(); expect(canonicalEvidenceDigest({ ...original, runId: "0195f000-0000-7000-8000-000000000099" })).toBe(original.integrity.digest); diff --git a/packages/profile-software-architecture/src/evidence.ts b/packages/profile-software-architecture/src/evidence.ts index aab32c7..7d6eba1 100644 --- a/packages/profile-software-architecture/src/evidence.ts +++ b/packages/profile-software-architecture/src/evidence.ts @@ -3,6 +3,8 @@ import { z } from "zod"; export const FINDING_MESSAGE_CODES = Object.freeze([ "BOUNDARY_PARSE_REQUIRED", + "CALL_ARGUMENT_MISSING", + "CALL_ARGUMENT_PROOF_FAILED", "CLOSED_REGISTRY_ENTRY_INVALID", "CLOSED_REGISTRY_PROOF_FAILED", "CONTEXT_PARAMETER_INVALID", @@ -23,6 +25,8 @@ export type FindingMessageCode = (typeof FINDING_MESSAGE_CODES)[number]; const messages: Readonly> & EvidenceMessages = Object.freeze({ BOUNDARY_PARSE_REQUIRED: "A public boundary value is not proven to be parsed.", + CALL_ARGUMENT_MISSING: "A call to a governed operation omits a required argument field.", + CALL_ARGUMENT_PROOF_FAILED: "A call to a governed operation could not be proven to carry a required argument field.", CLOSED_REGISTRY_ENTRY_INVALID: "A closed registry entry is missing required metadata or has an invalid identifier.", CLOSED_REGISTRY_PROOF_FAILED: "The closed registry invariant could not be proven from static declarations.", CONTEXT_PARAMETER_INVALID: "A required context parameter is missing or has a disallowed declaration.", diff --git a/packages/profile-software-architecture/src/policy.test.ts b/packages/profile-software-architecture/src/policy.test.ts index b08278c..832277e 100644 --- a/packages/profile-software-architecture/src/policy.test.ts +++ b/packages/profile-software-architecture/src/policy.test.ts @@ -48,11 +48,21 @@ describe("RepositoryPolicy v1 contract", () => { ["property write dotted property", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-property-write", files: ["src/**"], targetType: { file: "src/job.ts", exportName: "Job" }, property: "a.b", allowFrom: [] } }] }], ["property write missing allowFrom", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-property-write", files: ["src/**"], targetType: { file: "src/job.ts", exportName: "Job" }, property: "status" } }] }], ["context intrinsic outside the closed set", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-context-parameter", files: ["apps/**"], symbols: "*", parameter: "ctx", expectedType: { kind: "intrinsic", name: "object" } } }] }], + ["call argument empty callee segment", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["db..findMany"], requiredPath: "where.workspaceId" } }] }], + ["call argument slash in callee", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["db/policy.findMany"], requiredPath: "where.workspaceId" } }] }], + ["call argument empty callee list", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: [], requiredPath: "where.workspaceId" } }] }], + ["call argument trailing dot in path", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], requiredPath: "where." } }] }], + ["call argument glob in path", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], requiredPath: "where.*" } }] }], + ["call argument nine path segments", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], requiredPath: "a.b.c.d.e.f.g.h.i" } }] }], + ["call argument index above nine", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: 10, requiredPath: "where" } }] }], + ["call argument negative index", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: -1, requiredPath: "where" } }] }], + ["call argument fractional index", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: 0.5, requiredPath: "where" } }] }], + ["call argument unknown field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], requiredPath: "where", regex: ".*" } }] }], ])("rejects %s", (_label, value) => { expect(RepositoryPolicySchema.safeParse(value).success).toBe(false); }); - it("accepts all ten closed check variants and rejects variant-only drift", () => { + it("accepts all eleven closed check variants and rejects variant-only drift", () => { const checks = [ { kind: "forbid-import-edge", from: ["apps/**"], deny: ["module:x"] }, { kind: "require-import", files: ["apps/**"], module: "server-only", allowTypeOnly: false }, @@ -67,6 +77,8 @@ describe("RepositoryPolicy v1 contract", () => { { kind: "require-closed-registry", registryFile: "src/tools/tool-policy.ts", registryExport: "TOOL_POLICY", declarationFiles: ["src/tools/**/*.ts"], declarationCalls: ["defineTool"], requiredKeys: ["classification", "authority", "approval"] }, { kind: "restrict-property-write", files: ["src/**/*.ts"], targetType: { file: "src/domain/job.ts", exportName: "Job" }, property: "status", allowFrom: ["src/dataplane/state/**"] }, { kind: "restrict-property-write", files: ["src/**/*.ts"], targetType: { file: "src/domain/job.ts", exportName: "Job" }, property: "status", allowFrom: [] }, + { kind: "require-call-argument", files: ["packages/persistence/src/**/*.ts"], callee: ["*.findFirst", "*.findMany", "prisma.*.updateMany", "$db.count"], argument: 0, requiredPath: "where.workspaceId", allowFrom: ["packages/persistence/src/audit.ts"] }, + { kind: "require-call-argument", files: ["src/**"], callee: ["*"], requiredPath: "a.b.c.d.e.f.g.h" }, ]; for (const [index, check] of checks.entries()) { const result = RepositoryPolicySchema.safeParse({ @@ -77,6 +89,14 @@ describe("RepositoryPolicy v1 contract", () => { } }); + it("defaults require-call-argument's argument to 0 and allowFrom to []", () => { + const parsed = RepositoryPolicySchema.parse({ + ...validPolicy, + rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], requiredPath: "where.workspaceId" } }], + }); + expect(parsed.rules[0]?.check).toEqual({ kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: 0, requiredPath: "where.workspaceId", allowFrom: [] }); + }); + describe("layers", () => { const extraLayers = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, index) => [`layer-${String(index)}`, ["apps/**"]])); const layeredRule = { ...validPolicy.rules[0], check: { kind: "forbid-import-edge", from: ["layer:ui", "packages/**"], deny: ["module:@prisma/client"] } }; diff --git a/packages/profile-software-architecture/src/policy.ts b/packages/profile-software-architecture/src/policy.ts index 9d7f593..047c514 100644 --- a/packages/profile-software-architecture/src/policy.ts +++ b/packages/profile-software-architecture/src/policy.ts @@ -25,6 +25,10 @@ const ModuleDenialSchema = z.string().min(6).max(500).refine( ); const IdentifierSchema = z.string().min(1).max(200).regex(/^[A-Za-z_$][\w$]*$/u, "Export names must be a single identifier."); +/** A glob over a resolved callee chain such as `*.findMany` or `prisma.*.updateMany`; `*` spans dots. */ +export const CalleeGlobSchema = z.string().min(1).max(200).regex(/^[A-Za-z_$*][\w$*]*(?:\.[A-Za-z_$*][\w$*]*)*$/u, "Callee globs must be dotted identifier segments, each of which may contain *."); +/** 1..8 identifier segments joined by `.`, such as `where.workspaceId`. */ +export const DottedPathSchema = z.string().min(1).max(200).regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){0,7}$/u, "Dotted paths must be 1 to 8 identifier segments."); const RelativeTypeScriptFileSchema = RelativeGlobSchema.refine( (value) => !/[*?[\]{}]/u.test(value) && /\.(?:ts|tsx)$/u.test(value), "Type files must be one exact contained TypeScript file, not a glob.", @@ -71,6 +75,14 @@ export const PolicyCheckSchema = z.discriminatedUnion("kind", [ property: IdentifierSchema, allowFrom: OptionalRuleGlobList, }), + z.strictObject({ + kind: z.literal("require-call-argument"), + files: RuleGlobList, + callee: uniqueArray(CalleeGlobSchema, 1, 100), + argument: z.number().int().min(0).max(9).default(0), + requiredPath: DottedPathSchema, + allowFrom: OptionalRuleGlobList.default([]), + }), ]); const PolicyRuleSchema = z.strictObject({ diff --git a/packages/validator/README.md b/packages/validator/README.md index 4eeea87..8e64232 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -61,6 +61,32 @@ as written, so a policy without `layers` keeps its digest. A reference to an undeclared layer, a non-slug name, or a reference inside `scope` fails policy parsing with exit `2`. +## Required call arguments + +`require-call-argument` proves that every call whose resolved callee matches +a glob carries a dotted path in one argument, so a tenant identifier cannot be +dropped from a query selector. Proof is static: an object literal, an +`Object.freeze` of one, or a same-file `const` bound to one, with no opaque +spread or computed key on the path. Anything the validator cannot prove is +`CALL_ARGUMENT_PROOF_FAILED`; a provable selector without the path is +`CALL_ARGUMENT_MISSING`. + +```json +{ + "id": "tenant-queries-carry-workspace", + "title": "Tenant queries carry the workspace identifier", + "level": "error", + "check": { + "kind": "require-call-argument", + "files": ["layer:persistence"], + "callee": ["*.findFirst", "*.findMany", "*.updateMany", "*.deleteMany", "*.count"], + "requiredPath": "where.workspaceId", + "allowFrom": ["src/persistence/audit.ts"] + }, + "remediation": "Put workspaceId in the where selector of every tenant-scoped query." +} +``` + ## Explain and init ```text diff --git a/packages/validator/fixtures/kinds/call-argument/queries.ts b/packages/validator/fixtures/kinds/call-argument/queries.ts new file mode 100644 index 0000000..95d899a --- /dev/null +++ b/packages/validator/fixtures/kinds/call-argument/queries.ts @@ -0,0 +1,21 @@ +declare const db: { policy: { findMany(a: unknown): void; updateMany(a: unknown): void }; other: { findMany(a: unknown): void } }; +declare const workspaceId: string; +declare const flag: boolean; +declare function buildSelector(): unknown; +declare const base: object; +declare const key: string; + +db.policy.findMany({ where: { workspaceId } }); +db.policy.findMany({ where: { status: "x" } }); +const selector = { where: { workspaceId } }; +db.policy.findMany(selector); +db.policy.findMany({ where: { workspaceId, ...(flag ? {} : { status: "x" }) } }); +db.policy.findMany({ where: { ...base, workspaceId } }); +db.policy.findMany({ where: { workspaceId, ...base } }); +db.policy.findMany({ where: { ...base } }); +db.policy.findMany({ where: { workspaceId: undefined } }); +db.policy.findMany(buildSelector()); +(db as any).policy.findMany({ where: { workspaceId } }); +db.other.findMany({}); +db["policy"].findMany({}); +db[key].findMany({}); diff --git a/packages/validator/src/checks/argument-shape.test.ts b/packages/validator/src/checks/argument-shape.test.ts new file mode 100644 index 0000000..b5b889f --- /dev/null +++ b/packages/validator/src/checks/argument-shape.test.ts @@ -0,0 +1,205 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { walk } from "../ast"; +import { createRepositoryProgram } from "../program"; +import { chainMatches, couldMatchCallee, proveObjectPath, resolveCallChain, type PathProof } from "./argument-shape"; + +const COMPILER_TEST_TIMEOUT_MS = 30_000; +const PREAMBLE = ` + declare const db: { policy: { findMany(a: unknown): void }; other: { findMany(a: unknown): void } }; + declare const maybe: { policy: { findMany(a: unknown): void } } | undefined; + declare function target(a?: unknown): void; + declare function getDb(): typeof db; + declare const workspaceId: string; + declare const flag: boolean; + declare const base: object; + declare const key: string; + declare const external: { where: { workspaceId: string } }; + export {}; +`; + +/** One program over `sources`, one file each; returns the call expressions of every file in source order. */ +function compile(sources: readonly string[]) { + const rootPath = mkdtempSync(path.join(tmpdir(), "kernel-zero-argument-shape-")); + const filePaths = sources.map((_, index) => `case-${String(index)}.ts`); + for (const [index, source] of sources.entries()) { + writeFileSync(path.join(rootPath, filePaths[index] ?? ""), `${PREAMBLE}\n${source}\n`, "utf8"); + } + const repository = createRepositoryProgram({ rootPath, filePaths }); + const checker = repository.program.getTypeChecker(); + const files = filePaths.map((filePath) => { + const sourceFile = repository.sourceFiles.get(filePath); + if (sourceFile === undefined) throw new Error("Fixture did not compile."); + const calls: ts.CallExpression[] = []; + walk(sourceFile, (node) => { + if (ts.isCallExpression(node)) calls.push(node); + }); + return { calls, sourceFile }; + }); + return { checker, files }; +} + +/** Proves `dotted` on the first argument of the last `target(...)` call of each source. */ +function proveEach(sources: readonly string[], dotted = "where.workspaceId"): PathProof[] { + const { checker, files } = compile(sources); + return files.map(({ calls, sourceFile }) => { + const call = calls.filter((candidate) => ts.isIdentifier(candidate.expression) && candidate.expression.text === "target").at(-1); + if (call === undefined) throw new Error("No target call in fixture."); + return proveObjectPath(call.arguments[0], dotted.split("."), checker, sourceFile); + }); +} + +function kinds(sources: readonly string[], dotted?: string): string[] { + return proveEach(sources, dotted).map((proof) => (proof.kind === "unprovable" ? `unprovable:${proof.reason}` : proof.kind)); +} + +describe("proveObjectPath", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { + it("proves present leaves and reports string-literal and template values", () => { + expect(proveEach([ + 'target({ where: { workspaceId: "ws" } });', + "target({ where: { workspaceId: `ws` } });", + "target({ where: { workspaceId } });", + 'target({ where: { ["workspaceId"]: workspaceId } });', + "target({ where: { workspaceId: 1 } } as const);", + ])).toMatchObject([ + { kind: "present", literal: "ws" }, + { kind: "present", literal: "ws" }, + { kind: "present", literal: undefined }, + { kind: "present", literal: undefined }, + { kind: "present", literal: undefined }, + ]); + expect(kinds(["target({ where: { workspaceId } });"], "where")).toEqual(["present"]); + }); + + it("reports missing for an absent argument, an absent key, undefined, and void 0", () => { + expect(kinds([ + "target();", + "target({ where: { status: 1 } });", + "target({ take: 1 });", + "target({ where: { workspaceId: undefined } });", + "target({ where: { workspaceId: void 0 } });", + ])).toEqual(["missing", "missing", "missing", "missing", "missing"]); + }); + + it("reports not-literal for calls, conditionals in the key position, non-object initializers, and non-const bindings", () => { + expect(kinds([ + "target(getDb());", + "target({ where: flag ? { workspaceId } : { workspaceId } });", + "target(flag ? { where: { workspaceId } } : { where: { workspaceId } });", + "target({ where: 1 });", + "target(external);", + "let selector = { where: { workspaceId } }; target(selector);", + "target({ where() { return 1; } });", + "const selector = flag ? { where: { workspaceId } } : {}; target(selector);", + ])).toEqual(Array.from({ length: 8 }, () => "unprovable:not-literal")); + }); + + it("follows same-file const bindings, shorthand members, and Object.freeze wrappers", () => { + expect(kinds([ + "const selector = { where: { workspaceId } }; target(selector);", + "const selector = { where: { workspaceId } } as const; target((selector));", + "const where = { workspaceId }; target({ where });", + "const where = { status: 1 }; target({ where });", + "target(Object.freeze({ where: Object.freeze({ workspaceId }) }));", + "const selector = Object.freeze({ where: { workspaceId } }); target(selector);", + "const inner = { workspaceId }; const selector = { where: inner }; target(selector);", + ])).toEqual(["present", "present", "present", "missing", "present", "present", "present"]); + }); + + it("reports a cycle when const bindings refer to each other", () => { + expect(kinds(["const a: unknown = b; const b: unknown = a; target(a);"])).toEqual(["unprovable:cycle"]); + }); + + it("reports computed for a non-literal computed key at the searched level", () => { + expect(kinds([ + "target({ where: { [key]: 1, workspaceId } });", + "target({ [key]: 1, where: { workspaceId } });", + ])).toEqual(["unprovable:computed", "unprovable:computed"]); + }); + + it("treats spreads before the key as harmless and opaque spreads after it as unprovable", () => { + expect(kinds([ + "target({ where: { ...base, workspaceId } });", + "target({ where: { workspaceId, ...base } });", + "target({ where: { ...base } });", + "target({ ...base, where: { workspaceId } });", + "target({ where: { workspaceId }, ...base });", + ])).toEqual(["present", "unprovable:spread", "unprovable:spread", "present", "unprovable:spread"]); + }); + + it("accepts harmless spreads after the key: literals and conditionals of literals without the key at their top level", () => { + expect(kinds([ + "target({ where: { workspaceId, ...(flag ? {} : { status: 1 }) } });", + "target({ where: { workspaceId, ...{ status: 1 } } });", + "target({ where: { workspaceId, ...(flag ? {} : { generatedAt: { ...base, ...base } }) } });", + "target({ where: { workspaceId, ...(flag ? {} : { other() { return 1; }, get more() { return 1; } }) } });", + ])).toEqual(["present", "present", "present", "present"]); + }); + + it("rejects spreads after the key whose operand may author the key", () => { + expect(kinds([ + "target({ where: { workspaceId, ...(flag ? {} : { workspaceId: 1 }) } });", + "target({ where: { workspaceId, ...(flag ? {} : { workspaceId() { return 1; } }) } });", + "target({ where: { workspaceId, ...(flag ? {} : { get workspaceId() { return 1; } }) } });", + "target({ where: { workspaceId, ...(flag ? {} : { ...base }) } });", + "target({ where: { workspaceId, ...(flag ? {} : { [key]: 1 }) } });", + "target({ where: { workspaceId, ...(flag ? base : {}) } });", + ])).toEqual(Array.from({ length: 6 }, () => "unprovable:spread")); + }); +}); + +describe("resolveCallChain, chainMatches, and couldMatchCallee", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { + it("resolves textual chains and judges the raw innermost receiver", () => { + const { checker, files } = compile([` + db.policy.findMany({}); + (db as any).policy.findMany({}); + maybe?.policy.findMany({}); + db["policy"].findMany({}); + const alias = db.policy.findMany; + alias({}); + getDb().policy.findMany({}); // the outer call is unresolvable; the inner getDb() call resolves to its identifier + db[key].findMany({}); + `]); + const calls = files[0]?.calls ?? []; + expect(calls.map((call) => resolveCallChain(call, checker))).toEqual([ + { chain: "db.policy.findMany", unsafeReceiver: false }, + { chain: "db.policy.findMany", unsafeReceiver: true }, + { chain: "maybe.policy.findMany", unsafeReceiver: true }, + { chain: "db.policy.findMany", unsafeReceiver: false }, + { chain: "alias", unsafeReceiver: false }, + undefined, + { chain: "getDb", unsafeReceiver: false }, + undefined, + ]); + }); + + it("matches chains against callee globs where * spans dots", () => { + expect(chainMatches("db.policy.findMany", ["*.findMany"])).toBe(true); + expect(chainMatches("db.policy.findMany", ["db.policy.*"])).toBe(true); + expect(chainMatches("db.other.findMany", ["db.policy.*", "*.findFirst"])).toBe(false); + expect(chainMatches("findMany", ["*.findMany"])).toBe(false); + }); + + it("names an unresolved callee only when both ends of a glob agree", () => { + const { files } = compile([` + db[key].findMany({}); + getDb().policy.findMany({}); + target({}); + `]); + const [elementAccess, callRoot, , identifier] = files[0]?.calls ?? []; + if (elementAccess === undefined || callRoot === undefined || identifier === undefined) throw new Error("Expected three calls."); + expect(couldMatchCallee(elementAccess, ["other.*", "*.findFirst", "db.*"])).toBe("db.*"); + expect(couldMatchCallee(elementAccess, ["*.findMany"])).toBe("*.findMany"); + expect(couldMatchCallee(elementAccess, ["db.policy.findMany"])).toBe("db.policy.findMany"); + expect(couldMatchCallee(elementAccess, ["*.findFirst"])).toBeUndefined(); + expect(couldMatchCallee(elementAccess, ["other.findMany"])).toBeUndefined(); + expect(couldMatchCallee(callRoot, ["*.findMany"])).toBe("*.findMany"); + expect(couldMatchCallee(callRoot, ["db.findMany"])).toBeUndefined(); + expect(couldMatchCallee(identifier, ["*"])).toBeUndefined(); + }); +}); diff --git a/packages/validator/src/checks/argument-shape.ts b/packages/validator/src/checks/argument-shape.ts new file mode 100644 index 0000000..cab75fc --- /dev/null +++ b/packages/validator/src/checks/argument-shape.ts @@ -0,0 +1,225 @@ +import ts from "typescript"; + +import { + containsUnsafeType, + expressionRootName, + literalText, + propertyInitializer, + propertyName, + resolveCalleeName, + unwrapExpression, +} from "../ast"; +import { matchesGlob } from "../findings"; + +/** + * Shared static prover for call-argument shapes: whether a dotted path is + * provably present inside a call argument, and which callee chain a call + * reaches. Adapters map this analysis onto their own codes; this module never + * emits findings. + */ +export type PathProof = + | { readonly kind: "present"; readonly value: ts.Expression; readonly literal: string | undefined } + | { readonly kind: "missing" } + | { readonly kind: "unprovable"; readonly reason: "not-literal" | "spread" | "computed" | "cycle" }; + +export interface CallChain { + /** The textual callee chain `restrict-call-site` matches. */ + readonly chain: string; + /** True when the innermost raw receiver expression has an unsafe (any/unknown/undefined/void) type. */ + readonly unsafeReceiver: boolean; +} + +const MISSING: PathProof = Object.freeze({ kind: "missing" }); + +function unprovable(reason: Extract["reason"]): PathProof { + return { kind: "unprovable", reason }; +} + +export function proveObjectPath( + argument: ts.Expression | undefined, + path: readonly string[], + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, +): PathProof { + return prove(argument, path, checker, sourceFile, new Set()); +} + +function prove( + expression: ts.Expression | undefined, + path: readonly string[], + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, + seen: Set, +): PathProof { + if (expression === undefined) { + return MISSING; + } + const value = unwrapValue(expression); + const [head, ...rest] = path; + if (head === undefined) { + return isUndefinedValue(value) ? MISSING : { kind: "present", value, literal: literalText(value) }; + } + // ponytail: a conditional in the key's own position is unprovable; branch-wise proof is the upgrade. + if (ts.isConditionalExpression(value)) { + return unprovable("not-literal"); + } + if (ts.isIdentifier(value)) { + const initializer = constInitializer(value, checker, sourceFile, seen); + if (initializer === "cycle") { + return unprovable("cycle"); + } + return initializer === undefined ? unprovable("not-literal") : prove(initializer, path, checker, sourceFile, seen); + } + if (!ts.isObjectLiteralExpression(value)) { + return unprovable("not-literal"); + } + + let keyIndex = -1; + for (const [index, property] of value.properties.entries()) { + if (ts.isSpreadAssignment(property)) { + continue; + } + const name = memberName(property); + if (name === undefined) { + return unprovable("computed"); + } + if (name === head) { + keyIndex = index; + } + } + const keyProperty = keyIndex < 0 ? undefined : value.properties[keyIndex]; + if (keyProperty === undefined) { + return value.properties.some(ts.isSpreadAssignment) ? unprovable("spread") : MISSING; + } + // Spreads before the key are always harmless: the later literal key wins. + for (const property of value.properties.slice(keyIndex + 1)) { + if (ts.isSpreadAssignment(property) && !isHarmlessSpread(property.expression, head)) { + return unprovable("spread"); + } + } + const initializer = propertyInitializer(keyProperty); + if (initializer === undefined) { + return unprovable("not-literal"); + } + return prove(initializer, rest, checker, sourceFile, seen); +} + +/** `unwrapExpression` plus an exact `Object.freeze()` wrapper. */ +function unwrapValue(expression: ts.Expression): ts.Expression { + const value = unwrapExpression(expression); + if ( + ts.isCallExpression(value) + && value.arguments.length === 1 + && ts.isPropertyAccessExpression(value.expression) + && value.expression.questionDotToken === undefined + && ts.isIdentifier(value.expression.expression) + && value.expression.expression.text === "Object" + && value.expression.name.text === "freeze" + ) { + const wrapped = value.arguments[0]; + return wrapped === undefined ? value : unwrapValue(wrapped); + } + return value; +} + +function isUndefinedValue(value: ts.Expression): boolean { + return (ts.isIdentifier(value) && value.text === "undefined") || ts.isVoidExpression(value); +} + +/** The member's own name; undefined for a computed key that is not a string literal. */ +function memberName(property: Exclude): string | undefined { + return ts.isComputedPropertyName(property.name) ? literalText(property.name.expression) : propertyName(property.name); +} + +/** + * A spread is harmless for `key` when its operand is an object literal, or a conditional + * whose branches are both object literals, and each literal's own top-level members have no + * spread, no computed key, and no member named `key`. Nested values are not inspected. + */ +function isHarmlessSpread(operand: ts.Expression, key: string): boolean { + const value = unwrapValue(operand); + if (ts.isConditionalExpression(value)) { + return isHarmlessLiteral(unwrapValue(value.whenTrue), key) && isHarmlessLiteral(unwrapValue(value.whenFalse), key); + } + return isHarmlessLiteral(value, key); +} + +function isHarmlessLiteral(value: ts.Expression, key: string): boolean { + if (!ts.isObjectLiteralExpression(value)) { + return false; + } + return value.properties.every((property) => { + if (ts.isSpreadAssignment(property)) { + return false; + } + const name = memberName(property); + return name !== undefined && name !== key; + }); +} + +/** The initializer of a same-file `const` with exactly one declaration; "cycle" when the binding was already followed. */ +function constInitializer( + identifier: ts.Identifier, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, + seen: Set, +): ts.Expression | "cycle" | undefined { + const symbol = ts.isShorthandPropertyAssignment(identifier.parent) + ? checker.getShorthandAssignmentValueSymbol(identifier.parent) + : checker.getSymbolAtLocation(identifier); + if (symbol === undefined) { + return undefined; + } + if (seen.has(symbol)) { + return "cycle"; + } + seen.add(symbol); + const declarations = symbol.declarations ?? []; + const declaration = declarations.length === 1 ? declarations[0] : undefined; + if ( + declaration === undefined + || !ts.isVariableDeclaration(declaration) + || declaration.initializer === undefined + || declaration.getSourceFile() !== sourceFile + || (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 + ) { + return undefined; + } + return declaration.initializer; +} + +export function resolveCallChain(call: ts.CallExpression, checker: ts.TypeChecker): CallChain | undefined { + const chain = resolveCalleeName(call.expression, checker, new Set()); + if (chain === undefined) { + return undefined; + } + // Raw descent, no unwrapping: `(db as any).policy.findMany` must expose the `any` receiver. + let innermost: ts.Expression = call.expression; + while (ts.isPropertyAccessExpression(innermost) || ts.isElementAccessExpression(innermost)) { + innermost = innermost.expression; + } + return { chain, unsafeReceiver: containsUnsafeType(checker.getTypeAtLocation(innermost)) }; +} + +/** `*` spans dots because a chain has no `/`. */ +export function chainMatches(chain: string, globs: readonly string[]): boolean { + return globs.some((glob) => matchesGlob(chain, glob)); +} + +/** + * Unresolved-callee heuristic: the first glob whose first segment is `*` or the callee's root + * identifier and whose last segment is `*` or the accessed name. Both ends must agree. + */ +export function couldMatchCallee(call: ts.CallExpression, globs: readonly string[]): string | undefined { + if (!ts.isPropertyAccessExpression(call.expression)) { + return undefined; + } + const root = expressionRootName(call.expression); + const last = call.expression.name.text; + return globs.find((glob) => { + const segments = glob.split("."); + const first = segments[0]; + const end = segments.at(-1); + return (first === "*" || (root !== undefined && first === root)) && (end === "*" || end === last); + }); +} diff --git a/packages/validator/src/checks/call-argument.test.ts b/packages/validator/src/checks/call-argument.test.ts new file mode 100644 index 0000000..cf8472c --- /dev/null +++ b/packages/validator/src/checks/call-argument.test.ts @@ -0,0 +1,62 @@ +import { fileURLToPath } from "node:url"; + +import type { RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; +import { describe, expect, it } from "vitest"; + +import { createRepositoryProgram, evaluatePolicyChecks } from "../engine"; + +const COMPILER_TEST_TIMEOUT_MS = 30_000; +const fixtureRoot = fileURLToPath(new URL("../../fixtures/kinds/", import.meta.url)); +type Check = Extract; + +const FILE = "call-argument/queries.ts"; +const SUBJECT = "call:db.policy.findMany:argument:0:where.workspaceId"; + +function evaluate(check: Partial = {}, level: "error" | "warning" = "error") { + const repository = createRepositoryProgram({ rootPath: fixtureRoot, filePaths: [FILE] }); + return evaluatePolicyChecks({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + metadata: { name: "call-argument-test", revision: 1, description: "Call argument fixture policy" }, + scope: { languages: ["typescript"], include: ["**/*.ts"], exclude: [] }, + rules: [{ + id: "tenant-queries-carry-workspace", + title: "Tenant queries carry the workspace", + level, + check: { kind: "require-call-argument", files: ["call-argument/**"], callee: ["db.policy.*"], argument: 0, requiredPath: "where.workspaceId", allowFrom: [], ...check }, + remediation: "Add where.workspaceId to the selector.", + }], + }, repository).map(({ messageCode, subject, path, location }) => [messageCode, subject, path, `${String(location.startLine)}:${String(location.startColumn)}-${String(location.endLine)}:${String(location.endColumn)}`]); +} + +describe("require-call-argument", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { + it("emits the exact finding matrix over the fixture in engine order", () => { + expect(evaluate()).toEqual([ + ["CALL_ARGUMENT_MISSING", SUBJECT, FILE, "9:1-9:47"], + ["CALL_ARGUMENT_UNPROVABLE", SUBJECT, FILE, "14:1-14:56"], + ["CALL_ARGUMENT_UNPROVABLE", SUBJECT, FILE, "15:1-15:43"], + ["CALL_ARGUMENT_MISSING", SUBJECT, FILE, "16:1-16:58"], + ["CALL_ARGUMENT_UNPROVABLE", SUBJECT, FILE, "17:1-17:36"], + ["CALL_ARGUMENT_UNPROVABLE", SUBJECT, FILE, "18:1-18:56"], + ["CALL_ARGUMENT_MISSING", SUBJECT, FILE, "20:1-20:26"], + ["CALL_ARGUMENT_UNRESOLVED", "call:db.policy.*:argument:0:where.workspaceId", FILE, "21:1-21:21"], + ]); + }); + + it("skips allowFrom files and files outside the rule", () => { + expect(evaluate({ allowFrom: ["call-argument/queries.ts"] })).toEqual([]); + expect(evaluate({ files: ["elsewhere/**"] })).toEqual([]); + }); + + it("reports unresolved callees only at error level", () => { + expect(evaluate({}, "warning").filter(([code]) => code === "CALL_ARGUMENT_UNRESOLVED")).toEqual([]); + expect(evaluate({}, "warning")).toHaveLength(7); + }); + + it("carries the configured argument index and path into every subject", () => { + const findings = evaluate({ callee: ["*.findMany"], argument: 1, requiredPath: "where" }); + expect(findings[0]).toEqual(["CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:1:where", FILE, "8:1-8:47"]); + expect(findings.filter(([, subject]) => subject === "call:db.other.findMany:argument:1:where")).toHaveLength(1); + expect(findings.at(-1)).toEqual(["CALL_ARGUMENT_UNRESOLVED", "call:*.findMany:argument:1:where", FILE, "21:1-21:21"]); + }); +}); diff --git a/packages/validator/src/checks/call-argument.ts b/packages/validator/src/checks/call-argument.ts new file mode 100644 index 0000000..7db1457 --- /dev/null +++ b/packages/validator/src/checks/call-argument.ts @@ -0,0 +1,47 @@ +import ts from "typescript"; + +import { walk } from "../ast"; +import { forEachMatchingSource, matchesGlob, nodeLocation, rawFinding, type CheckEvaluator, type RawFindingMessageCode } from "../findings"; +import { chainMatches, couldMatchCallee, proveObjectPath, resolveCallChain, type PathProof } from "./argument-shape"; + +export const evaluateCallArguments: CheckEvaluator<"require-call-argument"> = (rule, repository, failedPaths, findings) => { + const checker = repository.program.getTypeChecker(); + const { allowFrom, argument, callee, requiredPath } = rule.check; + const path = requiredPath.split("."); + const subjectFor = (chain: string): string => `call:${chain}:argument:${String(argument)}:${requiredPath}`; + forEachMatchingSource(repository, rule.check.files, failedPaths, (filePath, sourceFile) => { + if (allowFrom.some((glob) => matchesGlob(filePath, glob))) { + return; + } + walk(sourceFile, (node) => { + if (!ts.isCallExpression(node)) { + return; + } + const resolved = resolveCallChain(node, checker); + if (resolved === undefined) { + const glob = rule.level === "error" ? couldMatchCallee(node, callee) : undefined; + if (glob !== undefined) { + findings.push(rawFinding(rule, "CALL_ARGUMENT_UNRESOLVED", subjectFor(glob), filePath, nodeLocation(sourceFile, node))); + } + return; + } + if (!chainMatches(resolved.chain, callee)) { + return; + } + const code = resolved.unsafeReceiver + ? "CALL_ARGUMENT_UNPROVABLE" + : proofCode(proveObjectPath(node.arguments[argument], path, checker, sourceFile)); + if (code !== undefined) { + findings.push(rawFinding(rule, code, subjectFor(resolved.chain), filePath, nodeLocation(sourceFile, node))); + } + }); + }); +}; + +function proofCode(proof: PathProof): RawFindingMessageCode | undefined { + switch (proof.kind) { + case "present": return undefined; + case "missing": return "CALL_ARGUMENT_MISSING"; + case "unprovable": return "CALL_ARGUMENT_UNPROVABLE"; + } +} diff --git a/packages/validator/src/engine.ts b/packages/validator/src/engine.ts index 954f7bd..536ca0a 100644 --- a/packages/validator/src/engine.ts +++ b/packages/validator/src/engine.ts @@ -1,6 +1,7 @@ import { isLayerReference, ruleFileLists, type RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; import { evaluateBoundaryParses } from "./checks/boundary"; +import { evaluateCallArguments } from "./checks/call-argument"; import { evaluateRestrictedCalls } from "./checks/calls"; import { evaluateContextParameters } from "./checks/context"; import { evaluateExportKeys } from "./checks/exports"; @@ -96,6 +97,9 @@ function evaluateRule( case "restrict-property-write": evaluatePropertyWrites({ ...rule, check: rule.check }, repository, failedPaths, findings); return; + case "require-call-argument": + evaluateCallArguments({ ...rule, check: rule.check }, repository, failedPaths, findings); + return; } } @@ -113,6 +117,7 @@ function ruleClaimsPath(check: PolicyCheck, filePath: string): boolean { case "require-tenant-parameter": case "require-boundary-parse": case "require-governed-operation": + case "require-call-argument": return check.files.some((glob) => matchesGlob(filePath, glob)); case "restrict-property-write": return check.targetType.file === filePath || check.files.some((glob) => matchesGlob(filePath, glob)); diff --git a/packages/validator/src/findings.ts b/packages/validator/src/findings.ts index c63484d..7b53edf 100644 --- a/packages/validator/src/findings.ts +++ b/packages/validator/src/findings.ts @@ -29,7 +29,10 @@ export type RawFindingMessageCode = | "CLOSED_REGISTRY_PROOF_FAILED" | "PROPERTY_WRITE_DENIED" | "PROPERTY_WRITE_UNRESOLVED" - | "PROPERTY_TARGET_UNRESOLVED"; + | "PROPERTY_TARGET_UNRESOLVED" + | "CALL_ARGUMENT_MISSING" + | "CALL_ARGUMENT_UNPROVABLE" + | "CALL_ARGUMENT_UNRESOLVED"; export interface RawFindingLocation { readonly startLine: number; diff --git a/packages/validator/src/runner.ts b/packages/validator/src/runner.ts index 438bf8f..b36720f 100644 --- a/packages/validator/src/runner.ts +++ b/packages/validator/src/runner.ts @@ -211,5 +211,8 @@ function publicMessageCode(code: RawFindingMessageCode): FindingMessageCode { case "PROPERTY_WRITE_DENIED": return "PROPERTY_WRITE_DENIED"; case "PROPERTY_WRITE_UNRESOLVED": case "PROPERTY_TARGET_UNRESOLVED": return "PROPERTY_WRITE_PROOF_FAILED"; + case "CALL_ARGUMENT_MISSING": return "CALL_ARGUMENT_MISSING"; + case "CALL_ARGUMENT_UNPROVABLE": + case "CALL_ARGUMENT_UNRESOLVED": return "CALL_ARGUMENT_PROOF_FAILED"; } } diff --git a/scripts/benchmark-validator.ts b/scripts/benchmark-validator.ts index 3b7d339..41f4413 100644 --- a/scripts/benchmark-validator.ts +++ b/scripts/benchmark-validator.ts @@ -29,6 +29,12 @@ const policy = { level: "error", remediation: "Use a repository boundary.", title: "No raw database client", + }, { + check: { callee: ["*.findMany"], files: ["src/**/*.ts"], kind: "require-call-argument", requiredPath: "where.workspaceId" }, + id: "tenant-queries-carry-workspace", + level: "error", + remediation: "Add where.workspaceId to the selector.", + title: "Tenant queries carry the workspace", }], scope: { exclude: [], include: ["src/**/*.ts"], languages: ["typescript"] }, }; diff --git a/scripts/generate-contracts.ts b/scripts/generate-contracts.ts index 3cef29f..1ac370c 100644 --- a/scripts/generate-contracts.ts +++ b/scripts/generate-contracts.ts @@ -164,11 +164,11 @@ const outputs = new Map([ ["docs/contracts/malformed/repository-policy-path-escape.json", `${JSON.stringify({ ...policyExample, scope: { ...policyExample.scope, include: ["../private.ts"] } }, null, 2)}\n`], ["docs/contracts/malformed/exception-grant-set-private-data.json", `${JSON.stringify({ ...exceptionExample, rationale: "must never be exported" }, null, 2)}\n`], ["docs/contracts/malformed/repository-evidence-source-content.json", `${JSON.stringify({ ...evidenceExample, source: "private source text" }, null, 2)}\n`], - ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, and \`restrict-property-write\`. + ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, \`restrict-property-write\`, and \`require-call-argument\`. \`layers\` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (\`from\`, \`files\`, \`allowFrom\`, \`declarationFiles\`) may carry \`layer:\` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. \`scope.include\` and \`scope.exclude\` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside \`scope\`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without \`layers\` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. -\`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], +\`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`require-call-argument\` proves that every call in \`files\` (minus \`allowFrom\`) whose resolved callee chain matches one of \`callee\` carries \`requiredPath\` in the argument at index \`argument\` (0 to 9, default 0). A callee glob is dotted identifier segments where \`*\` spans dots (\`*.findMany\`, \`prisma.*.updateMany\`); \`requiredPath\` is 1 to 8 identifier segments (\`where.workspaceId\`). The argument is proven when it is an object literal, an \`Object.freeze\` of one, or an identifier bound by a same-file \`const\` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal \`undefined\` or \`void 0\`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: \`CALL_ARGUMENT_MISSING\` when the argument is provable and the path is absent or \`undefined\`, and \`CALL_ARGUMENT_PROOF_FAILED\` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`, or (at \`error\` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject \`call::argument::\`, where \`\` is the resolved callee chain or, for an unresolved callee, the matching glob itself.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], ]); const check = process.argv.includes("--check"); From c50946d7020ea93a5937fa060c5277cddcdb9670 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 12:27:29 -0700 Subject: [PATCH 4/7] Add the restrict-state-transition check kind and govern policy revision state writes --- .../2026-09-09-restrict-state-transition.md | 249 ++++++++++++++++++ docs/contracts/README.md | 4 +- .../repository-evidence-v1.schema.json | 2 + .../repository-policy-v1.schema.json | 80 +++++- ...-09-validator-rule-language-advisor-log.md | 3 + ...09-09-validator-rule-language-run-state.md | 7 +- docs/validator-and-hooks.md | 30 +++ kernel-zero.policy.json | 31 +++ .../src/compatibility.test.ts | 19 ++ .../src/compatibility.ts | 16 +- .../src/evidence.test.ts | 18 ++ .../src/evidence.ts | 4 + .../src/policy.test.ts | 22 +- .../src/policy.ts | 12 +- packages/validator/README.md | 31 +++ .../kinds/state-transition/allowed/writer.ts | 12 + .../kinds/state-transition/elsewhere/rogue.ts | 3 + .../src/checks/state-transition.test.ts | 78 ++++++ .../validator/src/checks/state-transition.ts | 72 +++++ packages/validator/src/engine.ts | 5 + packages/validator/src/findings.ts | 5 +- packages/validator/src/runner.ts | 3 + scripts/benchmark-validator.ts | 6 + scripts/generate-contracts.ts | 4 +- 24 files changed, 702 insertions(+), 14 deletions(-) create mode 100644 docs/adr/2026-09-09-restrict-state-transition.md create mode 100644 packages/validator/fixtures/kinds/state-transition/allowed/writer.ts create mode 100644 packages/validator/fixtures/kinds/state-transition/elsewhere/rogue.ts create mode 100644 packages/validator/src/checks/state-transition.test.ts create mode 100644 packages/validator/src/checks/state-transition.ts diff --git a/docs/adr/2026-09-09-restrict-state-transition.md b/docs/adr/2026-09-09-restrict-state-transition.md new file mode 100644 index 0000000..a4398e4 --- /dev/null +++ b/docs/adr/2026-09-09-restrict-state-transition.md @@ -0,0 +1,249 @@ +# `restrict-state-transition` + +## Context + +PRP `docs/prp/2026-09-09-validator-rule-language.md` FR-STA: a policy +revision's `state` column moves only `draft -> approved -> active -> superseded`, +and only `packages/persistence/src/policies.ts` writes it. Nothing proved +that. `tenant-queries-carry-workspace` (step 2) proves every `updateMany` +carries `where.workspaceId`, but a second file could write +`data: { state: "approved" }` and `policies.ts` could freeze a draft straight +to `active`, and every gate stayed green. + +The step-2 prover (`packages/validator/src/checks/argument-shape.ts`) already +proves a dotted path inside a call argument and returns the string literal at +the leaf, resolves callee chains with receiver safety, and carries the +both-ends unresolved heuristic. This kind is a second adapter over that +prover; the prover itself is unchanged. + +This is `kz-policy-rule` branch B (new check kind) plus one branch-A rule in +the self-policy. Spec: `docs/plans/2026-09-09-validator-rule-language-spec.md` +sections 4, 6, 7, 8. + +## Decision + +- Schema (`packages/profile-software-architecture/src/policy.ts`): member + `restrict-state-transition` with `callee: uniqueArray(CalleeGlobSchema, 1, 100)`, + `argument: int 0..9 default 0`, `field: DottedPathSchema`, + `allowFrom: OptionalRuleGlobList default []`, and + `transitions: uniqueArray({ from: Identifier | "*", to: Identifier }, 0, 100) default []`. + `IdentifierSchema` is now exported beside `CalleeGlobSchema` so + compatibility parses the pair subject with the same grammar. The predicate + path is `field` with its first segment replaced by `where` + (`data.state` -> `where.state`). +- Evaluator (`packages/validator/src/checks/state-transition.ts`, new): every + call in every file in scope, skipping `failedPaths`; `ruleClaimsPath` is + `true` for every file, like `restrict-call-site`. Per call, in spec order: + unresolved chain at `error` level with a `couldMatchCallee` glob -> raw + `STATE_TRANSITION_UNPROVABLE` with the glob as subject; chain not matching + -> skip; unsafe receiver -> `UNPROVABLE`; `proveObjectPath(arg, field)` + `missing` -> not a write, skip; `unprovable` -> `UNPROVABLE`; file not in + `allowFrom` -> `STATE_TRANSITION_DENIED_WRITER`; `transitions` empty -> + done; written value not a string literal, or predicate absent or not a + string literal -> `UNPROVABLE`; pair not listed (with `from: "*"` matching + any source) -> `STATE_TRANSITION_DENIED_PAIR` with subject + `transition::->`. Every other subject is + `transition::`; location is the call expression. +- `findings.ts` gains the three raw codes; `engine.ts` dispatches the kind; + `runner.ts` maps `DENIED_WRITER | DENIED_PAIR -> STATE_TRANSITION_DENIED` + and `UNPROVABLE -> STATE_TRANSITION_PROOF_FAILED`. +- Compatibility: `messageCodesByKind["restrict-state-transition"]` is + `["STATE_TRANSITION_DENIED", "STATE_TRANSITION_PROOF_FAILED"]`. Both codes + accept `transition::` + a chain that parses as a callee glob and + matches `callee`; `STATE_TRANSITION_DENIED` alone also accepts + `transition::->` where `from` is an identifier or `*` and + `to` an identifier. The forms cannot collide because a chain segment never + contains `-`. The pair is checked against the grammar, not the transition + list, because the finding reports a pair that is by definition unlisted. +- Fixtures `packages/validator/fixtures/kinds/state-transition/allowed/writer.ts` + (the six spec cases plus one unresolved `db[key].updateMany` call, added so + the warning-level and glob-subject branches are pinned in the same matrix) + and `elsewhere/rogue.ts` (one denied writer); `checks/state-transition.test.ts` + pins the exact finding list with subject and location in engine order, + the writer-only mode (`transitions: []`), the `*` source, empty + `allowFrom`, the warning level, and a chain outside `callee`. +- Self-policy rule `policy-revision-state-is-governed`: + `callee: ["*.policyRevision.updateMany"]`, `argument: 0`, + `field: "data.state"`, `allowFrom: ["packages/persistence/src/policies.ts"]`, + transitions `draft->approved`, `approved->active`, `active->superseded`. + No persistence file needed an edit: the three writes in `policies.ts` + carry literal `where.state` predicates and literal `data.state` values, the + draft-save write carries only `canonicalJson` and is ignored, and no other + file calls `policyRevision.updateMany`. +- Benchmark policy gains one `restrict-state-transition` rule + (`callee: ["*.updateMany"]`, `field: "data.state"`, `allowFrom: ["src/**"]`, + one transition). The corpus has no call expressions, so the rule walks + every node and proves nothing; the numbers below are therefore a floor for + this evaluator, not a bound. A corpus of real `updateMany` calls would add + the per-call `proveObjectPath` and `resolveCallChain` cost on top. + +Deliberate simplifications (`// ponytail:` ceilings inherited from the +prover): a conditional in the written value's position is unprovable, and +only same-file `const` bindings are followed. A literal `from` value that is +not an identifier (for example `"in progress"`) produces a pair subject the +compatibility grammar rejects; the self-policy's states are all identifiers, +and widening the grammar is the upgrade if a consumer needs it. + +## Invariants touched + +1. Definite policy violations fail closed. A provable write outside + `allowFrom` or through an unlisted pair is `STATE_TRANSITION_DENIED`; + anything the prover cannot see through (opaque spread, computed key, + non-literal value or predicate, absent predicate, `any` receiver, + unresolvable callee that could match) is `STATE_TRANSITION_PROOF_FAILED`, + never a pass. Proof: `state-transition.test.ts` (exact matrix), both bite + proofs below. +10. Local validation is deterministic and network-free. The evaluator is a + pure function of the program; `npm run validator:self` twice on the final + tree produced the same `integrity.digest` (quoted below). +11. Public wire formats are versioned and digests are over document content. + See Contract impact and the schema-default note. +12. Kernel packages never import a profile. Unchanged: + `git diff --stat -- packages/domain packages/contracts packages/persistence` + is empty after both bite restores. + +Invariants 2, 4, 5, 6, 7, 8, 9 are unaffected: no control-plane change, no +governed action, no new ingress, no UI. Invariant 3 is withdrawn. + +## Contract impact + +Policy `kernel-zero.dev/v1`: additive. One new discriminated-union member; +every stored policy parses unchanged and keeps its digest. Existing rule +fields, subjects, and fingerprints are untouched. + +Evidence `kernel-zero.dev/evidence/v1`: additive. The closed `messageCode` +enum grows from seventeen to nineteen public codes with +`STATE_TRANSITION_DENIED` ("A governed state field is written outside its +allowed writer or through an unlisted transition.") and +`STATE_TRANSITION_PROOF_FAILED` ("A write to a governed state field could not +be proven against the allowed transitions."). Four of the PRP section 6 seven +codes are now delivered; existing codes, strings, and fingerprints are +untouched, so every stored run still re-validates. Exception bundles: +unchanged. + +Generated contracts regenerated: `docs/contracts/repository-policy-v1.schema.json`, +`docs/contracts/repository-evidence-v1.schema.json`, and one paragraph in +`docs/contracts/README.md`; `npm run contracts:check` exit 0. + +Golden fixture `packages/validator/fixtures/golden/**`: byte-identical +(`git diff --stat -- packages/validator/fixtures/golden` empty). + +Self-policy digests (the document changed, so both moved): + +| | before (require-call-argument ADR) | after | +| --- | --- | --- | +| `policy.digest` | `sha256:2453c919f39582e2c9bd8c4a0cabd62853b1f7473cf67bd691f2004c46bda5a1` | `sha256:3fa084d236c6a05cdddc5723b581eaa58331a1696369d23ca9401d7a63089f11` | +| `integrity.digest` | `sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177` | `sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1` | + +The integrity digest also reflects the manifest gaining one source file +(`checks/state-transition.ts`; 94 to 95 files). + +Working-tree note: before the bite proofs, the two determinism runs on this +change gave `sha256:65284e1146a550905eefd46e5783d0ba80a9cfd7ae1e3d90cdc20232a317a271`. +`git checkout --` then rewrote `evidence.ts` and `policies.ts` with LF per +`.gitattributes` while this checkout held CRLF copies (`core.autocrlf=true`), +which moved the manifest digest with zero content change (`git diff` empty in +both states), exactly as the step-2 ADR recorded for `exceptions.ts`. The +files are left at LF, the git-clean state; the "after" value above and the +two determinism runs quoted below were taken on that final tree, after every +edit in this step. + +## Schema-default digest note + +`argument` defaults to `0`, `allowFrom` to `[]`, and `transitions` to `[]`. +The policy digest is `canonicalSha256` of the schema-parsed document, so a +stored policy that declares this kind and omits any of the three would parse +to a document carrying all of them and its digest would move (CLAUDE.md rule +5). No stored policy can contain this kind yet, because the kind did not +exist before this change, so nothing existing moves. The self-policy spells +all three fields explicitly. + +## Bite proofs + +Both taken with `npm run validator:self`, each restored with +`git checkout -- ` before the next step. + +1. Rogue writer. Inserted + `await tx.policyRevision.updateMany({ data: { state: "approved" }, where: { state: "draft", workspaceId: input.workspaceId } });` + as the first statement of `storeInTransaction` in + `packages/persistence/src/evidence.ts` (the `where` carries `workspaceId`, + so `tenant-queries-carry-workspace` stays silent and only this rule fires). + + ```text + kernel-zero: fail (1 errors, 0 warnings, 0 excepted, 95 files) + error policy-revision-state-is-governed packages/persistence/src/evidence.ts:314:9 STATE_TRANSITION_DENIED transition:state:tx.policyRevision.updateMany + remediation: Change a policy revision's state only from policies.ts, with a literal where.state predicate, through draft->approved, approved->active, or active->superseded. + bite 1 exit=1 + restored evidence.ts + ``` + +2. Unlisted pair. In `packages/persistence/src/policies.ts` changed the + freeze write's `state: "approved"` to `state: "active"` (predicate still + `where.state: "draft"`). + + ```text + kernel-zero: fail (1 errors, 0 warnings, 0 excepted, 95 files) + error policy-revision-state-is-governed packages/persistence/src/policies.ts:87:25 STATE_TRANSITION_DENIED transition:state:draft->active + remediation: Change a policy revision's state only from policies.ts, with a literal where.state predicate, through draft->approved, approved->active, or active->superseded. + bite 2 exit=1 + restored policies.ts + ``` + +After both restores: `kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 95 files)`, +exit 0; `git diff --stat -- packages/domain packages/contracts packages/persistence` +empty. + +## FR-IDs + +FR-STA-001 (schema with `CalleeGlob`, `DottedPath`, identifier-or-`*` +transitions, defaults `0`, `[]`, `[]`; predicate path under `where`), +FR-STA-002 (every matching call anywhere in scope; writer outside `allowFrom` +is `STATE_TRANSITION_DENIED` with the callee subject), FR-STA-003 (literal +`to` and literal predicate `from`, listed pair or `*`; unlisted pair is +`STATE_TRANSITION_DENIED` with the pair subject; non-literal or missing +predicate is `STATE_TRANSITION_PROOF_FAILED` with the callee subject), +FR-STA-004 (writes not touching `field` ignored; spreads and computed keys +are proof failures), FR-STA-005 (self-policy rule, both bites). + +## Verification command + +Node 22 (`export PATH="/c/Users/Guerr/AppData/Roaming/fnm/node-versions/v22.22.3/installation:$PATH"`). + +```text +npx vitest run packages/profile-software-architecture + Test Files 6 passed (6) + Tests 129 passed (129) + +npm run test:validator + Test Files 12 passed (12) + Tests 93 passed (93) + +npm run validator:self (run 1, final tree) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 95 files) + integrity.digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 +npm run validator:self (run 2, final tree) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 95 files) + integrity.digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 + +npm run benchmark:validator (three samples; the first ran beside typecheck and lint) + {"deterministic":true,"files":5000,"firstMs":18478,"limitMs":30000,"limitRssBytes":1073741824,"peakObservedRssBytes":514420736,"secondMs":12841} + {"deterministic":true,"files":5000,"firstMs":19611,"limitMs":30000,"limitRssBytes":1073741824,"peakObservedRssBytes":510558208,"secondMs":7241} + {"deterministic":true,"files":5000,"firstMs":12980,"limitMs":30000,"limitRssBytes":1073741824,"peakObservedRssBytes":513036288,"secondMs":9438} + All under 30,000 ms and 1 GiB; see the floor-not-bound note under Decision. + +npm run contracts:generate && npm run contracts:check exit 0 +git diff --stat -- packages/validator/fixtures/golden (empty) +git diff --stat -- packages/domain packages/contracts packages/persistence (empty) +npm run typecheck exit 0 +npm run lint exit 0 +``` + +`npm run verify` on this tree: exit 0. + +```text +kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 95 files) +workflow: pass (0 errors, 0 warnings, 1 files) +unit Test Files 49 passed (49) Tests 352 passed (352) +architecture Test Files 1 passed (1) Tests 1 passed (1) +integration Test Files 3 passed (3) Tests 7 passed (7) +``` diff --git a/docs/contracts/README.md b/docs/contracts/README.md index bc6851a..ed9346a 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -8,7 +8,7 @@ Media type: `application/vnd.kernel-zero.policy+json;version=1` A strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256. -The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`, and `require-call-argument`. +The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`, `require-call-argument`, and `restrict-state-transition`. `layers` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (`from`, `files`, `allowFrom`, `declarationFiles`) may carry `layer:` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. `scope.include` and `scope.exclude` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside `scope`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without `layers` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. @@ -18,6 +18,8 @@ The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-cal `require-call-argument` proves that every call in `files` (minus `allowFrom`) whose resolved callee chain matches one of `callee` carries `requiredPath` in the argument at index `argument` (0 to 9, default 0). A callee glob is dotted identifier segments where `*` spans dots (`*.findMany`, `prisma.*.updateMany`); `requiredPath` is 1 to 8 identifier segments (`where.workspaceId`). The argument is proven when it is an object literal, an `Object.freeze` of one, or an identifier bound by a same-file `const` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal `undefined` or `void 0`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: `CALL_ARGUMENT_MISSING` when the argument is provable and the path is absent or `undefined`, and `CALL_ARGUMENT_PROOF_FAILED` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed `any`, `unknown`, or possibly `undefined`, or (at `error` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject `call::argument::`, where `` is the resolved callee chain or, for an unresolved callee, the matching glob itself. +`restrict-state-transition` proves that a state field is written only by its allowed writer and only through listed transitions. It inspects every call in scope whose resolved callee chain matches one of `callee` (the same glob grammar as `require-call-argument`); a call whose argument at index `argument` (0 to 9, default 0) provably carries `field` (1 to 8 identifier segments, for example `data.state`) is a write and must sit in an `allowFrom` file, and an empty `allowFrom` (the default) permits no writer. When `transitions` (default empty) lists pairs `{ from, to }`, where `from` is an identifier or `*` and `to` an identifier, every allowed write must carry a string-literal `field` value and a string-literal predicate value at `field` with its first segment replaced by `where` (`where.state` for `data.state`), and the pair must be listed. A call whose argument does not carry `field` is ignored. Codes: `STATE_TRANSITION_DENIED` with subject `transition::` for a writer outside `allowFrom` or `transition::->` for an unlisted pair, and `STATE_TRANSITION_PROOF_FAILED` with subject `transition::` when the write cannot be proven (an opaque spread or non-literal computed key on the path, a value built elsewhere, a const chain that cycles, a receiver typed `any`, `unknown`, or possibly `undefined`), when `transitions` are listed and the written value or the predicate is not a string literal or the predicate is absent, or (at `error` level) when a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob, in which case `` is that glob. `` is the last segment of `field`; the two subject forms never collide because a chain segment cannot contain `-`. + `restrict-property-write` proves that one property of one exported type (`targetType` as `{ file, exportName }` plus `property`) is written only from `allowFrom` files; an empty `allowFrom` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and `delete`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. `Object.assign`, `Reflect.set`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: `PROPERTY_WRITE_DENIED` and `PROPERTY_WRITE_PROOF_FAILED`, both with subject `property:#.`. ## RepositoryEvidence v1 diff --git a/docs/contracts/repository-evidence-v1.schema.json b/docs/contracts/repository-evidence-v1.schema.json index d8c32b9..6532bd7 100644 --- a/docs/contracts/repository-evidence-v1.schema.json +++ b/docs/contracts/repository-evidence-v1.schema.json @@ -157,6 +157,8 @@ "REQUIRED_EXPORT_KEY_MISSING", "REQUIRED_IMPORT_MISSING", "RESTRICTED_CALL", + "STATE_TRANSITION_DENIED", + "STATE_TRANSITION_PROOF_FAILED", "TENANT_PARAMETER_MISSING", "UNREGISTERED_DECLARATION" ] diff --git a/docs/contracts/repository-policy-v1.schema.json b/docs/contracts/repository-policy-v1.schema.json index 19bb048..9241dfb 100644 --- a/docs/contracts/repository-policy-v1.schema.json +++ b/docs/contracts/repository-policy-v1.schema.json @@ -548,10 +548,7 @@ "maximum": 9 }, "requiredPath": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "pattern": "^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*){0,7}$" + "$ref": "#/$defs/__schema21" }, "allowFrom": { "default": [], @@ -565,6 +562,51 @@ "requiredPath" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "restrict-state-transition" + }, + "callee": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema20" + } + }, + "argument": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 9 + }, + "field": { + "$ref": "#/$defs/__schema21" + }, + "allowFrom": { + "default": [], + "$ref": "#/$defs/__schema7" + }, + "transitions": { + "default": [], + "minItems": 0, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema22" + } + } + }, + "required": [ + "kind", + "callee", + "field" + ], + "additionalProperties": false } ] }, @@ -625,6 +667,36 @@ "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z_$*][\\w$*]*(?:\\.[A-Za-z_$*][\\w$*]*)*$" + }, + "__schema21": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*){0,7}$" + }, + "__schema22": { + "type": "object", + "properties": { + "from": { + "anyOf": [ + { + "$ref": "#/$defs/__schema19" + }, + { + "type": "string", + "const": "*" + } + ] + }, + "to": { + "$ref": "#/$defs/__schema19" + } + }, + "required": [ + "from", + "to" + ], + "additionalProperties": false } } } diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md index 48e9216..7c762ce 100644 --- a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -56,3 +56,6 @@ Session `autonomous-validator-rule-language-2026-09-09`. Every decision and ever ### Verdict 8: kz-checker on step 2 (require-call-argument) **Verdict:** PASS; both bites reproduced; three deviations acceptable; two follow-ups filed in the run-state (F1 digest EOL portability, F2 bare-identifier alias ceiling). + +### Verdict 9: kz-checker on step 3 (restrict-state-transition) +**Verdict:** PASS; both bites reproduced; benchmark spread judged external load; four caveats acceptable; F3 filed. diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index 055b232..f5325b8 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -6,15 +6,15 @@ - **Branch:** feat/validator-rule-language (off main at 618b4cc) - **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope - **Current phase:** 3-implement -- **In-flight:** step 3 (restrict-state-transition), implementer dispatched; tasks 3.1-3.5 -- **Next action:** Phase 3 step 3 — implementer returns, run kz-checker, commit; then step 4 (owner checkpoint before the route edit) +- **In-flight:** owner checkpoint before step 4 (route dependency-injection seam removal); AUTONOMOUS MODE PAUSED +- **Next action:** on owner confirmation, dispatch step 4 (require-ingress-parse, tasks 4.1-4.6); on refusal, skip to step 5 with FR-ING undelivered and record the block ## Phase Gates | Phase | Gate | Result | Evidence (command + exit / artifact path) | |-------|------|--------|-------------------------------------------| | 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | | 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | -| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md | +| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md. Step 3 restrict-state-transition: verify exit 0, unit 352/49, integration 7/3, self-policy pass 95 files, digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 identical twice, benchmark 6883/4768 ms at parity with step 2 (other samples up to 27 s under 97 percent external load, F3 filed), golden and kernel diffs empty, two bites exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-restrict-state-transition.md | | 4-branch | PR URL recorded | pending | | | 5-optimize | optimization loop termination | pending | | @@ -29,4 +29,5 @@ Full log: docs/plans/2026-09-09-validator-rule-language-advisor-log.md | # | Found in | Item | |---|---|---| | F1 | step 2 check | `manifestDigest` hashes raw working-tree bytes; 26 in-scope files are CRLF in this checkout (`core.autocrlf=true`), so the self-policy integrity digest is deterministic here but differs on a fresh LF clone. Pre-existing. Options: normalize EOL in the manifest digest input (contract change, needs kz-grill) or `git add --renormalize` once. Owner decision. | +| F3 | step 3 check | Benchmark headroom: on this machine under unrelated 97 percent load a first run reached 27 s against the 30 s limit; the evaluator is at parity with step 2 on an idle sample. Environment risk for CI runners under contention; a quiet-machine sample belongs in the PR body. | | F2 | step 2 check | A bare-identifier alias to a matching callee (`const q = tx.policyRevision.findFirst; q({...})`) resolves to chain `q` and passes `require-call-argument` silently; inherited from `resolveCalleeName`, shared with `restrict-call-site`, pinned by a test. Follow-up: alias-following for bare identifiers, or at least `UNRESOLVED` when the const initializer is a matching chain. Changing it moves golden fixtures, so it is its own ADR. | diff --git a/docs/validator-and-hooks.md b/docs/validator-and-hooks.md index e6fb9f0..8a03fe4 100644 --- a/docs/validator-and-hooks.md +++ b/docs/validator-and-hooks.md @@ -67,6 +67,36 @@ Deleting one `workspaceId` from a selector fails the gate with selectors carry the tenant key inside the unique-key object, which the composite index already scopes. +## Governed state transitions + +The self-policy rule `policy-revision-state-is-governed` uses +`restrict-state-transition` to prove that `*.policyRevision.updateMany` +writes `data.state` only from `packages/persistence/src/policies.ts`, and +only as `draft->approved`, `approved->active`, or `active->superseded`, read +from the literal `where.state` predicate and the literal `data.state` value. + +```json +"check": { + "kind": "restrict-state-transition", + "callee": ["*.policyRevision.updateMany"], + "argument": 0, + "field": "data.state", + "allowFrom": ["packages/persistence/src/policies.ts"], + "transitions": [ + { "from": "draft", "to": "approved" }, + { "from": "approved", "to": "active" }, + { "from": "active", "to": "superseded" } + ] +} +``` + +A state write anywhere else fails the gate with `STATE_TRANSITION_DENIED` +and the callee chain as subject; a write in `policies.ts` through an +unlisted pair fails with the same code and subject `transition:state:->`; +a write whose `where` carries no literal `state`, or whose `data` is a spread, +fails with `STATE_TRANSITION_PROOF_FAILED`. Writes that touch other columns +are ignored. + ## Agent-facing commands `kernel-zero explain --policy|--evidence ` renders one strictly parsed diff --git a/kernel-zero.policy.json b/kernel-zero.policy.json index fbd7912..38ce03d 100644 --- a/kernel-zero.policy.json +++ b/kernel-zero.policy.json @@ -223,6 +223,37 @@ ] }, "remediation": "Put workspaceId in the where selector of every tenant-scoped query so a sibling tenant's rows are never reachable." + }, + { + "id": "policy-revision-state-is-governed", + "title": "Policy revision state changes only through the governed lifecycle", + "level": "error", + "check": { + "kind": "restrict-state-transition", + "callee": [ + "*.policyRevision.updateMany" + ], + "argument": 0, + "field": "data.state", + "allowFrom": [ + "packages/persistence/src/policies.ts" + ], + "transitions": [ + { + "from": "draft", + "to": "approved" + }, + { + "from": "approved", + "to": "active" + }, + { + "from": "active", + "to": "superseded" + } + ] + }, + "remediation": "Change a policy revision's state only from policies.ts, with a literal where.state predicate, through draft->approved, approved->active, or active->superseded." } ] } diff --git a/packages/profile-software-architecture/src/compatibility.test.ts b/packages/profile-software-architecture/src/compatibility.test.ts index 5750922..784337a 100644 --- a/packages/profile-software-architecture/src/compatibility.test.ts +++ b/packages/profile-software-architecture/src/compatibility.test.ts @@ -11,6 +11,8 @@ const PW = { allowFrom: ["src/dataplane/state/**"], files: ["src/**/*.ts"], kind const CA = { allowFrom: ["src/audit.ts"], argument: 0, callee: ["*.findFirst", "db.policy.*"], files: ["src/**/*.ts"], kind: "require-call-argument", requiredPath: "where.workspaceId" }; +const ST = { allowFrom: ["src/persistence/policies.ts"], argument: 0, callee: ["*.policyRevision.updateMany"], field: "data.state", kind: "restrict-state-transition", transitions: [{ from: "draft", to: "approved" }] }; + const checkCases: readonly Readonly<{ check: Record; code: FindingMessageCode; @@ -37,6 +39,11 @@ const checkCases: readonly Readonly<{ { check: CA, code: "CALL_ARGUMENT_PROOF_FAILED", subject: "call:db.policy.findMany:argument:0:where.workspaceId" }, { check: CA, code: "CALL_ARGUMENT_PROOF_FAILED", subject: "call:db.policy.*:argument:0:where.workspaceId" }, { check: CA, code: "CALL_ARGUMENT_PROOF_FAILED", subject: "call:*.findFirst:argument:0:where.workspaceId" }, + { check: ST, code: "STATE_TRANSITION_DENIED", subject: "transition:state:tx.policyRevision.updateMany" }, + { check: ST, code: "STATE_TRANSITION_DENIED", subject: "transition:state:draft->active" }, + { check: ST, code: "STATE_TRANSITION_DENIED", subject: "transition:state:*->active" }, + { check: ST, code: "STATE_TRANSITION_PROOF_FAILED", subject: "transition:state:tx.policyRevision.updateMany" }, + { check: ST, code: "STATE_TRANSITION_PROOF_FAILED", subject: "transition:state:*.policyRevision.updateMany" }, ]; function policy(check: Record) { @@ -123,6 +130,18 @@ describe("evidence rule compatibility", () => { expect(findingCompatibilityReason(layered, finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:0:where.workspaceId"))).toBeNull(); }); + it("rejects state-transition subjects with another leaf, a chain outside the callee globs, a malformed pair, or a pair on a proof failure", () => { + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_DENIED", "transition:status:tx.policyRevision.updateMany"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_DENIED", "transition:state:tx.policyPack.updateMany"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_DENIED", "transition:state:draft->"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_DENIED", "transition:state:draft->a->b"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_DENIED", "transition:state:draft->*"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_DENIED", "transition:state:in progress->done"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("STATE_TRANSITION_PROOF_FAILED", "transition:state:draft->active"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ST), finding("CALL_ARGUMENT_MISSING", "transition:state:draft->active"))).toBe("rule_code_mismatch"); + expect(findingCompatibilityReason(policy({ ...ST, field: "state" }), finding("STATE_TRANSITION_DENIED", "transition:state:draft->active"))).toBeNull(); + }); + it("resolves layer references before matching so a layered rule accepts its finding", () => { const layered = RepositoryPolicySchema.parse({ apiVersion: "kernel-zero.dev/v1", diff --git a/packages/profile-software-architecture/src/compatibility.ts b/packages/profile-software-architecture/src/compatibility.ts index 72e939d..826ac64 100644 --- a/packages/profile-software-architecture/src/compatibility.ts +++ b/packages/profile-software-architecture/src/compatibility.ts @@ -1,7 +1,7 @@ import type { EvidenceFinding } from "@kernel-zero/contracts"; import { resolvePolicyLayers } from "./layers"; -import { CalleeGlobSchema, type RepositoryPolicy } from "./policy"; +import { CalleeGlobSchema, IdentifierSchema, type RepositoryPolicy } from "./policy"; const messageCodesByKind: Readonly> = Object.freeze({ "forbid-import-edge": ["DENIED_IMPORT"], @@ -15,6 +15,7 @@ const messageCodesByKind: Readonly"); + return rest.length === 0 && from !== undefined && to !== undefined + && (from === "*" || IdentifierSchema.safeParse(from).success) && IdentifierSchema.safeParse(to).success + ? null + : "rule_subject_mismatch"; + } } } diff --git a/packages/profile-software-architecture/src/evidence.test.ts b/packages/profile-software-architecture/src/evidence.test.ts index 81c5d83..5195106 100644 --- a/packages/profile-software-architecture/src/evidence.test.ts +++ b/packages/profile-software-architecture/src/evidence.test.ts @@ -100,6 +100,24 @@ describe("repository evidence contract", () => { } }); + it("publishes the state-transition codes with their exact messages and accepts both subject forms in findings", () => { + expect(findingMessage("STATE_TRANSITION_DENIED")).toBe("A governed state field is written outside its allowed writer or through an unlisted transition."); + expect(findingMessage("STATE_TRANSITION_PROOF_FAILED")).toBe("A write to a governed state field could not be proven against the allowed transitions."); + const original = validEvidence(); + const finding = onlyFinding(original); + const cases = [ + ["STATE_TRANSITION_DENIED", "transition:state:tx.policyRevision.updateMany"], + ["STATE_TRANSITION_DENIED", "transition:state:draft->active"], + ["STATE_TRANSITION_PROOF_FAILED", "transition:state:tx.policyRevision.updateMany"], + ] as const; + for (const [messageCode, subject] of cases) { + const identity = findingIdentity({ location, messageCode, path: finding.path, policyDigest: original.policy.digest, ruleId: finding.ruleId, subject }); + const rewritten = { ...finding, ...identity, message: findingMessage(messageCode), messageCode, subject }; + const base = { ...original, findings: [rewritten] }; + expect(RepositoryEvidenceSchema.safeParse({ ...base, integrity: { algorithm: "sha256", digest: canonicalEvidenceDigest(base) } }).success).toBe(true); + } + }); + it("excludes run metadata and diagnostic duration from integrity", () => { const original = validEvidence(); expect(canonicalEvidenceDigest({ ...original, runId: "0195f000-0000-7000-8000-000000000099" })).toBe(original.integrity.digest); diff --git a/packages/profile-software-architecture/src/evidence.ts b/packages/profile-software-architecture/src/evidence.ts index 7d6eba1..8fa6b7a 100644 --- a/packages/profile-software-architecture/src/evidence.ts +++ b/packages/profile-software-architecture/src/evidence.ts @@ -17,6 +17,8 @@ export const FINDING_MESSAGE_CODES = Object.freeze([ "REQUIRED_EXPORT_KEY_MISSING", "REQUIRED_IMPORT_MISSING", "RESTRICTED_CALL", + "STATE_TRANSITION_DENIED", + "STATE_TRANSITION_PROOF_FAILED", "TENANT_PARAMETER_MISSING", "UNREGISTERED_DECLARATION", ] as const); @@ -39,6 +41,8 @@ const messages: Readonly> & EvidenceMessages REQUIRED_EXPORT_KEY_MISSING: "A required exported object key is missing.", REQUIRED_IMPORT_MISSING: "A required module import is missing.", RESTRICTED_CALL: "A restricted call was found outside its allowed location.", + STATE_TRANSITION_DENIED: "A governed state field is written outside its allowed writer or through an unlisted transition.", + STATE_TRANSITION_PROOF_FAILED: "A write to a governed state field could not be proven against the allowed transitions.", TENANT_PARAMETER_MISSING: "A tenant-scoped symbol is missing its required tenant parameter.", UNREGISTERED_DECLARATION: "A declaration is not present in its required closed registry.", }); diff --git a/packages/profile-software-architecture/src/policy.test.ts b/packages/profile-software-architecture/src/policy.test.ts index 832277e..0a51f0c 100644 --- a/packages/profile-software-architecture/src/policy.test.ts +++ b/packages/profile-software-architecture/src/policy.test.ts @@ -58,11 +58,21 @@ describe("RepositoryPolicy v1 contract", () => { ["call argument negative index", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: -1, requiredPath: "where" } }] }], ["call argument fractional index", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: 0.5, requiredPath: "where" } }] }], ["call argument unknown field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], requiredPath: "where", regex: ".*" } }] }], + ["state transition missing field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"] } }] }], + ["state transition glob in field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.*" } }] }], + ["state transition empty callee list", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: [], field: "data.state" } }] }], + ["state transition dotted from", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", transitions: [{ from: "a.b", to: "c" }] } }] }], + ["state transition wildcard to", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", transitions: [{ from: "draft", to: "*" }] } }] }], + ["state transition empty to", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", transitions: [{ from: "draft", to: "" }] } }] }], + ["state transition extra pair key", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", transitions: [{ from: "draft", to: "approved", via: "x" }] } }] }], + ["state transition duplicate pair", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", transitions: [{ from: "draft", to: "approved" }, { from: "draft", to: "approved" }] } }] }], + ["state transition index above nine", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", argument: 10 } }] }], + ["state transition unknown field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", files: ["src/**"] } }] }], ])("rejects %s", (_label, value) => { expect(RepositoryPolicySchema.safeParse(value).success).toBe(false); }); - it("accepts all eleven closed check variants and rejects variant-only drift", () => { + it("accepts all twelve closed check variants and rejects variant-only drift", () => { const checks = [ { kind: "forbid-import-edge", from: ["apps/**"], deny: ["module:x"] }, { kind: "require-import", files: ["apps/**"], module: "server-only", allowTypeOnly: false }, @@ -79,6 +89,8 @@ describe("RepositoryPolicy v1 contract", () => { { kind: "restrict-property-write", files: ["src/**/*.ts"], targetType: { file: "src/domain/job.ts", exportName: "Job" }, property: "status", allowFrom: [] }, { kind: "require-call-argument", files: ["packages/persistence/src/**/*.ts"], callee: ["*.findFirst", "*.findMany", "prisma.*.updateMany", "$db.count"], argument: 0, requiredPath: "where.workspaceId", allowFrom: ["packages/persistence/src/audit.ts"] }, { kind: "require-call-argument", files: ["src/**"], callee: ["*"], requiredPath: "a.b.c.d.e.f.g.h" }, + { kind: "restrict-state-transition", callee: ["*.policyRevision.updateMany"], argument: 0, field: "data.state", allowFrom: ["packages/persistence/src/policies.ts"], transitions: [{ from: "draft", to: "approved" }, { from: "*", to: "superseded" }] }, + { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "state" }, ]; for (const [index, check] of checks.entries()) { const result = RepositoryPolicySchema.safeParse({ @@ -97,6 +109,14 @@ describe("RepositoryPolicy v1 contract", () => { expect(parsed.rules[0]?.check).toEqual({ kind: "require-call-argument", files: ["src/**"], callee: ["*.findMany"], argument: 0, requiredPath: "where.workspaceId", allowFrom: [] }); }); + it("defaults restrict-state-transition's argument to 0, allowFrom to [], and transitions to []", () => { + const parsed = RepositoryPolicySchema.parse({ + ...validPolicy, + rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state" } }], + }); + expect(parsed.rules[0]?.check).toEqual({ kind: "restrict-state-transition", callee: ["*.updateMany"], argument: 0, field: "data.state", allowFrom: [], transitions: [] }); + }); + describe("layers", () => { const extraLayers = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, index) => [`layer-${String(index)}`, ["apps/**"]])); const layeredRule = { ...validPolicy.rules[0], check: { kind: "forbid-import-edge", from: ["layer:ui", "packages/**"], deny: ["module:@prisma/client"] } }; diff --git a/packages/profile-software-architecture/src/policy.ts b/packages/profile-software-architecture/src/policy.ts index 047c514..e3d1af5 100644 --- a/packages/profile-software-architecture/src/policy.ts +++ b/packages/profile-software-architecture/src/policy.ts @@ -24,7 +24,9 @@ const ModuleDenialSchema = z.string().min(6).max(500).refine( "Denied targets must declare module, module-prefix, or path semantics.", ); -const IdentifierSchema = z.string().min(1).max(200).regex(/^[A-Za-z_$][\w$]*$/u, "Export names must be a single identifier."); +export const IdentifierSchema = z.string().min(1).max(200).regex(/^[A-Za-z_$][\w$]*$/u, "Export names must be a single identifier."); +/** One legal state pair; `from: "*"` allows any source state for that `to`. */ +const StateTransitionSchema = z.strictObject({ from: z.union([IdentifierSchema, z.literal("*")]), to: IdentifierSchema }); /** A glob over a resolved callee chain such as `*.findMany` or `prisma.*.updateMany`; `*` spans dots. */ export const CalleeGlobSchema = z.string().min(1).max(200).regex(/^[A-Za-z_$*][\w$*]*(?:\.[A-Za-z_$*][\w$*]*)*$/u, "Callee globs must be dotted identifier segments, each of which may contain *."); /** 1..8 identifier segments joined by `.`, such as `where.workspaceId`. */ @@ -83,6 +85,14 @@ export const PolicyCheckSchema = z.discriminatedUnion("kind", [ requiredPath: DottedPathSchema, allowFrom: OptionalRuleGlobList.default([]), }), + z.strictObject({ + kind: z.literal("restrict-state-transition"), + callee: uniqueArray(CalleeGlobSchema, 1, 100), + argument: z.number().int().min(0).max(9).default(0), + field: DottedPathSchema, + allowFrom: OptionalRuleGlobList.default([]), + transitions: uniqueArray(StateTransitionSchema, 0, 100).default([]), + }), ]); const PolicyRuleSchema = z.strictObject({ diff --git a/packages/validator/README.md b/packages/validator/README.md index 8e64232..6f6f9fd 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -87,6 +87,37 @@ spread or computed key on the path. Anything the validator cannot prove is } ``` +## Governed state transitions + +`restrict-state-transition` proves that a state field is written only from +its allowed writer files and, when `transitions` are listed, only through a +listed `from -> to` pair, read from the literal `where` predicate and the +literal written value. A write outside `allowFrom` or through an unlisted +pair is `STATE_TRANSITION_DENIED`; a write the validator cannot prove (a +spread, a value built elsewhere, a predicate without the field) is +`STATE_TRANSITION_PROOF_FAILED`. Calls that do not write the field are +ignored. + +```json +{ + "id": "policy-revision-state-is-governed", + "title": "Policy revision state changes only through the governed lifecycle", + "level": "error", + "check": { + "kind": "restrict-state-transition", + "callee": ["*.policyRevision.updateMany"], + "field": "data.state", + "allowFrom": ["src/persistence/policies.ts"], + "transitions": [ + { "from": "draft", "to": "approved" }, + { "from": "approved", "to": "active" }, + { "from": "active", "to": "superseded" } + ] + }, + "remediation": "Change revision state only from policies.ts through a listed transition." +} +``` + ## Explain and init ```text diff --git a/packages/validator/fixtures/kinds/state-transition/allowed/writer.ts b/packages/validator/fixtures/kinds/state-transition/allowed/writer.ts new file mode 100644 index 0000000..688b84a --- /dev/null +++ b/packages/validator/fixtures/kinds/state-transition/allowed/writer.ts @@ -0,0 +1,12 @@ +declare const db: { policyRevision: { updateMany(a: unknown): void } }; +declare const id: string; +declare const patch: object; +declare const key: string; + +db.policyRevision.updateMany({ data: { state: "approved" }, where: { id, state: "draft" } }); +db.policyRevision.updateMany({ data: { state: "active" }, where: { id, state: "approved" } }); +db.policyRevision.updateMany({ data: { state: "active" }, where: { id, state: "draft" } }); +db.policyRevision.updateMany({ data: { state: "approved" }, where: { id } }); +db.policyRevision.updateMany({ data: { ...patch }, where: { id, state: "draft" } }); +db.policyRevision.updateMany({ data: { canonicalJson: "{}" }, where: { id } }); +db[key].updateMany({ data: { state: "approved" }, where: { id, state: "draft" } }); diff --git a/packages/validator/fixtures/kinds/state-transition/elsewhere/rogue.ts b/packages/validator/fixtures/kinds/state-transition/elsewhere/rogue.ts new file mode 100644 index 0000000..f486696 --- /dev/null +++ b/packages/validator/fixtures/kinds/state-transition/elsewhere/rogue.ts @@ -0,0 +1,3 @@ +declare const db: { policyRevision: { updateMany(a: unknown): void } }; + +db.policyRevision.updateMany({ data: { state: "approved" }, where: { state: "draft" } }); diff --git a/packages/validator/src/checks/state-transition.test.ts b/packages/validator/src/checks/state-transition.test.ts new file mode 100644 index 0000000..eac3d45 --- /dev/null +++ b/packages/validator/src/checks/state-transition.test.ts @@ -0,0 +1,78 @@ +import { fileURLToPath } from "node:url"; + +import type { RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; +import { describe, expect, it } from "vitest"; + +import { createRepositoryProgram, evaluatePolicyChecks } from "../engine"; + +const COMPILER_TEST_TIMEOUT_MS = 30_000; +const fixtureRoot = fileURLToPath(new URL("../../fixtures/kinds/", import.meta.url)); +type Check = Extract; + +const WRITER = "state-transition/allowed/writer.ts"; +const ROGUE = "state-transition/elsewhere/rogue.ts"; +const CHAIN = "transition:state:db.policyRevision.updateMany"; +const GLOB = "transition:state:*.policyRevision.updateMany"; + +function evaluate(check: Partial = {}, level: "error" | "warning" = "error") { + const repository = createRepositoryProgram({ rootPath: fixtureRoot, filePaths: [WRITER, ROGUE] }); + return evaluatePolicyChecks({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + metadata: { name: "state-transition-test", revision: 1, description: "State transition fixture policy" }, + scope: { languages: ["typescript"], include: ["**/*.ts"], exclude: [] }, + rules: [{ + id: "policy-revision-state-is-governed", + title: "Policy revision state is governed", + level, + check: { + kind: "restrict-state-transition", + callee: ["*.policyRevision.updateMany"], + argument: 0, + field: "data.state", + allowFrom: ["state-transition/allowed/**"], + transitions: [{ from: "draft", to: "approved" }, { from: "approved", to: "active" }], + ...check, + }, + remediation: "Write state only from the governed writer through a listed transition.", + }], + }, repository).map(({ messageCode, subject, path, location }) => [messageCode, subject, path, `${String(location.startLine)}:${String(location.startColumn)}-${String(location.endLine)}:${String(location.endColumn)}`]); +} + +describe("restrict-state-transition", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { + it("emits the exact finding matrix over both fixtures in engine order", () => { + expect(evaluate()).toEqual([ + ["STATE_TRANSITION_DENIED_PAIR", "transition:state:draft->active", WRITER, "8:1-8:91"], + ["STATE_TRANSITION_UNPROVABLE", CHAIN, WRITER, "9:1-9:77"], + ["STATE_TRANSITION_UNPROVABLE", CHAIN, WRITER, "10:1-10:84"], + ["STATE_TRANSITION_UNPROVABLE", GLOB, WRITER, "12:1-12:83"], + ["STATE_TRANSITION_DENIED_WRITER", CHAIN, ROGUE, "3:1-3:89"], + ]); + }); + + it("checks only the writer when transitions are empty", () => { + expect(evaluate({ transitions: [] })).toEqual([ + ["STATE_TRANSITION_UNPROVABLE", CHAIN, WRITER, "10:1-10:84"], + ["STATE_TRANSITION_UNPROVABLE", GLOB, WRITER, "12:1-12:83"], + ["STATE_TRANSITION_DENIED_WRITER", CHAIN, ROGUE, "3:1-3:89"], + ]); + }); + + it("accepts a wildcard source state and denies every writer when allowFrom is empty", () => { + expect(evaluate({ transitions: [{ from: "*", to: "approved" }, { from: "*", to: "active" }] }).filter(([code]) => code === "STATE_TRANSITION_DENIED_PAIR")).toEqual([]); + expect(evaluate({ allowFrom: [] }).filter(([code]) => code === "STATE_TRANSITION_DENIED_WRITER")).toHaveLength(5); + }); + + it("reports unresolved callees only at error level", () => { + const warnings = evaluate({}, "warning"); + expect(warnings.filter(([, subject]) => subject === GLOB)).toEqual([]); + expect(warnings).toHaveLength(4); + }); + + it("ignores resolved calls whose chain matches no callee glob and reports the unresolved call under the glob whose ends agree", () => { + expect(evaluate({ callee: ["*.policyPack.updateMany"] })).toEqual([ + ["STATE_TRANSITION_UNPROVABLE", "transition:state:*.policyPack.updateMany", WRITER, "12:1-12:83"], + ]); + expect(evaluate({ callee: ["*.policyPack.create"] })).toEqual([]); + }); +}); diff --git a/packages/validator/src/checks/state-transition.ts b/packages/validator/src/checks/state-transition.ts new file mode 100644 index 0000000..a537a99 --- /dev/null +++ b/packages/validator/src/checks/state-transition.ts @@ -0,0 +1,72 @@ +import ts from "typescript"; + +import { walk } from "../ast"; +import { forEachMatchingSource, matchesGlob, nodeLocation, rawFinding, type CheckEvaluator, type RawFindingMessageCode } from "../findings"; +import { chainMatches, couldMatchCallee, proveObjectPath, resolveCallChain } from "./argument-shape"; + +/** + * Every call in scope whose callee matches is inspected; a call whose argument provably + * writes `field` must come from `allowFrom`, and when `transitions` are listed its literal + * `field` value paired with the literal predicate value (`field` with `where` as its first + * segment) must be one of them. Anything not provable is a proof failure, never a pass. + */ +export const evaluateStateTransitions: CheckEvaluator<"restrict-state-transition"> = (rule, repository, failedPaths, findings) => { + const checker = repository.program.getTypeChecker(); + const { allowFrom, argument, callee, field, transitions } = rule.check; + const fieldPath = field.split("."); + const predicatePath = ["where", ...fieldPath.slice(1)]; + const subjectFor = (target: string): string => `transition:${fieldPath.at(-1) ?? field}:${target}`; + forEachMatchingSource(repository, ["**/*"], failedPaths, (filePath, sourceFile) => { + const allowedWriter = allowFrom.some((glob) => matchesGlob(filePath, glob)); + walk(sourceFile, (node) => { + if (!ts.isCallExpression(node)) { + return; + } + const push = (code: RawFindingMessageCode, subject: string): void => { + findings.push(rawFinding(rule, code, subject, filePath, nodeLocation(sourceFile, node))); + }; + const resolved = resolveCallChain(node, checker); + if (resolved === undefined) { + const glob = rule.level === "error" ? couldMatchCallee(node, callee) : undefined; + if (glob !== undefined) { + push("STATE_TRANSITION_UNPROVABLE", subjectFor(glob)); + } + return; + } + if (!chainMatches(resolved.chain, callee)) { + return; + } + const chainSubject = subjectFor(resolved.chain); + if (resolved.unsafeReceiver) { + push("STATE_TRANSITION_UNPROVABLE", chainSubject); + return; + } + const target = node.arguments[argument]; + const write = proveObjectPath(target, fieldPath, checker, sourceFile); + if (write.kind === "missing") { + return; + } + if (write.kind === "unprovable") { + push("STATE_TRANSITION_UNPROVABLE", chainSubject); + return; + } + if (!allowedWriter) { + push("STATE_TRANSITION_DENIED_WRITER", chainSubject); + return; + } + if (transitions.length === 0) { + return; + } + const to = write.literal; + const from = proveObjectPath(target, predicatePath, checker, sourceFile); + if (to === undefined || from.kind !== "present" || from.literal === undefined) { + push("STATE_TRANSITION_UNPROVABLE", chainSubject); + return; + } + const fromLiteral = from.literal; + if (!transitions.some((transition) => (transition.from === "*" || transition.from === fromLiteral) && transition.to === to)) { + push("STATE_TRANSITION_DENIED_PAIR", subjectFor(`${fromLiteral}->${to}`)); + } + }); + }); +}; diff --git a/packages/validator/src/engine.ts b/packages/validator/src/engine.ts index 536ca0a..adec774 100644 --- a/packages/validator/src/engine.ts +++ b/packages/validator/src/engine.ts @@ -9,6 +9,7 @@ import { evaluateGovernedOperations } from "./checks/governed"; import { evaluateForbiddenImports, evaluateRequiredImports } from "./checks/imports"; import { evaluatePropertyWrites } from "./checks/property-write"; import { evaluateClosedRegistry } from "./checks/registry"; +import { evaluateStateTransitions } from "./checks/state-transition"; import { evaluateTenantParameters } from "./checks/tenant"; import { compareFindings, @@ -100,6 +101,9 @@ function evaluateRule( case "require-call-argument": evaluateCallArguments({ ...rule, check: rule.check }, repository, failedPaths, findings); return; + case "restrict-state-transition": + evaluateStateTransitions({ ...rule, check: rule.check }, repository, failedPaths, findings); + return; } } @@ -108,6 +112,7 @@ function ruleClaimsPath(check: PolicyCheck, filePath: string): boolean { case "forbid-import-edge": return check.from.some((glob) => matchesGlob(filePath, glob)); case "restrict-call-site": + case "restrict-state-transition": return true; case "require-context-parameter": return (check.expectedType?.kind === "export" && check.expectedType.file === filePath) diff --git a/packages/validator/src/findings.ts b/packages/validator/src/findings.ts index 7b53edf..636937d 100644 --- a/packages/validator/src/findings.ts +++ b/packages/validator/src/findings.ts @@ -32,7 +32,10 @@ export type RawFindingMessageCode = | "PROPERTY_TARGET_UNRESOLVED" | "CALL_ARGUMENT_MISSING" | "CALL_ARGUMENT_UNPROVABLE" - | "CALL_ARGUMENT_UNRESOLVED"; + | "CALL_ARGUMENT_UNRESOLVED" + | "STATE_TRANSITION_DENIED_WRITER" + | "STATE_TRANSITION_DENIED_PAIR" + | "STATE_TRANSITION_UNPROVABLE"; export interface RawFindingLocation { readonly startLine: number; diff --git a/packages/validator/src/runner.ts b/packages/validator/src/runner.ts index b36720f..9f2208e 100644 --- a/packages/validator/src/runner.ts +++ b/packages/validator/src/runner.ts @@ -214,5 +214,8 @@ function publicMessageCode(code: RawFindingMessageCode): FindingMessageCode { case "CALL_ARGUMENT_MISSING": return "CALL_ARGUMENT_MISSING"; case "CALL_ARGUMENT_UNPROVABLE": case "CALL_ARGUMENT_UNRESOLVED": return "CALL_ARGUMENT_PROOF_FAILED"; + case "STATE_TRANSITION_DENIED_WRITER": + case "STATE_TRANSITION_DENIED_PAIR": return "STATE_TRANSITION_DENIED"; + case "STATE_TRANSITION_UNPROVABLE": return "STATE_TRANSITION_PROOF_FAILED"; } } diff --git a/scripts/benchmark-validator.ts b/scripts/benchmark-validator.ts index 41f4413..4bf9970 100644 --- a/scripts/benchmark-validator.ts +++ b/scripts/benchmark-validator.ts @@ -35,6 +35,12 @@ const policy = { level: "error", remediation: "Add where.workspaceId to the selector.", title: "Tenant queries carry the workspace", + }, { + check: { allowFrom: ["src/**"], callee: ["*.updateMany"], field: "data.state", kind: "restrict-state-transition", transitions: [{ from: "draft", to: "approved" }] }, + id: "state-is-governed", + level: "error", + remediation: "Write state through a listed transition.", + title: "State is governed", }], scope: { exclude: [], include: ["src/**/*.ts"], languages: ["typescript"] }, }; diff --git a/scripts/generate-contracts.ts b/scripts/generate-contracts.ts index 1ac370c..4336fd4 100644 --- a/scripts/generate-contracts.ts +++ b/scripts/generate-contracts.ts @@ -164,11 +164,11 @@ const outputs = new Map([ ["docs/contracts/malformed/repository-policy-path-escape.json", `${JSON.stringify({ ...policyExample, scope: { ...policyExample.scope, include: ["../private.ts"] } }, null, 2)}\n`], ["docs/contracts/malformed/exception-grant-set-private-data.json", `${JSON.stringify({ ...exceptionExample, rationale: "must never be exported" }, null, 2)}\n`], ["docs/contracts/malformed/repository-evidence-source-content.json", `${JSON.stringify({ ...evidenceExample, source: "private source text" }, null, 2)}\n`], - ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, \`restrict-property-write\`, and \`require-call-argument\`. + ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, \`restrict-property-write\`, \`require-call-argument\`, and \`restrict-state-transition\`. \`layers\` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (\`from\`, \`files\`, \`allowFrom\`, \`declarationFiles\`) may carry \`layer:\` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. \`scope.include\` and \`scope.exclude\` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside \`scope\`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without \`layers\` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. -\`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`require-call-argument\` proves that every call in \`files\` (minus \`allowFrom\`) whose resolved callee chain matches one of \`callee\` carries \`requiredPath\` in the argument at index \`argument\` (0 to 9, default 0). A callee glob is dotted identifier segments where \`*\` spans dots (\`*.findMany\`, \`prisma.*.updateMany\`); \`requiredPath\` is 1 to 8 identifier segments (\`where.workspaceId\`). The argument is proven when it is an object literal, an \`Object.freeze\` of one, or an identifier bound by a same-file \`const\` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal \`undefined\` or \`void 0\`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: \`CALL_ARGUMENT_MISSING\` when the argument is provable and the path is absent or \`undefined\`, and \`CALL_ARGUMENT_PROOF_FAILED\` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`, or (at \`error\` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject \`call::argument::\`, where \`\` is the resolved callee chain or, for an unresolved callee, the matching glob itself.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], +\`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`require-call-argument\` proves that every call in \`files\` (minus \`allowFrom\`) whose resolved callee chain matches one of \`callee\` carries \`requiredPath\` in the argument at index \`argument\` (0 to 9, default 0). A callee glob is dotted identifier segments where \`*\` spans dots (\`*.findMany\`, \`prisma.*.updateMany\`); \`requiredPath\` is 1 to 8 identifier segments (\`where.workspaceId\`). The argument is proven when it is an object literal, an \`Object.freeze\` of one, or an identifier bound by a same-file \`const\` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal \`undefined\` or \`void 0\`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: \`CALL_ARGUMENT_MISSING\` when the argument is provable and the path is absent or \`undefined\`, and \`CALL_ARGUMENT_PROOF_FAILED\` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`, or (at \`error\` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject \`call::argument::\`, where \`\` is the resolved callee chain or, for an unresolved callee, the matching glob itself.\n\n\`restrict-state-transition\` proves that a state field is written only by its allowed writer and only through listed transitions. It inspects every call in scope whose resolved callee chain matches one of \`callee\` (the same glob grammar as \`require-call-argument\`); a call whose argument at index \`argument\` (0 to 9, default 0) provably carries \`field\` (1 to 8 identifier segments, for example \`data.state\`) is a write and must sit in an \`allowFrom\` file, and an empty \`allowFrom\` (the default) permits no writer. When \`transitions\` (default empty) lists pairs \`{ from, to }\`, where \`from\` is an identifier or \`*\` and \`to\` an identifier, every allowed write must carry a string-literal \`field\` value and a string-literal predicate value at \`field\` with its first segment replaced by \`where\` (\`where.state\` for \`data.state\`), and the pair must be listed. A call whose argument does not carry \`field\` is ignored. Codes: \`STATE_TRANSITION_DENIED\` with subject \`transition::\` for a writer outside \`allowFrom\` or \`transition::->\` for an unlisted pair, and \`STATE_TRANSITION_PROOF_FAILED\` with subject \`transition::\` when the write cannot be proven (an opaque spread or non-literal computed key on the path, a value built elsewhere, a const chain that cycles, a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`), when \`transitions\` are listed and the written value or the predicate is not a string literal or the predicate is absent, or (at \`error\` level) when a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob, in which case \`\` is that glob. \`\` is the last segment of \`field\`; the two subject forms never collide because a chain segment cannot contain \`-\`.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], ]); const check = process.argv.includes("--check"); From 0c544a33cd5c53de31478b9103458dd6bebdbd64 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 13:13:52 -0700 Subject: [PATCH 5/7] Add the require-ingress-parse check kind and prove the evidence route parses its input --- .../app/api/evidence/v1/runs/route.test.ts | 53 ++-- .../src/app/api/evidence/v1/runs/route.ts | 82 +++--- docs/adr/2026-09-09-require-ingress-parse.md | 271 +++++++++++++++++ docs/contracts/README.md | 4 +- .../repository-evidence-v1.schema.json | 3 + .../repository-policy-v1.schema.json | 41 +++ ...-09-validator-rule-language-advisor-log.md | 8 + ...09-09-validator-rule-language-run-state.md | 6 +- docs/validator-and-hooks.md | 30 ++ kernel-zero.policy.json | 115 ++++++++ .../src/compatibility.test.ts | 22 ++ .../src/compatibility.ts | 23 ++ .../src/evidence.test.ts | 20 ++ .../src/evidence.ts | 6 + .../src/policy.test.ts | 19 +- .../src/policy.ts | 11 + packages/validator/README.md | 30 ++ .../fixtures/kinds/ingress/handlers.ts | 63 ++++ packages/validator/src/checks/ingress.test.ts | 83 ++++++ packages/validator/src/checks/ingress.ts | 273 ++++++++++++++++++ packages/validator/src/engine.ts | 5 + packages/validator/src/findings.ts | 5 +- packages/validator/src/runner.ts | 3 + scripts/benchmark-validator.ts | 6 + scripts/generate-contracts.ts | 4 +- 25 files changed, 1109 insertions(+), 77 deletions(-) create mode 100644 docs/adr/2026-09-09-require-ingress-parse.md create mode 100644 packages/validator/fixtures/kinds/ingress/handlers.ts create mode 100644 packages/validator/src/checks/ingress.test.ts create mode 100644 packages/validator/src/checks/ingress.ts diff --git a/apps/control/src/app/api/evidence/v1/runs/route.test.ts b/apps/control/src/app/api/evidence/v1/runs/route.test.ts index 40113d5..4292424 100644 --- a/apps/control/src/app/api/evidence/v1/runs/route.test.ts +++ b/apps/control/src/app/api/evidence/v1/runs/route.test.ts @@ -1,48 +1,61 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { createEvidencePostHandler, type EvidenceRouteDependencies } from "./route"; +import { POST } from "./route"; const mediaType = "application/vnd.kernel-zero.evidence+json;version=1"; -const context = { - correlationId: "0195f000-0000-7000-8000-000000000004", - workspaceId: "0195f000-0000-7000-8000-000000000002", -}; +const evidenceToken = "test-evidence-token-with-at-least-32-characters"; +const workspaceId = "0195f000-0000-7000-8000-000000000002"; +const correlationId = "0195f000-0000-7000-8000-000000000004"; +const runId = "0195f000-0000-7000-8000-000000000001"; + +const { submit } = vi.hoisted(() => ({ submit: vi.fn() })); + +vi.mock("../../../../../server/runtime", () => ({ + getRuntime: () => ({ + config: { databaseUrl: "postgresql://unused", environment: "test", evidenceToken, workspaceId }, + prisma: {}, + }), +})); + +vi.mock("../../../../../server/evidence/evidence-service", () => ({ + EvidenceService: class { + submit = submit; + }, +})); function request(headers: Record = {}): Request { return new Request("https://example.invalid/api/evidence/v1/runs", { body: "{}", - headers: { "content-type": mediaType, ...headers }, + headers: { authorization: `Bearer ${evidenceToken}`, "content-type": mediaType, "x-correlation-id": correlationId, ...headers }, method: "POST", }); } -function dependencies(kind: "created" | "duplicate" = "created"): EvidenceRouteDependencies { - return { - resolveSubmission: () => Promise.resolve(context), - service: { submit: () => Promise.resolve({ attestationState: "recorded", kind, runId: "0195f000-0000-7000-8000-000000000001" }) }, - }; -} - describe("POST /api/evidence/v1/runs", () => { it("requires the evidence bearer token", async () => { - const response = await createEvidencePostHandler({ ...dependencies(), resolveSubmission: () => Promise.resolve(null) })(request()); + const response = await POST(new Request("https://example.invalid/api/evidence/v1/runs", { body: "{}", headers: { "content-type": mediaType }, method: "POST" })); expect(response.status).toBe(401); - await expect(response.json()).resolves.toMatchObject({ error: { code: "UNAUTHENTICATED" } }); + await expect(response.json()).resolves.toMatchObject({ error: { code: "UNAUTHENTICATED", correlationId: "unavailable" } }); + expect(submit).not.toHaveBeenCalled(); }); it("maps created and idempotent duplicate submissions", async () => { - const created = await createEvidencePostHandler(dependencies("created"))(request()); + submit.mockResolvedValueOnce({ attestationState: "recorded", kind: "created", runId }); + const created = await POST(request()); expect(created.status).toBe(201); - const duplicate = await createEvidencePostHandler(dependencies("duplicate"))(request()); + expect(submit).toHaveBeenCalledWith({ correlationId, document: {}, workspaceId }); + + submit.mockResolvedValueOnce({ attestationState: "recorded", kind: "duplicate", runId }); + const duplicate = await POST(request()); expect(duplicate.status).toBe(200); await expect(duplicate.json()).resolves.toMatchObject({ kind: "duplicate" }); }); it("preserves 415 transport semantics and safe error envelopes", async () => { - const response = await createEvidencePostHandler(dependencies())(request({ "content-encoding": "br" })); + const response = await POST(request({ "content-encoding": "br" })); expect(response.status).toBe(415); await expect(response.json()).resolves.toEqual({ - error: { code: "INVALID_EVIDENCE", correlationId: context.correlationId, message: "The evidence document is invalid." }, + error: { code: "INVALID_EVIDENCE", correlationId, message: "The evidence document is invalid." }, }); }); }); diff --git a/apps/control/src/app/api/evidence/v1/runs/route.ts b/apps/control/src/app/api/evidence/v1/runs/route.ts index d5132b9..2adc8de 100644 --- a/apps/control/src/app/api/evidence/v1/runs/route.ts +++ b/apps/control/src/app/api/evidence/v1/runs/route.ts @@ -1,62 +1,46 @@ -import { resolveRequestContext } from "../../../../../server/authorization/request-context"; +import { resolveRequestContext, type RequestContext } from "../../../../../server/authorization/request-context"; import { EvidenceIngressError } from "../../../../../server/evidence/errors"; -import type { EvidenceSubmissionResult } from "../../../../../server/evidence/evidence-service"; import { EvidenceService } from "../../../../../server/evidence/evidence-service"; import { createEvidenceRepository } from "../../../../../server/evidence/persistence-adapter"; import { readEvidenceRequest } from "../../../../../server/evidence/transport"; import { getRuntime } from "../../../../../server/runtime"; -export type EvidenceRouteContext = Readonly<{ - correlationId: string; - workspaceId: string; -}>; +export async function POST(request: Request): Promise { + const dependencies = evidenceDependencies(); + let correlationId = "unavailable"; + try { + const context = await dependencies.resolveSubmission(request); + if (context === null) return errorResponse(401, "UNAUTHENTICATED", "Authentication is required.", correlationId); + correlationId = context.correlationId; + const document = await readEvidenceRequest(request); + const result = await dependencies.service.submit({ + correlationId: context.correlationId, + document, + workspaceId: context.workspaceId, + }); + return Response.json(result, { status: result.kind === "created" ? 201 : 200 }); + } catch (error) { + if (error instanceof EvidenceIngressError) return errorResponse(error.status, error.code, error.message, correlationId); + return errorResponse(500, "INTERNAL_ERROR", "The request could not be completed.", correlationId); + } +} -export type EvidenceRouteDependencies = Readonly<{ - resolveSubmission: (request: Request) => Promise; - service: Readonly<{ - submit: (input: Readonly<{ - correlationId: string; - document: unknown; - workspaceId: string; - }>) => Promise; - }>; +type EvidenceDependencies = Readonly<{ + resolveSubmission: (request: Request) => Promise; + service: EvidenceService; }>; -export function createEvidencePostHandler(dependencies: EvidenceRouteDependencies): (request: Request) => Promise { - return async (request) => { - let correlationId = "unavailable"; - try { - const context = await dependencies.resolveSubmission(request); - if (context === null) return errorResponse(401, "UNAUTHENTICATED", "Authentication is required.", correlationId); - correlationId = context.correlationId; - const document = await readEvidenceRequest(request); - const result = await dependencies.service.submit({ - correlationId: context.correlationId, - document, - workspaceId: context.workspaceId, - }); - return Response.json(result, { status: result.kind === "created" ? 201 : 200 }); - } catch (error) { - if (error instanceof EvidenceIngressError) return errorResponse(error.status, error.code, error.message, correlationId); - return errorResponse(500, "INTERNAL_ERROR", "The request could not be completed.", correlationId); - } - }; -} - -const productionDependencies: EvidenceRouteDependencies = Object.freeze({ - resolveSubmission(request) { - const resolution = resolveRequestContext(request, getRuntime().config); - return Promise.resolve(resolution.kind === "ok" ? resolution.context : null); - }, - service: Object.freeze({ - submit(input: Parameters[0]) { - const runtime = getRuntime(); - return new EvidenceService(createEvidenceRepository(runtime.prisma)).submit(input); +/** Production wiring behind one no-argument accessor, so the exported handler is provable on its own. */ +function evidenceDependencies(): EvidenceDependencies { + const runtime = getRuntime(); + return Object.freeze({ + resolveSubmission(request: Request) { + const resolution = resolveRequestContext(request, runtime.config); + return Promise.resolve(resolution.kind === "ok" ? resolution.context : null); }, - }), -}); - -export const POST = createEvidencePostHandler(productionDependencies); + service: new EvidenceService(createEvidenceRepository(runtime.prisma)), + }); +} function errorResponse(status: number, code: string, message: string, correlationId: string): Response { return Response.json({ error: { code, correlationId, message } }, { status }); diff --git a/docs/adr/2026-09-09-require-ingress-parse.md b/docs/adr/2026-09-09-require-ingress-parse.md new file mode 100644 index 0000000..7633fe0 --- /dev/null +++ b/docs/adr/2026-09-09-require-ingress-parse.md @@ -0,0 +1,271 @@ +# `require-ingress-parse` + +## Context + +PRP `docs/prp/2026-09-09-validator-rule-language.md` FR-ING: every external +payload is schema-validated before it reaches a service. The existing +`public-evidence-is-parsed` rule (`require-boundary-parse`) proves that one +named boundary call inside `evidence-service.ts` receives a parsed value; nothing +proved that the transport route itself parses before the request escapes. The +route was `export const POST = createEvidencePostHandler(productionDependencies)`, +a handler built by a factory, so no intra-procedural proof over the exported +symbol was possible at all. + +This is `kz-policy-rule` branch B (new check kind) plus five branch-A rules in +the self-policy. Spec: `docs/plans/2026-09-09-validator-rule-language-spec.md` +sections 5, 6, 7, 8; plan step 4. + +## Decision + +- Schema (`packages/profile-software-architecture/src/policy.ts`): member + `require-ingress-parse` with `files: RuleGlobList`, + `symbols: NonemptyExactStringSchema` (a glob over exported names; the glob + grammar has no alternation, so the self-policy declares one rule per verb), + `parserCalls: ExactList`, `readerCalls` and `allowedCalls` as + `uniqueArray(NonemptyExactStringSchema, 0, 100).default([])`. The spec wrote + `ExactList.default([])`; a Zod 4 default short-circuits parsing, so that + shape would accept an absent list and reject an explicit `[]`. An explicit + empty list must mean the same as an absent one, hence the 0..100 element + list. +- Evaluator (`packages/validator/src/checks/ingress.ts`, new). Exports are + read through `checker.getExportsOfModule`; a value export matching + `symbols` that is not a function declaration with a body or a `const` + bound directly to an arrow or function expression is raw + `INGRESS_UNRESOLVED` at the export (`export const HEAD = factory()` is a + proof failure, never a silent skip). Type-only exports are not ingresses + and are skipped. For a provable function: every parameter binding is + untrusted; one forward pass in source order widens the set through + declarations and assignments to locals whose right side carries untrusted, + and through one level of destructuring; membership is monotone. An + expression carries untrusted when it is an untrusted binding, a member or + element access on one, a `readerCalls` result, a wrapper (`await`, + parentheses, `as`, `satisfies`, non-null) of one, or an object or array + literal containing one. Sinks: a call with an untrusted argument must + resolve to `parserCalls` (sets `parsed`), `readerCalls`, or `allowedCalls`; + an unresolvable callee is `INGRESS_UNRESOLVED`; any other chain is + `INGRESS_ESCAPE` with `escape:`. A `return` of an untrusted value is + `escape:return`; assignment to a binding declared outside the function is + `escape:`; a nested function referencing an untrusted binding is + `escape:closure` unless it is itself an argument to an `allowedCalls` + call. Any `for`/`while`/`do` statement, destructuring deeper than one + level of an untrusted value, and (the plan's defensive default) an + untrusted value reaching `new`, a tagged template, `throw`, `yield`, or a + property write on a non-local object are `INGRESS_UNRESOLVED`; evaluation + of that function stops there. After the pass, `parsed === false` with + nothing recorded is `INGRESS_PARSE_MISSING` at the function name. +- Deliberate readings beyond the spec text, all fail-closed: + 1. The receiver of a method call counts as an argument: `request.json()` + is an escape (`escape:request.json`) unless the chain is listed. Spec + step 3 conditioned on arguments only, which would have let a method on + the raw request launder its result. + 2. Carrying is widened with conditionals, `??`/`||`/`&&`, and template + spans, so a value chosen from or built around untrusted input stays + untrusted. + 3. A property write on a local object widens that local rather than + failing; only a non-local target is the defensive proof failure. + 4. Closures are judged after the pass against the final untrusted set, + which is a superset of the set at any earlier point because membership + is monotone; only closures seen before a proof failure are judged. +- `findings.ts` gains `INGRESS_PARSE_MISSING`, `INGRESS_ESCAPE`, + `INGRESS_UNRESOLVED`; `engine.ts` dispatches the kind and `ruleClaimsPath` + uses `files`; `runner.ts` maps `INGRESS_UNRESOLVED -> INGRESS_PROOF_FAILED` + and the other two to themselves. +- Compatibility: `messageCodesByKind["require-ingress-parse"]` is the three + public codes. Subjects are `symbol::` plus a suffix each + code owns: `parser` for `INGRESS_PARSE_MISSING`, `proof` for + `INGRESS_PROOF_FAILED`, `escape:` for `INGRESS_ESCAPE` where + `` is an identifier chain (`return` and `closure` are identifier + chains, so one grammar covers all three). The qualified name or its last + segment must match `symbols`. +- Fixtures `packages/validator/fixtures/kinds/ingress/handlers.ts` (the spec's + seven cases plus `OPTIONS` for a closure escape, `TRACE` for the + allowed-call closure exemption, and `CONNECT` for monotone reassignment) + and `checks/ingress.test.ts` pinning the exact matrix with subject and + location, the symbol glob, the reader/allowed toggles, and the + parser-missing precedence. +- **Forced route edit, owner-confirmed on 2026-09-09.** The factory-built + export was `INGRESS_PROOF_FAILED` by construction, and the fix is the route, + not the rule (FR-ING-006). The owner confirmed removal of + `createEvidencePostHandler` and the `EvidenceRouteDependencies` injection + seam before the edit (CLAUDE.md refactoring rule). `route.ts` now exports + `async function POST(request)` whose body is the spec section 7 sketch and a + module-level `evidenceDependencies()` accessor with no parameters returning + `{ resolveSubmission, service }` from `getRuntime().config` and + `EvidenceService` over `createEvidenceRepository(runtime.prisma)`; the + `EvidenceRouteContext` and `EvidenceRouteDependencies` exports are gone. + Behaviour is unchanged: same status codes, same envelopes, same lazy + runtime access at request time. `route.test.ts` keeps its cases through + `vi.mock` of the runtime module (a 46-character token, the workspace, a + prisma stub) and of the evidence-service module (`submit` resolving + `created` then `duplicate`): 401 without the bearer header, 201 then 200, + and 415 for `content-encoding: br` through the real `readEvidenceRequest` + with an `x-correlation-id` header so the envelope assertion is exact. The + route still imports neither `@kernel-zero/persistence` nor + `@prisma/client`, so `transport-does-not-import-repositories` stays green. +- Self-policy: five rules `route-handlers-parse-their-input-{get,post,put,patch,delete}` + with `files: ["layer:transport"]`, `symbols` the verb, + `parserCalls: ["readEvidenceRequest"]`, + `readerCalls: ["dependencies.resolveSubmission"]`, + `allowedCalls: ["dependencies.service.submit", "errorResponse"]`. +- Benchmark policy gains one rule (`symbols: "handler*"`, which matches + nothing in the corpus), so the numbers below cover export enumeration on + 5,000 files and nothing of the per-function pass. + +## Invariants touched + +1. Definite policy violations fail closed. Unparsed input reaching an + unlisted call, a `return`, an outer binding, or a closure is + `INGRESS_ESCAPE`; a handler that never parses is `INGRESS_PARSE_MISSING`; + everything the pass cannot follow is `INGRESS_PROOF_FAILED`, never a pass. + Proof: `ingress.test.ts` exact matrix and the bite below. +2. Every request path resolves an actor and validates its input at the + boundary. The route body is unchanged; the exported `POST` is now provable + and the self-policy proves it on every gate. +10. Local validation is deterministic and network-free. Two runs on the final + tree produced identical `integrity.digest` (quoted below). +11. Public wire formats are versioned and digests are over document content. + See Contract impact. +12. Kernel packages never import a profile. Unchanged: + `git diff --stat -- packages/domain packages/contracts packages/persistence` + is empty. + +Invariants 4 to 9 are unaffected: no governed action, no persistence change, +no UI. Invariant 3 is withdrawn. + +## Contract impact + +Policy `kernel-zero.dev/v1`: additive. One new discriminated-union member; +every stored policy parses unchanged and keeps its digest. + +Evidence `kernel-zero.dev/evidence/v1`: additive. The closed `messageCode` +enum grows from nineteen to twenty-two public codes with +`INGRESS_PARSE_MISSING` ("An ingress function never passes its input through +a required parser."), `INGRESS_ESCAPE` ("Unparsed ingress input reaches a +call, return, or binding outside the allowed set."), and +`INGRESS_PROOF_FAILED` ("Ingress input flow could not be proven within the +function."). All seven PRP section 6 codes are now delivered; existing codes, +strings, and fingerprints are untouched, so every stored run still +re-validates. Exception bundles: unchanged. + +Generated contracts regenerated: `docs/contracts/repository-policy-v1.schema.json`, +`docs/contracts/repository-evidence-v1.schema.json`, one paragraph in +`docs/contracts/README.md`; `npm run contracts:check` exit 0. Golden fixture +`packages/validator/fixtures/golden/**`: byte-identical. + +Self-policy digests (the document changed, so both moved): + +| | before (restrict-state-transition ADR) | after | +| --- | --- | --- | +| `policy.digest` | `sha256:3fa084d236c6a05cdddc5723b581eaa58331a1696369d23ca9401d7a63089f11` | `sha256:ca3bc516780a354f54f006a4c1ad0d091220c51cc37190d3c2063dbcb18a4b31` | +| `integrity.digest` | `sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1` | `sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37` | + +The integrity digest also reflects the manifest gaining one source file +(`checks/ingress.ts`; 95 to 96 files) and the rewritten route. + +### Schema-default digest note + +`readerCalls` and `allowedCalls` default to `[]`. The policy digest is +`canonicalSha256` of the schema-parsed document, so a stored policy that +declares this kind and omits either would parse to a document carrying both +and its digest would move (CLAUDE.md rule 5). No stored policy can contain +this kind yet, because the kind did not exist before this change, so nothing +existing moves. The self-policy spells both fields explicitly. + +## Trust model ceilings + +- `// ponytail:` in `checks/ingress.ts`: the proof is intra-procedural only. + Every parameter is untrusted and nothing crosses a call boundary; + inter-procedural flow is the upgrade if a consumer needs it. +- `allowedCalls` results are trusted unconditionally. This is a deliberate + laundering hole: `const raw = dependencies.service.submit(request)` would + make `raw` trusted even though the service received the raw request. The + self-policy lists only the one service call and the response builder, both + of which the route calls with parsed or trusted values; a consumer that + lists a broader allowed set accepts this ceiling knowingly. +- Only same-function bindings are tracked; a local object's property write + widens the whole local, and a closure judged against the final set may be + reported for a capture that was trusted when the closure was created (a + false positive, never a false negative). + +## Bite proof + +Taken with `npm run validator:self` on the final route. In `route.ts`, +`const document = await readEvidenceRequest(request);` was replaced with +`const document = await request.json();`. + +```text +kernel-zero: fail (1 errors, 0 warnings, 0 excepted, 96 files) +error route-handlers-parse-their-input-post apps/control/src/app/api/evidence/v1/runs/route.ts:15:28 INGRESS_ESCAPE symbol:POST:escape:request.json + remediation: Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json. +bite exit=1 +``` + +The finding is `INGRESS_ESCAPE` rather than `INGRESS_PARSE_MISSING` because +the raw request reaches `request.json` (an unlisted chain) first, and a +missing parser is reported only when nothing else was recorded. The route +was restored (`git checkout` returned the committed factory route, so the new +route was written back verbatim) and `npm run validator:self` returned +`kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 96 files)`, exit 0, +with the "after" integrity digest above. + +## FR-IDs + +FR-ING-001 (schema; `readerCalls`/`allowedCalls` as 0..100 lists with `[]` +defaults, see Decision), FR-ING-002 (export resolution; factory-bound +export is a proof failure; parameters, member access, `await`, and reader +results are untrusted), FR-ING-003 (`INGRESS_PARSE_MISSING` with +`symbol::parser`), FR-ING-004 (`INGRESS_ESCAPE` with +`escape:` for calls, returns, and outer bindings; closures as +`escape:closure`), FR-ING-005 (loops, deep destructuring, and unresolvable +callees are `INGRESS_PROOF_FAILED`; reassignment widens monotonically), +FR-ING-006 (five self-policy rules; the route corrected behind +`evidenceDependencies()`, seam removal owner-confirmed). + +## Verification command + +Node 22 (`export PATH="/c/Users/Guerr/AppData/Roaming/fnm/node-versions/v22.22.3/installation:$PATH"`). + +```text +npx vitest run packages/profile-software-architecture + Test Files 6 passed (6) + Tests 151 passed (151) + +npm run test:validator + Test Files 13 passed (13) + Tests 98 passed (98) + +npx vitest run apps/control/src/app/api/evidence/v1/runs/route.test.ts + Test Files 1 passed (1) + Tests 3 passed (3) + +npm run validator:self (run 1, final tree) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 96 files) + integrity.digest sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37 +npm run validator:self (run 2, final tree) + kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 96 files) + integrity.digest sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37 + +npm run benchmark:validator + {"deterministic":true,"files":5000,"firstMs":20580,"limitMs":30000,"limitRssBytes":1073741824,"peakObservedRssBytes":437968896,"secondMs":20041} + Under 30,000 ms and 1 GiB; see the corpus note under Decision. + +npm run contracts:generate && npm run contracts:check exit 0 +git diff --stat -- packages/validator/fixtures/golden (empty) +git diff --stat -- packages/domain packages/contracts packages/persistence (empty) +git diff --stat -- apps/control route.ts and route.test.ts only +npm run typecheck exit 0 +npm run lint exit 0 +``` + +`npm run verify` on this tree: exit 0. + +```text +kernel-zero: pass (0 errors, 0 warnings, 0 excepted, 96 files) +workflow: pass (0 errors, 0 warnings, 1 files) +unit Test Files 50 passed (50) Tests 379 passed (379) +architecture Test Files 1 passed (1) Tests 1 passed (1) +integration Test Files 3 passed (3) Tests 7 passed (7) +``` + +`npm run test:browser` on this tree (builds the app, drives six pages with +keyboard and axe checks): `12 passed (40.2s)`, exit 0. diff --git a/docs/contracts/README.md b/docs/contracts/README.md index ed9346a..16d9568 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -8,7 +8,7 @@ Media type: `application/vnd.kernel-zero.policy+json;version=1` A strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256. -The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`, `require-call-argument`, and `restrict-state-transition`. +The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`, `require-call-argument`, `restrict-state-transition`, and `require-ingress-parse`. `layers` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (`from`, `files`, `allowFrom`, `declarationFiles`) may carry `layer:` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. `scope.include` and `scope.exclude` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside `scope`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without `layers` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. @@ -20,6 +20,8 @@ The closed check kinds are `forbid-import-edge`, `require-import`, `restrict-cal `restrict-state-transition` proves that a state field is written only by its allowed writer and only through listed transitions. It inspects every call in scope whose resolved callee chain matches one of `callee` (the same glob grammar as `require-call-argument`); a call whose argument at index `argument` (0 to 9, default 0) provably carries `field` (1 to 8 identifier segments, for example `data.state`) is a write and must sit in an `allowFrom` file, and an empty `allowFrom` (the default) permits no writer. When `transitions` (default empty) lists pairs `{ from, to }`, where `from` is an identifier or `*` and `to` an identifier, every allowed write must carry a string-literal `field` value and a string-literal predicate value at `field` with its first segment replaced by `where` (`where.state` for `data.state`), and the pair must be listed. A call whose argument does not carry `field` is ignored. Codes: `STATE_TRANSITION_DENIED` with subject `transition::` for a writer outside `allowFrom` or `transition::->` for an unlisted pair, and `STATE_TRANSITION_PROOF_FAILED` with subject `transition::` when the write cannot be proven (an opaque spread or non-literal computed key on the path, a value built elsewhere, a const chain that cycles, a receiver typed `any`, `unknown`, or possibly `undefined`), when `transitions` are listed and the written value or the predicate is not a string literal or the predicate is absent, or (at `error` level) when a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob, in which case `` is that glob. `` is the last segment of `field`; the two subject forms never collide because a chain segment cannot contain `-`. +`require-ingress-parse` proves that every exported function in `files` whose export name matches the `symbols` glob passes its input through one of `parserCalls` before that input escapes. Only a function declaration with a body or a `const` bound directly to an arrow or function expression is provable; any other value export shape (a handler built by a factory, a class, a re-export of one) is a proof failure, never a silent skip; type-only exports are not ingresses. Every parameter is untrusted; a single forward pass in source order widens the untrusted set through `const`/`let` declarations, assignments to locals, one level of destructuring, member and element access, `await` and type wrappers, object and array literals, conditionals, `??`/`||`/`&&`, template spans, and the results of `readerCalls` (default empty); membership is monotone, so a later trusted assignment never downgrades a binding. Results of `parserCalls` and `allowedCalls` (default empty) are trusted. A call whose argument or receiver is untrusted must resolve to a `parserCalls` entry (which satisfies the rule), a `readerCalls` entry, or an `allowedCalls` entry. Codes: `INGRESS_PARSE_MISSING` (subject `symbol::parser`) when the function never passes an untrusted value to a parser and nothing else was reported; `INGRESS_ESCAPE` (subject `symbol::escape:`) when an untrusted value reaches any other resolved call (`` is the callee chain), a `return` (`return`), a binding declared outside the function (its name), or a nested function that captures it and is not itself an argument to an `allowedCalls` call (`closure`); and `INGRESS_PROOF_FAILED` (subject `symbol::proof`) for an export shape the rule cannot see inside, an unresolvable callee receiving untrusted input, any `for`, `while`, or `do` statement, destructuring deeper than one level of an untrusted value, or an untrusted value reaching `new`, a tagged template, `throw`, `yield`, or a property write on anything but a local object. Evaluation of a function stops at its first proof failure. The glob grammar has no alternation, so one rule per exported name family is declared. + `restrict-property-write` proves that one property of one exported type (`targetType` as `{ file, exportName }` plus `property`) is written only from `allowFrom` files; an empty `allowFrom` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and `delete`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. `Object.assign`, `Reflect.set`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: `PROPERTY_WRITE_DENIED` and `PROPERTY_WRITE_PROOF_FAILED`, both with subject `property:#.`. ## RepositoryEvidence v1 diff --git a/docs/contracts/repository-evidence-v1.schema.json b/docs/contracts/repository-evidence-v1.schema.json index 6532bd7..4c5069c 100644 --- a/docs/contracts/repository-evidence-v1.schema.json +++ b/docs/contracts/repository-evidence-v1.schema.json @@ -151,6 +151,9 @@ "CONTEXT_PARAMETER_PROOF_FAILED", "DENIED_IMPORT", "GOVERNED_OPERATION_INVALID", + "INGRESS_ESCAPE", + "INGRESS_PARSE_MISSING", + "INGRESS_PROOF_FAILED", "PARSE_FAILURE", "PROPERTY_WRITE_DENIED", "PROPERTY_WRITE_PROOF_FAILED", diff --git a/docs/contracts/repository-policy-v1.schema.json b/docs/contracts/repository-policy-v1.schema.json index 9241dfb..24d4972 100644 --- a/docs/contracts/repository-policy-v1.schema.json +++ b/docs/contracts/repository-policy-v1.schema.json @@ -607,6 +607,39 @@ "field" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "require-ingress-parse" + }, + "files": { + "$ref": "#/$defs/__schema5" + }, + "symbols": { + "$ref": "#/$defs/__schema15" + }, + "parserCalls": { + "$ref": "#/$defs/__schema16" + }, + "readerCalls": { + "default": [], + "$ref": "#/$defs/__schema23" + }, + "allowedCalls": { + "default": [], + "$ref": "#/$defs/__schema23" + } + }, + "required": [ + "kind", + "files", + "symbols", + "parserCalls" + ], + "additionalProperties": false } ] }, @@ -697,6 +730,14 @@ "to" ], "additionalProperties": false + }, + "__schema23": { + "minItems": 0, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema15" + } } } } diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md index 7c762ce..e068768 100644 --- a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -59,3 +59,11 @@ Session `autonomous-validator-rule-language-2026-09-09`. Every decision and ever ### Verdict 9: kz-checker on step 3 (restrict-state-transition) **Verdict:** PASS; both bites reproduced; benchmark spread judged external load; four caveats acceptable; F3 filed. + +### Decision 7: owner checkpoint before the route edit (human) +**Question:** Confirm removal of `createEvidencePostHandler` and the `EvidenceRouteDependencies` injection seam so `require-ingress-parse` can be dogfooded? +**Owner decided:** Confirm, proceed with step 4. +**Reasoning:** CLAUDE.md refactoring rule and the skill guardrail on deleting existing functionality; PRP FR-ING-006 authorizes correcting the route. + +### Verdict 10: kz-checker on step 4 (require-ingress-parse) +**Verdict:** PASS; three bites reproduced; all nine deviations acceptable (method-call receivers carrying untrusted is sound and produces no false escape on the route); browser suite 12 passed. diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index f5325b8..bf89ad0 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -6,15 +6,15 @@ - **Branch:** feat/validator-rule-language (off main at 618b4cc) - **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope - **Current phase:** 3-implement -- **In-flight:** owner checkpoint before step 4 (route dependency-injection seam removal); AUTONOMOUS MODE PAUSED -- **Next action:** on owner confirmation, dispatch step 4 (require-ingress-parse, tasks 4.1-4.6); on refusal, skip to step 5 with FR-ING undelivered and record the block +- **In-flight:** step 5 (traceability, builder skill doc, final determinism and benchmark quotes) +- **Next action:** step 5 gate: npm run verify, golden and kernel diffs empty, apps/control diff only route files, kz-checker PASS, commit; then Phase 4 push branch and open the PR ## Phase Gates | Phase | Gate | Result | Evidence (command + exit / artifact path) | |-------|------|--------|-------------------------------------------| | 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | | 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | -| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md. Step 3 restrict-state-transition: verify exit 0, unit 352/49, integration 7/3, self-policy pass 95 files, digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 identical twice, benchmark 6883/4768 ms at parity with step 2 (other samples up to 27 s under 97 percent external load, F3 filed), golden and kernel diffs empty, two bites exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-restrict-state-transition.md | +| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md. Step 3 restrict-state-transition: verify exit 0, unit 352/49, integration 7/3, self-policy pass 95 files, digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 identical twice, benchmark 6883/4768 ms at parity with step 2 (other samples up to 27 s under 97 percent external load, F3 filed), golden and kernel diffs empty, two bites exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-restrict-state-transition.md. Step 4 require-ingress-parse: verify exit 0, unit 379/50, integration 7/3, self-policy pass 96 files, digest sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37 identical twice, browser 12 passed, benchmark 19093/18080 ms peak 514 MB with the ingress rule's isolated delta within noise, golden and kernel diffs empty, apps/control diff only route.ts and route.test.ts (owner-confirmed seam removal), three bites (request.json escape exit 1; arrow-const provable exit 0; factory-bound const proof failure exit 1), kz-checker PASS, ADR docs/adr/2026-09-09-require-ingress-parse.md | | 4-branch | PR URL recorded | pending | | | 5-optimize | optimization loop termination | pending | | diff --git a/docs/validator-and-hooks.md b/docs/validator-and-hooks.md index 8a03fe4..b932b47 100644 --- a/docs/validator-and-hooks.md +++ b/docs/validator-and-hooks.md @@ -97,6 +97,36 @@ a write whose `where` carries no literal `state`, or whose `data` is a spread, fails with `STATE_TRANSITION_PROOF_FAILED`. Writes that touch other columns are ignored. +## Parsed ingress + +The five self-policy rules `route-handlers-parse-their-input-{get,post,put,patch,delete}` +use `require-ingress-parse` to prove that every exported HTTP verb handler in +`layer:transport` passes its request through `readEvidenceRequest` before the +request, anything read from it, or the result of +`dependencies.resolveSubmission` reaches anything other than +`dependencies.service.submit` or `errorResponse`. One rule per verb because +the symbol glob has no alternation. + +```json +"check": { + "kind": "require-ingress-parse", + "files": ["layer:transport"], + "symbols": "POST", + "parserCalls": ["readEvidenceRequest"], + "readerCalls": ["dependencies.resolveSubmission"], + "allowedCalls": ["dependencies.service.submit", "errorResponse"] +} +``` + +Replacing `readEvidenceRequest(request)` with `request.json()` fails the gate +with `INGRESS_ESCAPE` and subject `symbol:POST:escape:request.json`; a handler +that never parses fails with `INGRESS_PARSE_MISSING`; a handler built by a +factory (`export const POST = createHandler(...)`), one containing a loop, or +one calling something the validator cannot resolve fails with +`INGRESS_PROOF_FAILED`. The route's production wiring therefore sits behind a +no-argument `evidenceDependencies()` accessor so the exported `POST` is +provable on its own. + ## Agent-facing commands `kernel-zero explain --policy|--evidence ` renders one strictly parsed diff --git a/kernel-zero.policy.json b/kernel-zero.policy.json index 38ce03d..bf409fa 100644 --- a/kernel-zero.policy.json +++ b/kernel-zero.policy.json @@ -254,6 +254,121 @@ ] }, "remediation": "Change a policy revision's state only from policies.ts, with a literal where.state predicate, through draft->approved, approved->active, or active->superseded." + }, + { + "id": "route-handlers-parse-their-input-get", + "title": "GET route handlers parse their input before it escapes", + "level": "error", + "check": { + "kind": "require-ingress-parse", + "files": [ + "layer:transport" + ], + "symbols": "GET", + "parserCalls": [ + "readEvidenceRequest" + ], + "readerCalls": [ + "dependencies.resolveSubmission" + ], + "allowedCalls": [ + "dependencies.service.submit", + "errorResponse" + ] + }, + "remediation": "Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json." + }, + { + "id": "route-handlers-parse-their-input-post", + "title": "POST route handlers parse their input before it escapes", + "level": "error", + "check": { + "kind": "require-ingress-parse", + "files": [ + "layer:transport" + ], + "symbols": "POST", + "parserCalls": [ + "readEvidenceRequest" + ], + "readerCalls": [ + "dependencies.resolveSubmission" + ], + "allowedCalls": [ + "dependencies.service.submit", + "errorResponse" + ] + }, + "remediation": "Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json." + }, + { + "id": "route-handlers-parse-their-input-put", + "title": "PUT route handlers parse their input before it escapes", + "level": "error", + "check": { + "kind": "require-ingress-parse", + "files": [ + "layer:transport" + ], + "symbols": "PUT", + "parserCalls": [ + "readEvidenceRequest" + ], + "readerCalls": [ + "dependencies.resolveSubmission" + ], + "allowedCalls": [ + "dependencies.service.submit", + "errorResponse" + ] + }, + "remediation": "Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json." + }, + { + "id": "route-handlers-parse-their-input-patch", + "title": "PATCH route handlers parse their input before it escapes", + "level": "error", + "check": { + "kind": "require-ingress-parse", + "files": [ + "layer:transport" + ], + "symbols": "PATCH", + "parserCalls": [ + "readEvidenceRequest" + ], + "readerCalls": [ + "dependencies.resolveSubmission" + ], + "allowedCalls": [ + "dependencies.service.submit", + "errorResponse" + ] + }, + "remediation": "Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json." + }, + { + "id": "route-handlers-parse-their-input-delete", + "title": "DELETE route handlers parse their input before it escapes", + "level": "error", + "check": { + "kind": "require-ingress-parse", + "files": [ + "layer:transport" + ], + "symbols": "DELETE", + "parserCalls": [ + "readEvidenceRequest" + ], + "readerCalls": [ + "dependencies.resolveSubmission" + ], + "allowedCalls": [ + "dependencies.service.submit", + "errorResponse" + ] + }, + "remediation": "Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json." } ] } diff --git a/packages/profile-software-architecture/src/compatibility.test.ts b/packages/profile-software-architecture/src/compatibility.test.ts index 784337a..b5acb24 100644 --- a/packages/profile-software-architecture/src/compatibility.test.ts +++ b/packages/profile-software-architecture/src/compatibility.test.ts @@ -13,6 +13,8 @@ const CA = { allowFrom: ["src/audit.ts"], argument: 0, callee: ["*.findFirst", " const ST = { allowFrom: ["src/persistence/policies.ts"], argument: 0, callee: ["*.policyRevision.updateMany"], field: "data.state", kind: "restrict-state-transition", transitions: [{ from: "draft", to: "approved" }] }; +const ING = { allowedCalls: ["dependencies.service.submit", "errorResponse"], files: ["src/api/**/*.ts"], kind: "require-ingress-parse", parserCalls: ["readEvidenceRequest"], readerCalls: ["dependencies.resolveSubmission"], symbols: "POST" }; + const checkCases: readonly Readonly<{ check: Record; code: FindingMessageCode; @@ -44,6 +46,12 @@ const checkCases: readonly Readonly<{ { check: ST, code: "STATE_TRANSITION_DENIED", subject: "transition:state:*->active" }, { check: ST, code: "STATE_TRANSITION_PROOF_FAILED", subject: "transition:state:tx.policyRevision.updateMany" }, { check: ST, code: "STATE_TRANSITION_PROOF_FAILED", subject: "transition:state:*.policyRevision.updateMany" }, + { check: ING, code: "INGRESS_PARSE_MISSING", subject: "symbol:POST:parser" }, + { check: ING, code: "INGRESS_ESCAPE", subject: "symbol:POST:escape:request.json" }, + { check: ING, code: "INGRESS_ESCAPE", subject: "symbol:POST:escape:return" }, + { check: ING, code: "INGRESS_ESCAPE", subject: "symbol:POST:escape:closure" }, + { check: ING, code: "INGRESS_ESCAPE", subject: "symbol:handlers.POST:escape:sink" }, + { check: ING, code: "INGRESS_PROOF_FAILED", subject: "symbol:POST:proof" }, ]; function policy(check: Record) { @@ -111,6 +119,20 @@ describe("evidence rule compatibility", () => { expect(findingCompatibilityReason(policy(PW), finding("PROPERTY_WRITE_PROOF_FAILED", "property:src/domain/job.ts#Task.status"))).toBe("rule_subject_mismatch"); }); + it("rejects ingress subjects with another symbol, a suffix owned by another code, or a malformed escape target", () => { + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_PARSE_MISSING", "symbol:GET:parser"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_PARSE_MISSING", "symbol:POST:proof"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_PROOF_FAILED", "symbol:POST:parser"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_ESCAPE", "symbol:POST:parser"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_ESCAPE", "symbol:POST:escape:"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_ESCAPE", "symbol:POST:escape:request..json"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_ESCAPE", "symbol:POST:escape:a/b"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_ESCAPE", "symbol::escape:return"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("INGRESS_PROOF_FAILED", "symbol:POST"))).toBe("rule_subject_mismatch"); + expect(findingCompatibilityReason(policy(ING), finding("BOUNDARY_PARSE_REQUIRED", "symbol:POST:save"))).toBe("rule_code_mismatch"); + expect(findingCompatibilityReason(policy({ ...ING, symbols: "P*" }), finding("INGRESS_PROOF_FAILED", "symbol:PUT:proof"))).toBeNull(); + }); + it("rejects call-argument subjects with another index, path, or a chain outside the callee globs", () => { expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:1:where.workspaceId"))).toBe("rule_subject_mismatch"); expect(findingCompatibilityReason(policy(CA), finding("CALL_ARGUMENT_MISSING", "call:db.policy.findMany:argument:0:where.tenantId"))).toBe("rule_subject_mismatch"); diff --git a/packages/profile-software-architecture/src/compatibility.ts b/packages/profile-software-architecture/src/compatibility.ts index 826ac64..d0dbff4 100644 --- a/packages/profile-software-architecture/src/compatibility.ts +++ b/packages/profile-software-architecture/src/compatibility.ts @@ -12,6 +12,7 @@ const messageCodesByKind: Readonly { } }); + it("publishes the ingress codes with their exact messages and accepts the three subject forms in findings", () => { + expect(findingMessage("INGRESS_PARSE_MISSING")).toBe("An ingress function never passes its input through a required parser."); + expect(findingMessage("INGRESS_ESCAPE")).toBe("Unparsed ingress input reaches a call, return, or binding outside the allowed set."); + expect(findingMessage("INGRESS_PROOF_FAILED")).toBe("Ingress input flow could not be proven within the function."); + const original = validEvidence(); + const finding = onlyFinding(original); + const cases = [ + ["INGRESS_PARSE_MISSING", "symbol:POST:parser"], + ["INGRESS_ESCAPE", "symbol:POST:escape:request.json"], + ["INGRESS_ESCAPE", "symbol:POST:escape:return"], + ["INGRESS_PROOF_FAILED", "symbol:POST:proof"], + ] as const; + for (const [messageCode, subject] of cases) { + const identity = findingIdentity({ location, messageCode, path: finding.path, policyDigest: original.policy.digest, ruleId: finding.ruleId, subject }); + const rewritten = { ...finding, ...identity, message: findingMessage(messageCode), messageCode, subject }; + const base = { ...original, findings: [rewritten] }; + expect(RepositoryEvidenceSchema.safeParse({ ...base, integrity: { algorithm: "sha256", digest: canonicalEvidenceDigest(base) } }).success).toBe(true); + } + }); + it("excludes run metadata and diagnostic duration from integrity", () => { const original = validEvidence(); expect(canonicalEvidenceDigest({ ...original, runId: "0195f000-0000-7000-8000-000000000099" })).toBe(original.integrity.digest); diff --git a/packages/profile-software-architecture/src/evidence.ts b/packages/profile-software-architecture/src/evidence.ts index 8fa6b7a..f9b955f 100644 --- a/packages/profile-software-architecture/src/evidence.ts +++ b/packages/profile-software-architecture/src/evidence.ts @@ -11,6 +11,9 @@ export const FINDING_MESSAGE_CODES = Object.freeze([ "CONTEXT_PARAMETER_PROOF_FAILED", "DENIED_IMPORT", "GOVERNED_OPERATION_INVALID", + "INGRESS_ESCAPE", + "INGRESS_PARSE_MISSING", + "INGRESS_PROOF_FAILED", "PARSE_FAILURE", "PROPERTY_WRITE_DENIED", "PROPERTY_WRITE_PROOF_FAILED", @@ -35,6 +38,9 @@ const messages: Readonly> & EvidenceMessages CONTEXT_PARAMETER_PROOF_FAILED: "The required context parameter type could not be proven.", DENIED_IMPORT: "A denied dependency edge was found.", GOVERNED_OPERATION_INVALID: "A governed operation declaration is incomplete or invalid.", + INGRESS_ESCAPE: "Unparsed ingress input reaches a call, return, or binding outside the allowed set.", + INGRESS_PARSE_MISSING: "An ingress function never passes its input through a required parser.", + INGRESS_PROOF_FAILED: "Ingress input flow could not be proven within the function.", PARSE_FAILURE: "A claimed source file could not be parsed.", PROPERTY_WRITE_DENIED: "A protected property is written outside its allowed authority boundary.", PROPERTY_WRITE_PROOF_FAILED: "A protected property write could not be resolved well enough to prove its authority boundary.", diff --git a/packages/profile-software-architecture/src/policy.test.ts b/packages/profile-software-architecture/src/policy.test.ts index 0a51f0c..9378316 100644 --- a/packages/profile-software-architecture/src/policy.test.ts +++ b/packages/profile-software-architecture/src/policy.test.ts @@ -68,11 +68,18 @@ describe("RepositoryPolicy v1 contract", () => { ["state transition duplicate pair", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", transitions: [{ from: "draft", to: "approved" }, { from: "draft", to: "approved" }] } }] }], ["state transition index above nine", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", argument: 10 } }] }], ["state transition unknown field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "data.state", files: ["src/**"] } }] }], + ["ingress parse missing parser calls", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "POST" } }] }], + ["ingress parse empty parser list", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "POST", parserCalls: [] } }] }], + ["ingress parse empty symbols", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "", parserCalls: ["parse"] } }] }], + ["ingress parse duplicate reader", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "POST", parserCalls: ["parse"], readerCalls: ["read", "read"] } }] }], + ["ingress parse empty allowed entry", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "POST", parserCalls: ["parse"], allowedCalls: [""] } }] }], + ["ingress parse unknown field", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "POST", parserCalls: ["parse"], callee: ["x"] } }] }], + ["ingress parse empty files", { ...validPolicy, rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: [], symbols: "POST", parserCalls: ["parse"] } }] }], ])("rejects %s", (_label, value) => { expect(RepositoryPolicySchema.safeParse(value).success).toBe(false); }); - it("accepts all twelve closed check variants and rejects variant-only drift", () => { + it("accepts all thirteen closed check variants and rejects variant-only drift", () => { const checks = [ { kind: "forbid-import-edge", from: ["apps/**"], deny: ["module:x"] }, { kind: "require-import", files: ["apps/**"], module: "server-only", allowTypeOnly: false }, @@ -91,6 +98,8 @@ describe("RepositoryPolicy v1 contract", () => { { kind: "require-call-argument", files: ["src/**"], callee: ["*"], requiredPath: "a.b.c.d.e.f.g.h" }, { kind: "restrict-state-transition", callee: ["*.policyRevision.updateMany"], argument: 0, field: "data.state", allowFrom: ["packages/persistence/src/policies.ts"], transitions: [{ from: "draft", to: "approved" }, { from: "*", to: "superseded" }] }, { kind: "restrict-state-transition", callee: ["*.updateMany"], field: "state" }, + { kind: "require-ingress-parse", files: ["apps/control/src/app/api/**/*.ts"], symbols: "POST", parserCalls: ["readEvidenceRequest"], readerCalls: ["dependencies.resolveSubmission"], allowedCalls: ["dependencies.service.submit", "errorResponse"] }, + { kind: "require-ingress-parse", files: ["src/**"], symbols: "*", parserCalls: ["Schema.parse"], readerCalls: [], allowedCalls: [] }, ]; for (const [index, check] of checks.entries()) { const result = RepositoryPolicySchema.safeParse({ @@ -117,6 +126,14 @@ describe("RepositoryPolicy v1 contract", () => { expect(parsed.rules[0]?.check).toEqual({ kind: "restrict-state-transition", callee: ["*.updateMany"], argument: 0, field: "data.state", allowFrom: [], transitions: [] }); }); + it("defaults require-ingress-parse's readerCalls and allowedCalls to []", () => { + const parsed = RepositoryPolicySchema.parse({ + ...validPolicy, + rules: [{ ...validPolicy.rules[0], check: { kind: "require-ingress-parse", files: ["src/**"], symbols: "POST", parserCalls: ["parse"] } }], + }); + expect(parsed.rules[0]?.check).toEqual({ kind: "require-ingress-parse", files: ["src/**"], symbols: "POST", parserCalls: ["parse"], readerCalls: [], allowedCalls: [] }); + }); + describe("layers", () => { const extraLayers = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, index) => [`layer-${String(index)}`, ["apps/**"]])); const layeredRule = { ...validPolicy.rules[0], check: { kind: "forbid-import-edge", from: ["layer:ui", "packages/**"], deny: ["module:@prisma/client"] } }; diff --git a/packages/profile-software-architecture/src/policy.ts b/packages/profile-software-architecture/src/policy.ts index e3d1af5..18f0af7 100644 --- a/packages/profile-software-architecture/src/policy.ts +++ b/packages/profile-software-architecture/src/policy.ts @@ -19,6 +19,8 @@ const LayersSchema = z.record(LayerNameSchema, uniqueArray(LayerGlobSchema, 1, 1 `At most ${String(MAX_LAYERS)} layers may be declared.`, ); const ExactList = uniqueArray(NonemptyExactStringSchema, 1, 100); +// An explicit `[]` must parse exactly like an absent list, so the optional call lists allow zero entries. +const OptionalExactList = uniqueArray(NonemptyExactStringSchema, 0, 100); const ModuleDenialSchema = z.string().min(6).max(500).refine( (value) => value.startsWith("module:") || value.startsWith("module-prefix:") || value.startsWith("path:"), "Denied targets must declare module, module-prefix, or path semantics.", @@ -93,6 +95,15 @@ export const PolicyCheckSchema = z.discriminatedUnion("kind", [ allowFrom: OptionalRuleGlobList.default([]), transitions: uniqueArray(StateTransitionSchema, 0, 100).default([]), }), + z.strictObject({ + kind: z.literal("require-ingress-parse"), + files: RuleGlobList, + // A glob over exported function names; the glob grammar has no alternation, so one rule per name family. + symbols: NonemptyExactStringSchema, + parserCalls: ExactList, + readerCalls: OptionalExactList.default([]), + allowedCalls: OptionalExactList.default([]), + }), ]); const PolicyRuleSchema = z.strictObject({ diff --git a/packages/validator/README.md b/packages/validator/README.md index 6f6f9fd..27fc14a 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -118,6 +118,36 @@ ignored. } ``` +## Parsed ingress + +`require-ingress-parse` proves that every exported function matching +`symbols` passes its input through one of `parserCalls` before that input +escapes. Parameters are untrusted, and so is everything read from them, +awaited from them, built around them, or returned by a `readerCalls` call; +an untrusted value may reach only `parserCalls`, `readerCalls`, and +`allowedCalls`. A handler that never parses is `INGRESS_PARSE_MISSING`; input +reaching any other call, a `return`, an outer binding, or a capturing closure +is `INGRESS_ESCAPE`; a handler the pass cannot follow (built by a factory, +containing a loop, calling something unresolvable) is `INGRESS_PROOF_FAILED`. +The glob has no alternation, so declare one rule per exported name. + +```json +{ + "id": "route-handlers-parse-their-input-post", + "title": "POST route handlers parse their input before it escapes", + "level": "error", + "check": { + "kind": "require-ingress-parse", + "files": ["layer:transport"], + "symbols": "POST", + "parserCalls": ["readEvidenceRequest"], + "readerCalls": ["dependencies.resolveSubmission"], + "allowedCalls": ["dependencies.service.submit", "errorResponse"] + }, + "remediation": "Parse the request before anything else sees it." +} +``` + ## Explain and init ```text diff --git a/packages/validator/fixtures/kinds/ingress/handlers.ts b/packages/validator/fixtures/kinds/ingress/handlers.ts new file mode 100644 index 0000000..d74d66b --- /dev/null +++ b/packages/validator/fixtures/kinds/ingress/handlers.ts @@ -0,0 +1,63 @@ +declare const dependencies: { + resolveSubmission(request: Request): Promise<{ correlationId: string; workspaceId: string }>; + service: { submit(input: unknown): Promise<{ kind: "created" | "duplicate" }> }; +}; +declare function parse(value: unknown): unknown; +declare function log(value: unknown): void; +declare function respond(status: number): Response; +declare function factory(): (request: Request) => Promise; + +export async function POST(request: Request): Promise { + const context = await dependencies.resolveSubmission(request); + const document = parse(request); + const result = await dependencies.service.submit({ context, document }); + return respond(result.kind === "created" ? 201 : 200); +} + +export function GET(request: Request): Request { + return request; +} + +export function PUT(request: Request): Response { + log(request.headers); + return respond(200); +} + +export function PATCH(request: Request): Response { + for (const header of request.headers) { + parse(header); + } + return respond(200); +} + +export function DELETE(request: Request): Response { + const method = request.method; + return respond(method === "DELETE" ? 204 : 404); +} + +export const HEAD = factory(); + +export function OPTIONS(request: Request): Response { + const later = () => request.method; + log(later); + return respond(200); +} + +export async function TRACE(request: Request): Promise { + parse(request); + await dependencies.service.submit(() => request.method); + return respond(200); +} + +export function CONNECT(request: Request): Response { + let value: unknown = request.method; + value = "fixed"; + log(value); + return respond(200); +} + +function ignored(request: Request): Request { + return request; +} + +void ignored; diff --git a/packages/validator/src/checks/ingress.test.ts b/packages/validator/src/checks/ingress.test.ts new file mode 100644 index 0000000..61ef056 --- /dev/null +++ b/packages/validator/src/checks/ingress.test.ts @@ -0,0 +1,83 @@ +import { fileURLToPath } from "node:url"; + +import type { RepositoryPolicy } from "@kernel-zero/profile-software-architecture"; +import { describe, expect, it } from "vitest"; + +import { createRepositoryProgram, evaluatePolicyChecks } from "../engine"; + +const COMPILER_TEST_TIMEOUT_MS = 30_000; +const fixtureRoot = fileURLToPath(new URL("../../fixtures/kinds/", import.meta.url)); +type Check = Extract; + +const FILE = "ingress/handlers.ts"; + +function evaluate(check: Partial = {}) { + const repository = createRepositoryProgram({ rootPath: fixtureRoot, filePaths: [FILE] }); + return evaluatePolicyChecks({ + apiVersion: "kernel-zero.dev/v1", + kind: "RepositoryPolicy", + metadata: { name: "ingress-test", revision: 1, description: "Ingress fixture policy" }, + scope: { languages: ["typescript"], include: ["**/*.ts"], exclude: [] }, + rules: [{ + id: "route-handlers-parse-their-input", + title: "Route handlers parse their input", + level: "error", + check: { + kind: "require-ingress-parse", + files: ["ingress/**"], + symbols: "*", + parserCalls: ["parse"], + readerCalls: ["dependencies.resolveSubmission"], + allowedCalls: ["dependencies.service.submit"], + ...check, + }, + remediation: "Parse the request before it reaches anything else.", + }], + }, repository).map(({ messageCode, subject, path, location }) => [messageCode, subject, path, `${String(location.startLine)}:${String(location.startColumn)}-${String(location.endLine)}:${String(location.endColumn)}`]); +} + +describe("require-ingress-parse", { timeout: COMPILER_TEST_TIMEOUT_MS }, () => { + it("emits the exact finding matrix over the fixture in engine order", () => { + expect(evaluate()).toEqual([ + ["INGRESS_ESCAPE", "symbol:GET:escape:return", FILE, "18:3-18:18"], + ["INGRESS_ESCAPE", "symbol:PUT:escape:log", FILE, "22:3-22:23"], + ["INGRESS_UNRESOLVED", "symbol:PATCH:proof", FILE, "27:3-29:4"], + ["INGRESS_PARSE_MISSING", "symbol:DELETE:parser", FILE, "33:17-33:23"], + ["INGRESS_UNRESOLVED", "symbol:HEAD:proof", FILE, "38:14-38:30"], + ["INGRESS_ESCAPE", "symbol:OPTIONS:escape:closure", FILE, "41:17-41:37"], + ["INGRESS_ESCAPE", "symbol:CONNECT:escape:log", FILE, "55:3-55:13"], + ]); + }); + + it("restricts findings to the exported symbols the glob names", () => { + expect(evaluate({ symbols: "POST" })).toEqual([]); + expect(evaluate({ symbols: "TRACE" })).toEqual([]); + expect(evaluate({ symbols: "GET" })).toEqual([["INGRESS_ESCAPE", "symbol:GET:escape:return", FILE, "18:3-18:18"]]); + expect(evaluate({ symbols: "ignored" })).toEqual([]); + }); + + it("treats a reader result as untrusted and an allowed call as a sink only when listed", () => { + expect(evaluate({ symbols: "POST", readerCalls: [] })).toEqual([ + ["INGRESS_ESCAPE", "symbol:POST:escape:dependencies.resolveSubmission", FILE, "11:25-11:64"], + ]); + expect(evaluate({ symbols: "POST", allowedCalls: [] })).toEqual([ + ["INGRESS_ESCAPE", "symbol:POST:escape:dependencies.service.submit", FILE, "13:24-13:74"], + ]); + expect(evaluate({ symbols: "TRACE", allowedCalls: [] })).toEqual([ + ["INGRESS_ESCAPE", "symbol:TRACE:escape:closure", FILE, "48:37-48:57"], + ]); + }); + + it("reports a missing parser only when nothing else was recorded", () => { + expect(evaluate({ symbols: "POST", parserCalls: ["other"] })).toEqual([ + ["INGRESS_ESCAPE", "symbol:POST:escape:parse", FILE, "12:20-12:34"], + ]); + expect(evaluate({ symbols: "DELETE", parserCalls: ["respond"] })).toEqual([ + ["INGRESS_PARSE_MISSING", "symbol:DELETE:parser", FILE, "33:17-33:23"], + ]); + }); + + it("ignores files outside the rule", () => { + expect(evaluate({ files: ["elsewhere/**"] })).toEqual([]); + }); +}); diff --git a/packages/validator/src/checks/ingress.ts b/packages/validator/src/checks/ingress.ts new file mode 100644 index 0000000..9d009e5 --- /dev/null +++ b/packages/validator/src/checks/ingress.ts @@ -0,0 +1,273 @@ +import ts from "typescript"; + +import { isFunctionWithBody, isNodeWithin, unwrapExpression, walk } from "../ast"; +import { forEachMatchingSource, matchesGlob, nodeLocation, rawFinding, type CheckEvaluator, type RawFindingMessageCode } from "../findings"; +import { resolveCallChain } from "./argument-shape"; + +/** + * Every exported function matching `symbols` is an ingress: its parameters are untrusted, and + * untrusted values may reach only `parserCalls`, `readerCalls`, and `allowedCalls` before the + * function ends. Anything the single forward pass cannot follow is a proof failure, never a pass. + */ +export const evaluateIngressParses: CheckEvaluator<"require-ingress-parse"> = (rule, repository, failedPaths, findings) => { + const checker = repository.program.getTypeChecker(); + forEachMatchingSource(repository, rule.check.files, failedPaths, (filePath, sourceFile) => { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (moduleSymbol === undefined) { + return; + } + for (const exported of checker.getExportsOfModule(moduleSymbol)) { + if (!matchesGlob(exported.name, rule.check.symbols)) { + continue; + } + const target = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported; + // A type-only export has no runtime entry point, so it is not an ingress. + if ((target.flags & ts.SymbolFlags.Value) === 0) { + continue; + } + const push = (code: RawFindingMessageCode, subject: string, node: ts.Node): void => { + findings.push(rawFinding(rule, code, `symbol:${exported.name}:${subject}`, filePath, nodeLocation(sourceFile, node))); + }; + const provable = provableFunction(target); + if (provable === undefined) { + const location = [...(exported.declarations ?? []), ...(target.declarations ?? [])].find((declaration) => declaration.getSourceFile() === sourceFile) ?? sourceFile; + push("INGRESS_UNRESOLVED", "proof", location); + continue; + } + analyzeIngress(provable, rule.check, checker, sourceFile, push); + } + }); +}; + +interface ProvableFunction { + readonly node: ts.FunctionLikeDeclaration & { readonly body: ts.ConciseBody }; + readonly nameNode: ts.Node; +} + +/** A function declaration with a body, or a `const` bound directly to an arrow or function expression. */ +function provableFunction(symbol: ts.Symbol): ProvableFunction | undefined { + for (const declaration of symbol.declarations ?? []) { + if (ts.isFunctionDeclaration(declaration) && declaration.body !== undefined) { + return { node: declaration as ProvableFunction["node"], nameNode: declaration.name ?? declaration }; + } + if (ts.isVariableDeclaration(declaration) && declaration.initializer !== undefined) { + const initializer = unwrapExpression(declaration.initializer); + if ((ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) && isFunctionWithBody(initializer)) { + return { node: initializer, nameNode: declaration.name }; + } + } + } + return undefined; +} + +type IngressCheck = Extract>[0]["check"], { kind: "require-ingress-parse" }>; + +// ponytail: intra-procedural only; every parameter is untrusted and nothing crosses a call boundary. Inter-procedural flow is the upgrade. +function analyzeIngress( + { node: fn, nameNode }: ProvableFunction, + check: IngressCheck, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, + push: (code: RawFindingMessageCode, subject: string, node: ts.Node) => void, +): void { + const untrusted = new Set(); + const closures: ts.Node[] = []; + // Object properties stay `boolean` where closure-assigned locals would narrow to their initial literal. + const state = { failed: false, parsed: false, recorded: false }; + + const record = (code: RawFindingMessageCode, subject: string, node: ts.Node): void => { + push(code, subject, node); + state.recorded = true; + state.failed ||= code === "INGRESS_UNRESOLVED"; + }; + const symbolOf = (identifier: ts.Identifier): ts.Symbol | undefined => ( + ts.isShorthandPropertyAssignment(identifier.parent) && identifier.parent.name === identifier + ? checker.getShorthandAssignmentValueSymbol(identifier.parent) + : checker.getSymbolAtLocation(identifier) + ); + const isUntrusted = (identifier: ts.Identifier): boolean => { + const symbol = symbolOf(identifier); + return symbol !== undefined && untrusted.has(symbol); + }; + const isLocal = (symbol: ts.Symbol): boolean => ( + symbol.declarations?.some((declaration) => declaration.getSourceFile() === sourceFile && isNodeWithin(declaration, fn)) ?? false + ); + const chainOf = (call: ts.CallExpression): string | undefined => resolveCallChain(call, checker)?.chain; + + const carries = (expression: ts.Expression): boolean => { + if ( + ts.isParenthesizedExpression(expression) + || ts.isAsExpression(expression) + || ts.isTypeAssertionExpression(expression) + || ts.isNonNullExpression(expression) + || ts.isSatisfiesExpression(expression) + || ts.isAwaitExpression(expression) + || ts.isSpreadElement(expression) + ) { + return carries(expression.expression); + } + if (ts.isIdentifier(expression)) { + return isUntrusted(expression); + } + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + return carries(expression.expression); + } + if (ts.isCallExpression(expression)) { + const chain = chainOf(expression); + return chain !== undefined && check.readerCalls.includes(chain); + } + if (ts.isObjectLiteralExpression(expression)) { + return expression.properties.some((property) => { + if (ts.isPropertyAssignment(property)) return carries(property.initializer); + if (ts.isShorthandPropertyAssignment(property)) return isUntrusted(property.name); + return ts.isSpreadAssignment(property) && carries(property.expression); + }); + } + if (ts.isArrayLiteralExpression(expression)) { + return expression.elements.some(carries); + } + // Fail-closed widening beyond the enumerated forms: a value chosen or derived from untrusted input stays untrusted. + if (ts.isConditionalExpression(expression)) { + return carries(expression.whenTrue) || carries(expression.whenFalse); + } + if (ts.isBinaryExpression(expression) && isValueSelectingOperator(expression.operatorToken.kind)) { + return carries(expression.left) || carries(expression.right); + } + if (ts.isTemplateExpression(expression)) { + return expression.templateSpans.some((span) => carries(span.expression)); + } + return false; + }; + + const addBinding = (identifier: ts.Identifier): void => { + const symbol = checker.getSymbolAtLocation(identifier); + if (symbol !== undefined) { + untrusted.add(symbol); + } + }; + /** One level of destructuring is followed; a nested pattern is not provable. */ + const addOneLevel = (name: ts.BindingName): boolean => { + if (ts.isIdentifier(name)) { + addBinding(name); + return true; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) continue; + if (!ts.isIdentifier(element.name)) return false; + addBinding(element.name); + } + return true; + }; + const rootIdentifier = (expression: ts.Expression): ts.Identifier | undefined => { + let current = unwrapExpression(expression); + while (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) { + current = unwrapExpression(current.expression); + } + return ts.isIdentifier(current) ? current : undefined; + }; + + const visit = (node: ts.Node): void => { + if (state.failed) { + return; + } + if (node !== fn && isFunctionWithBody(node)) { + closures.push(node); + return; + } + if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) { + record("INGRESS_UNRESOLVED", "proof", node); + return; + } + if (ts.isVariableDeclaration(node) && node.initializer !== undefined && carries(node.initializer) && !addOneLevel(node.name)) { + record("INGRESS_UNRESOLVED", "proof", node); + return; + } + if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind) && carries(node.right)) { + const left = unwrapExpression(node.left); + if (ts.isIdentifier(left)) { + const symbol = checker.getSymbolAtLocation(left); + if (symbol !== undefined && isLocal(symbol)) { + untrusted.add(symbol); + } else { + record("INGRESS_ESCAPE", `escape:${left.text}`, node); + } + } else { + // A property write widens a local object; any other target is a sink the pass cannot follow. + const root = ts.isPropertyAccessExpression(left) || ts.isElementAccessExpression(left) ? rootIdentifier(left) : undefined; + const symbol = root === undefined ? undefined : checker.getSymbolAtLocation(root); + if (symbol !== undefined && isLocal(symbol)) { + untrusted.add(symbol); + } else { + record("INGRESS_UNRESOLVED", "proof", node); + return; + } + } + } + if (ts.isCallExpression(node) && (node.arguments.some(carries) || carries(node.expression))) { + const chain = chainOf(node); + if (chain === undefined) { + record("INGRESS_UNRESOLVED", "proof", node); + return; + } + if (check.parserCalls.includes(chain)) { + state.parsed = true; + } else if (!check.readerCalls.includes(chain) && !check.allowedCalls.includes(chain)) { + record("INGRESS_ESCAPE", `escape:${chain}`, node); + } + } + if (ts.isReturnStatement(node) && node.expression !== undefined && carries(node.expression)) { + record("INGRESS_ESCAPE", "escape:return", node); + } + if ( + (ts.isNewExpression(node) && (node.arguments?.some(carries) ?? false)) + || (ts.isTaggedTemplateExpression(node) && carries(node.template)) + || (ts.isThrowStatement(node) && carries(node.expression)) + || (ts.isYieldExpression(node) && node.expression !== undefined && carries(node.expression)) + ) { + record("INGRESS_UNRESOLVED", "proof", node); + return; + } + node.forEachChild(visit); + }; + + for (const parameter of fn.parameters) { + walk(parameter.name, (child) => { + if (ts.isIdentifier(child) && (ts.isParameter(child.parent) || ts.isBindingElement(child.parent)) && child.parent.name === child) { + addBinding(child); + } + }); + } + if (ts.isBlock(fn.body)) { + visit(fn.body); + } else if (carries(fn.body)) { + record("INGRESS_ESCAPE", "escape:return", fn.body); + } else { + visit(fn.body); + } + + // Closures seen before any proof failure are judged against the final set, which only ever grows. + for (const closure of closures) { + if (ts.isCallExpression(closure.parent) && closure.parent.arguments.includes(closure as ts.Expression)) { + const chain = chainOf(closure.parent); + if (chain !== undefined && check.allowedCalls.includes(chain)) continue; + } + const identifiers: ts.Identifier[] = []; + walk(closure, (child) => { + if (ts.isIdentifier(child)) identifiers.push(child); + }); + if (identifiers.some(isUntrusted)) { + record("INGRESS_ESCAPE", "escape:closure", closure); + } + } + if (!state.parsed && !state.recorded) { + record("INGRESS_PARSE_MISSING", "parser", nameNode); + } +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +function isValueSelectingOperator(kind: ts.SyntaxKind): boolean { + return kind === ts.SyntaxKind.QuestionQuestionToken || kind === ts.SyntaxKind.BarBarToken || kind === ts.SyntaxKind.AmpersandAmpersandToken; +} diff --git a/packages/validator/src/engine.ts b/packages/validator/src/engine.ts index adec774..a45cdd1 100644 --- a/packages/validator/src/engine.ts +++ b/packages/validator/src/engine.ts @@ -7,6 +7,7 @@ import { evaluateContextParameters } from "./checks/context"; import { evaluateExportKeys } from "./checks/exports"; import { evaluateGovernedOperations } from "./checks/governed"; import { evaluateForbiddenImports, evaluateRequiredImports } from "./checks/imports"; +import { evaluateIngressParses } from "./checks/ingress"; import { evaluatePropertyWrites } from "./checks/property-write"; import { evaluateClosedRegistry } from "./checks/registry"; import { evaluateStateTransitions } from "./checks/state-transition"; @@ -104,6 +105,9 @@ function evaluateRule( case "restrict-state-transition": evaluateStateTransitions({ ...rule, check: rule.check }, repository, failedPaths, findings); return; + case "require-ingress-parse": + evaluateIngressParses({ ...rule, check: rule.check }, repository, failedPaths, findings); + return; } } @@ -123,6 +127,7 @@ function ruleClaimsPath(check: PolicyCheck, filePath: string): boolean { case "require-boundary-parse": case "require-governed-operation": case "require-call-argument": + case "require-ingress-parse": return check.files.some((glob) => matchesGlob(filePath, glob)); case "restrict-property-write": return check.targetType.file === filePath || check.files.some((glob) => matchesGlob(filePath, glob)); diff --git a/packages/validator/src/findings.ts b/packages/validator/src/findings.ts index 636937d..3aed599 100644 --- a/packages/validator/src/findings.ts +++ b/packages/validator/src/findings.ts @@ -35,7 +35,10 @@ export type RawFindingMessageCode = | "CALL_ARGUMENT_UNRESOLVED" | "STATE_TRANSITION_DENIED_WRITER" | "STATE_TRANSITION_DENIED_PAIR" - | "STATE_TRANSITION_UNPROVABLE"; + | "STATE_TRANSITION_UNPROVABLE" + | "INGRESS_PARSE_MISSING" + | "INGRESS_ESCAPE" + | "INGRESS_UNRESOLVED"; export interface RawFindingLocation { readonly startLine: number; diff --git a/packages/validator/src/runner.ts b/packages/validator/src/runner.ts index 9f2208e..e625f2e 100644 --- a/packages/validator/src/runner.ts +++ b/packages/validator/src/runner.ts @@ -217,5 +217,8 @@ function publicMessageCode(code: RawFindingMessageCode): FindingMessageCode { case "STATE_TRANSITION_DENIED_WRITER": case "STATE_TRANSITION_DENIED_PAIR": return "STATE_TRANSITION_DENIED"; case "STATE_TRANSITION_UNPROVABLE": return "STATE_TRANSITION_PROOF_FAILED"; + case "INGRESS_PARSE_MISSING": return "INGRESS_PARSE_MISSING"; + case "INGRESS_ESCAPE": return "INGRESS_ESCAPE"; + case "INGRESS_UNRESOLVED": return "INGRESS_PROOF_FAILED"; } } diff --git a/scripts/benchmark-validator.ts b/scripts/benchmark-validator.ts index 4bf9970..4f71f66 100644 --- a/scripts/benchmark-validator.ts +++ b/scripts/benchmark-validator.ts @@ -41,6 +41,12 @@ const policy = { level: "error", remediation: "Write state through a listed transition.", title: "State is governed", + }, { + check: { files: ["src/**/*.ts"], kind: "require-ingress-parse", parserCalls: ["parse"], symbols: "handler*" }, + id: "handlers-parse-their-input", + level: "error", + remediation: "Parse the request before it escapes.", + title: "Handlers parse their input", }], scope: { exclude: [], include: ["src/**/*.ts"], languages: ["typescript"] }, }; diff --git a/scripts/generate-contracts.ts b/scripts/generate-contracts.ts index 4336fd4..a69639a 100644 --- a/scripts/generate-contracts.ts +++ b/scripts/generate-contracts.ts @@ -164,11 +164,11 @@ const outputs = new Map([ ["docs/contracts/malformed/repository-policy-path-escape.json", `${JSON.stringify({ ...policyExample, scope: { ...policyExample.scope, include: ["../private.ts"] } }, null, 2)}\n`], ["docs/contracts/malformed/exception-grant-set-private-data.json", `${JSON.stringify({ ...exceptionExample, rationale: "must never be exported" }, null, 2)}\n`], ["docs/contracts/malformed/repository-evidence-source-content.json", `${JSON.stringify({ ...evidenceExample, source: "private source text" }, null, 2)}\n`], - ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, \`restrict-property-write\`, \`require-call-argument\`, and \`restrict-state-transition\`. + ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, \`restrict-property-write\`, \`require-call-argument\`, \`restrict-state-transition\`, and \`require-ingress-parse\`. \`layers\` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (\`from\`, \`files\`, \`allowFrom\`, \`declarationFiles\`) may carry \`layer:\` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. \`scope.include\` and \`scope.exclude\` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside \`scope\`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without \`layers\` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. -\`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`require-call-argument\` proves that every call in \`files\` (minus \`allowFrom\`) whose resolved callee chain matches one of \`callee\` carries \`requiredPath\` in the argument at index \`argument\` (0 to 9, default 0). A callee glob is dotted identifier segments where \`*\` spans dots (\`*.findMany\`, \`prisma.*.updateMany\`); \`requiredPath\` is 1 to 8 identifier segments (\`where.workspaceId\`). The argument is proven when it is an object literal, an \`Object.freeze\` of one, or an identifier bound by a same-file \`const\` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal \`undefined\` or \`void 0\`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: \`CALL_ARGUMENT_MISSING\` when the argument is provable and the path is absent or \`undefined\`, and \`CALL_ARGUMENT_PROOF_FAILED\` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`, or (at \`error\` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject \`call::argument::\`, where \`\` is the resolved callee chain or, for an unresolved callee, the matching glob itself.\n\n\`restrict-state-transition\` proves that a state field is written only by its allowed writer and only through listed transitions. It inspects every call in scope whose resolved callee chain matches one of \`callee\` (the same glob grammar as \`require-call-argument\`); a call whose argument at index \`argument\` (0 to 9, default 0) provably carries \`field\` (1 to 8 identifier segments, for example \`data.state\`) is a write and must sit in an \`allowFrom\` file, and an empty \`allowFrom\` (the default) permits no writer. When \`transitions\` (default empty) lists pairs \`{ from, to }\`, where \`from\` is an identifier or \`*\` and \`to\` an identifier, every allowed write must carry a string-literal \`field\` value and a string-literal predicate value at \`field\` with its first segment replaced by \`where\` (\`where.state\` for \`data.state\`), and the pair must be listed. A call whose argument does not carry \`field\` is ignored. Codes: \`STATE_TRANSITION_DENIED\` with subject \`transition::\` for a writer outside \`allowFrom\` or \`transition::->\` for an unlisted pair, and \`STATE_TRANSITION_PROOF_FAILED\` with subject \`transition::\` when the write cannot be proven (an opaque spread or non-literal computed key on the path, a value built elsewhere, a const chain that cycles, a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`), when \`transitions\` are listed and the written value or the predicate is not a string literal or the predicate is absent, or (at \`error\` level) when a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob, in which case \`\` is that glob. \`\` is the last segment of \`field\`; the two subject forms never collide because a chain segment cannot contain \`-\`.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], +\`require-context-parameter\` proves that exported functions matching \`symbols\` take a required \`parameter\` (as a named parameter or a required property of the first object parameter). An optional \`expectedType\` is either an exact intrinsic (\`{ "kind": "intrinsic", "name": "string" }\`) or one exported type (\`{ "kind": "export", "file": "src/auth/context.ts", "exportName": "AuthorizationContext" }\`); exported identity is by symbol, so aliases, derived types, generic instantiations of the type, and intersections match while structural lookalikes do not, and every non-never union member must match. Codes: \`CONTEXT_PARAMETER_INVALID\` (subject \`symbol::parameter:\`) and \`CONTEXT_PARAMETER_PROOF_FAILED\` (the same subject, or \`type:#\` when the configured type cannot resolve).\n\n\`require-closed-registry\` proves that one exported registry object (\`registryFile\` plus \`registryExport\`, a direct or exact \`Object.freeze\` object literal) is the only source of declarations made through \`declarationCalls\` inside \`declarationFiles\`. Entry IDs are direct identifier or string keys matching \`[A-Za-z0-9][A-Za-z0-9._/-]{0,119}\`; each entry is a plain object literal carrying every \`requiredKeys\` key; a declaration's first argument is a literal ID that must exist in the registry; registry-only entries are allowed; duplicate declarations, spreads, computed keys, accessors, methods, invalid or duplicate IDs, and unresolved indirection block proof. Codes: \`CLOSED_REGISTRY_ENTRY_INVALID\` (subject \`registry::entry::\`, where key is a required key or \`id\`), \`UNREGISTERED_DECLARATION\` (subject \`registry::declaration:\`), and \`CLOSED_REGISTRY_PROOF_FAILED\` (subject \`registry::proof:\`, \`registry::proof:registry\`, or \`registry::proof:declaration\`). The marker \`\` stands for an ID that cannot be rendered safely.\n\n\`require-call-argument\` proves that every call in \`files\` (minus \`allowFrom\`) whose resolved callee chain matches one of \`callee\` carries \`requiredPath\` in the argument at index \`argument\` (0 to 9, default 0). A callee glob is dotted identifier segments where \`*\` spans dots (\`*.findMany\`, \`prisma.*.updateMany\`); \`requiredPath\` is 1 to 8 identifier segments (\`where.workspaceId\`). The argument is proven when it is an object literal, an \`Object.freeze\` of one, or an identifier bound by a same-file \`const\` to one; every path segment is a literal or shorthand property; and the leaf is any expression other than the literal \`undefined\` or \`void 0\`. A spread after the key is harmless only when its operand is an object literal, or a conditional whose branches are both object literals, whose own top-level members have no spread, no computed key, and no member with the key's name; spreads before the key are always harmless. Codes: \`CALL_ARGUMENT_MISSING\` when the argument is provable and the path is absent or \`undefined\`, and \`CALL_ARGUMENT_PROOF_FAILED\` when the argument is not a provable literal, an opaque spread or non-literal computed key sits on the path, a const chain cycles, the callee resolves only through a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`, or (at \`error\` level) a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob. Both use subject \`call::argument::\`, where \`\` is the resolved callee chain or, for an unresolved callee, the matching glob itself.\n\n\`restrict-state-transition\` proves that a state field is written only by its allowed writer and only through listed transitions. It inspects every call in scope whose resolved callee chain matches one of \`callee\` (the same glob grammar as \`require-call-argument\`); a call whose argument at index \`argument\` (0 to 9, default 0) provably carries \`field\` (1 to 8 identifier segments, for example \`data.state\`) is a write and must sit in an \`allowFrom\` file, and an empty \`allowFrom\` (the default) permits no writer. When \`transitions\` (default empty) lists pairs \`{ from, to }\`, where \`from\` is an identifier or \`*\` and \`to\` an identifier, every allowed write must carry a string-literal \`field\` value and a string-literal predicate value at \`field\` with its first segment replaced by \`where\` (\`where.state\` for \`data.state\`), and the pair must be listed. A call whose argument does not carry \`field\` is ignored. Codes: \`STATE_TRANSITION_DENIED\` with subject \`transition::\` for a writer outside \`allowFrom\` or \`transition::->\` for an unlisted pair, and \`STATE_TRANSITION_PROOF_FAILED\` with subject \`transition::\` when the write cannot be proven (an opaque spread or non-literal computed key on the path, a value built elsewhere, a const chain that cycles, a receiver typed \`any\`, \`unknown\`, or possibly \`undefined\`), when \`transitions\` are listed and the written value or the predicate is not a string literal or the predicate is absent, or (at \`error\` level) when a property-access callee cannot be resolved while its root and accessed name both agree with a callee glob, in which case \`\` is that glob. \`\` is the last segment of \`field\`; the two subject forms never collide because a chain segment cannot contain \`-\`.\n\n\`require-ingress-parse\` proves that every exported function in \`files\` whose export name matches the \`symbols\` glob passes its input through one of \`parserCalls\` before that input escapes. Only a function declaration with a body or a \`const\` bound directly to an arrow or function expression is provable; any other value export shape (a handler built by a factory, a class, a re-export of one) is a proof failure, never a silent skip; type-only exports are not ingresses. Every parameter is untrusted; a single forward pass in source order widens the untrusted set through \`const\`/\`let\` declarations, assignments to locals, one level of destructuring, member and element access, \`await\` and type wrappers, object and array literals, conditionals, \`??\`/\`||\`/\`&&\`, template spans, and the results of \`readerCalls\` (default empty); membership is monotone, so a later trusted assignment never downgrades a binding. Results of \`parserCalls\` and \`allowedCalls\` (default empty) are trusted. A call whose argument or receiver is untrusted must resolve to a \`parserCalls\` entry (which satisfies the rule), a \`readerCalls\` entry, or an \`allowedCalls\` entry. Codes: \`INGRESS_PARSE_MISSING\` (subject \`symbol::parser\`) when the function never passes an untrusted value to a parser and nothing else was reported; \`INGRESS_ESCAPE\` (subject \`symbol::escape:\`) when an untrusted value reaches any other resolved call (\`\` is the callee chain), a \`return\` (\`return\`), a binding declared outside the function (its name), or a nested function that captures it and is not itself an argument to an \`allowedCalls\` call (\`closure\`); and \`INGRESS_PROOF_FAILED\` (subject \`symbol::proof\`) for an export shape the rule cannot see inside, an unresolvable callee receiving untrusted input, any \`for\`, \`while\`, or \`do\` statement, destructuring deeper than one level of an untrusted value, or an untrusted value reaching \`new\`, a tagged template, \`throw\`, \`yield\`, or a property write on anything but a local object. Evaluation of a function stops at its first proof failure. The glob grammar has no alternation, so one rule per exported name family is declared.\n\n\`restrict-property-write\` proves that one property of one exported type (\`targetType\` as \`{ file, exportName }\` plus \`property\`) is written only from \`allowFrom\` files; an empty \`allowFrom\` permits no writer. Identity is the exact exported type symbol plus its resolved property, never property text alone, and a receiver may be the target through aliases, derived types, intersections, unions that may contain it, generic instantiations, and generics constrained to it. Covered write forms are direct, compound, and logical assignments; increment, decrement, and \`delete\`; literal or compile-time-constant element access; property targets in destructuring assignments; class-field initializers and constructor parameter properties; and object-literal initialization contextually typed, asserted, or satisfied as the target type. Spreads that may author the property, unresolved computed keys on a possible target, and unsafe or unresolved receivers at a direct protected write are proof failures; authorized files are skipped before ambiguity findings. \`Object.assign\`, \`Reflect.set\`, reflective APIs, serialization or ORM update documents, JavaScript, symbol or private properties, escape analysis, value correctness, and transition legality are out of scope. Codes: \`PROPERTY_WRITE_DENIED\` and \`PROPERTY_WRITE_PROOF_FAILED\`, both with subject \`property:#.\`.\n\n## RepositoryEvidence v1\n\nMedia type: \`${EVIDENCE_MEDIA_TYPE}\`\n\nEvidence contains normalized findings and repository metadata, never source. The required check-specific subject lets ingestion recompute each fingerprint, location-sensitive ID, and closed safe message. Integrity excludes run ID, generation time, diagnostic duration, integrity, and signature.\n\n## ExceptionGrantSet v1\n\nMedia type: \`${EXCEPTION_GRANT_SET_MEDIA_TYPE}\`\n\nBundles contain opaque grant IDs and deterministic matching fields only. Grants are sorted by exception ID. Lifetime is at most 24 hours and never extends a grant. Integrity omits \`integrity\` and \`signature\`; an Ed25519 signature covers the raw 32-byte SHA-256 digest.\n\n## ManifestPolicy v1 / ManifestEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nThe closed rule kinds are \`allowed-licenses\` and \`pinned-dependencies\`. The closed message codes are \`LICENSE_NOT_ALLOWED\`, \`DEPENDENCY_NOT_PINNED\`, and \`PARSE_FAILURE\`.\n\n## WorkflowPolicy v1 / WorkflowEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence) — the same two constants as RepositoryPolicy and RepositoryEvidence.\n\nGitHub Actions workflow hygiene. The closed rule kinds are \`pinned-actions\` (mode \`sha\` or \`tag\`) and \`restricted-permissions\` (an \`allowWrite\` scope list). The closed message codes are \`ACTION_NOT_PINNED\` (subject \`action:\`), \`PERMISSIONS_MISSING\` (subject \`permissions:top-level\`), \`PERMISSION_TOO_BROAD\` (subject \`permissions::\`), and \`PARSE_FAILURE\` (subject \`parse\`). Scope is \`include\` globs only, defaulting to \`.github/workflows/*.yml\` and \`.github/workflows/*.yaml\`.\n`], ]); const check = process.argv.includes("--check"); From 36fddf2a8d6854d653d39433c5ffe1025e8ba399 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 13:28:37 -0700 Subject: [PATCH 6/7] Trace the validator rule language, list the new kinds in the builder skill, and govern where the runtime is opened --- .claude/skills/kz-policy-rule/SKILL.md | 2 +- docs/TRACEABILITY.md | 25 +++++++++++++++++++ docs/adr/2026-09-09-policy-layers.md | 15 +++++++++++ ...-09-validator-rule-language-advisor-log.md | 7 ++++++ ...09-09-validator-rule-language-run-state.md | 6 ++--- .../prp/2026-09-09-validator-rule-language.md | 12 ++++++--- kernel-zero.policy.json | 16 ++++++++++++ scripts/generate-traceability.ts | 6 +++++ 8 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.claude/skills/kz-policy-rule/SKILL.md b/.claude/skills/kz-policy-rule/SKILL.md index 45c06ec..cafd47f 100644 --- a/.claude/skills/kz-policy-rule/SKILL.md +++ b/.claude/skills/kz-policy-rule/SKILL.md @@ -11,7 +11,7 @@ Invoke `kz-grill` first. Its ADR must say which branch. ## Branch A: new rule, existing kind -Existing kinds: `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`. Schema: `packages/profile-software-architecture/src/policy.ts`. +Existing kinds: `forbid-import-edge`, `require-import`, `restrict-call-site`, `require-export-keys`, `require-tenant-parameter`, `require-boundary-parse`, `require-governed-operation`, `require-context-parameter`, `require-closed-registry`, `restrict-property-write`, `require-call-argument`, `restrict-state-transition`, `require-ingress-parse`. Schema: `packages/profile-software-architecture/src/policy.ts`. File lists (`from`, `files`, `allowFrom`, `declarationFiles`) accept `layer:` references declared in the policy's top-level `layers` map; prefer a layer over repeating globs, and declare a new layer only when a rule needs it. 1. Add the rule object to `kernel-zero.policy.json` with `id`, `title`, `level`, `check`, `remediation`. Rule IDs are slugs, 3 to 80 chars, unique. 2. Prove it bites: introduce the violation on purpose in one file, run `npm run validator:self`, expect exit 1 and one finding with your rule ID. Revert the violation. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 838699b..eeed3da 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -52,3 +52,28 @@ Generated by `npm run traceability:generate`. Each functional requirement has an | FR-UI-007 | route/UI contracts, read models, production build, and desktop/320px Playwright axe checks over every page | local build/browser proof | | FR-UI-008 | route/UI contracts, read models, production build, and desktop/320px Playwright axe checks over every page | local build/browser proof | | FR-UI-009 | route/UI contracts, read models, production build, and desktop/320px Playwright axe checks over every page | local build/browser proof | +| FR-LAY-001 | policy schema layers/superRefine tests, layers expansion and fast-check order property, compatibility expansion, engine guard, runner expansion, self-policy rewrite with bite proof | local unit/static proof; existing digests and golden fixture unchanged by assertion | +| FR-LAY-002 | policy schema layers/superRefine tests, layers expansion and fast-check order property, compatibility expansion, engine guard, runner expansion, self-policy rewrite with bite proof | local unit/static proof; existing digests and golden fixture unchanged by assertion | +| FR-LAY-003 | policy schema layers/superRefine tests, layers expansion and fast-check order property, compatibility expansion, engine guard, runner expansion, self-policy rewrite with bite proof | local unit/static proof; existing digests and golden fixture unchanged by assertion | +| FR-LAY-004 | policy schema layers/superRefine tests, layers expansion and fast-check order property, compatibility expansion, engine guard, runner expansion, self-policy rewrite with bite proof | local unit/static proof; existing digests and golden fixture unchanged by assertion | +| FR-LAY-005 | policy schema layers/superRefine tests, layers expansion and fast-check order property, compatibility expansion, engine guard, runner expansion, self-policy rewrite with bite proof | local unit/static proof; existing digests and golden fixture unchanged by assertion | +| FR-ARG-001 | argument-shape prover tests, call-argument fixture matrix, compatibility grammar tests, self-policy rule tenant-queries-carry-workspace with missing-key and opaque-spread bite proofs | local unit/static proof; intra-procedural only | +| FR-ARG-002 | argument-shape prover tests, call-argument fixture matrix, compatibility grammar tests, self-policy rule tenant-queries-carry-workspace with missing-key and opaque-spread bite proofs | local unit/static proof; intra-procedural only | +| FR-ARG-003 | argument-shape prover tests, call-argument fixture matrix, compatibility grammar tests, self-policy rule tenant-queries-carry-workspace with missing-key and opaque-spread bite proofs | local unit/static proof; intra-procedural only | +| FR-ARG-004 | argument-shape prover tests, call-argument fixture matrix, compatibility grammar tests, self-policy rule tenant-queries-carry-workspace with missing-key and opaque-spread bite proofs | local unit/static proof; intra-procedural only | +| FR-STA-001 | state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs | local unit/static proof; literal predicates only | +| FR-STA-002 | state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs | local unit/static proof; literal predicates only | +| FR-STA-003 | state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs | local unit/static proof; literal predicates only | +| FR-STA-004 | state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs | local unit/static proof; literal predicates only | +| FR-STA-005 | state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs | local unit/static proof; literal predicates only | +| FR-ING-001 | ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites | local unit/static proof; intra-procedural, allowed-call results trusted by declaration | +| FR-ING-002 | ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites | local unit/static proof; intra-procedural, allowed-call results trusted by declaration | +| FR-ING-003 | ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites | local unit/static proof; intra-procedural, allowed-call results trusted by declaration | +| FR-ING-004 | ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites | local unit/static proof; intra-procedural, allowed-call results trusted by declaration | +| FR-ING-005 | ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites | local unit/static proof; intra-procedural, allowed-call results trusted by declaration | +| FR-ING-006 | ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites | local unit/static proof; intra-procedural, allowed-call results trusted by declaration | +| FR-DOG-001 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | +| FR-DOG-002 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | +| FR-DOG-003 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | +| FR-DOG-004 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | +| FR-DOG-005 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | diff --git a/docs/adr/2026-09-09-policy-layers.md b/docs/adr/2026-09-09-policy-layers.md index e5403c2..28f832d 100644 --- a/docs/adr/2026-09-09-policy-layers.md +++ b/docs/adr/2026-09-09-policy-layers.md @@ -187,3 +187,18 @@ unit Test Files 46 passed (46) Tests 287 passed (287) architecture Test Files 1 passed (1) Tests 1 passed (1) integration Test Files 3 passed (3) Tests 7 passed (7) ``` + +## Addendum (2026-09-09, final checker finding): sentence 5 dogfood rule + +The PRP claimed sentence 5 ("a function cannot be called from named layers") was +already covered by a `restrict-call-site` rule in the self-policy. No such rule +existed. Rule `runtime-opens-at-the-boundary` was added: `getRuntime` may be +called only from `layer:transport` and `apps/control/src/app/app/route-context.ts`, +so services and views receive the runtime rather than opening it. + +- Bite proof: a probe `export function probeRuntime() { return getRuntime(); }` + appended to `apps/control/src/server/policy/policy-service.ts` produced + `kernel-zero: fail (1 errors, 0 warnings, 0 excepted, 96 files)` / + `error runtime-opens-at-the-boundary apps/control/src/server/policy/policy-service.ts:76:41 RESTRICTED_CALL getRuntime`, exit 1; restored from a byte copy, exit 0. +- Self-policy integrity digest: `sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37` before, `sha256:f7010c9acca6e98fabaa77e35952f5972d8d82208d2269020f4ad68ecfbe6998` after, identical on two runs; 17 rules. +- Contract impact: none (existing kind, existing codes). FR-IDs: FR-LAY-005, FR-DOG-001. diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md index e068768..dab88b3 100644 --- a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -67,3 +67,10 @@ Session `autonomous-validator-rule-language-2026-09-09`. Every decision and ever ### Verdict 10: kz-checker on step 4 (require-ingress-parse) **Verdict:** PASS; three bites reproduced; all nine deviations acceptable (method-call receivers carrying untrusted is sound and produces no false escape on the route); browser suite 12 passed. + +### Verdict 11: kz-checker final gate (step 5) +**Verdict:** PASS on every executable gate; SC-V01 sentence-5 clause had no target because the PRP's premise (an existing restrict-call-site self-policy rule) was false. + +### Decision 8: satisfy sentence 5 instead of weakening SC-V01 (orchestrator, advisor role) +**Decided:** add self-policy rule `runtime-opens-at-the-boundary` (restrict-call-site, callee getRuntime, allowFrom layer:transport plus the page route context), prove it bites, correct the PRP premise in section 1, record the digest in the layers ADR addendum. +**Reasoning:** PRP section 1 intends all six sentences to be rules in the self-policy and section 2 item 4 forbids an unexercised kind; adding the rule fulfils the intent without new scope, whereas deleting the clause would weaken a success criterion to pass it. diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index bf89ad0..e19f42a 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -5,8 +5,8 @@ - **Session:** autonomous-validator-rule-language-2026-09-09 - **Branch:** feat/validator-rule-language (off main at 618b4cc) - **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope -- **Current phase:** 3-implement -- **In-flight:** step 5 (traceability, builder skill doc, final determinism and benchmark quotes) +- **Current phase:** 4-finish +- **In-flight:** Phase 4 — push branch and open the pull request - **Next action:** step 5 gate: npm run verify, golden and kernel diffs empty, apps/control diff only route files, kz-checker PASS, commit; then Phase 4 push branch and open the PR ## Phase Gates @@ -14,7 +14,7 @@ |-------|------|--------|-------------------------------------------| | 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | | 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | -| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | in progress | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md. Step 3 restrict-state-transition: verify exit 0, unit 352/49, integration 7/3, self-policy pass 95 files, digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 identical twice, benchmark 6883/4768 ms at parity with step 2 (other samples up to 27 s under 97 percent external load, F3 filed), golden and kernel diffs empty, two bites exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-restrict-state-transition.md. Step 4 require-ingress-parse: verify exit 0, unit 379/50, integration 7/3, self-policy pass 96 files, digest sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37 identical twice, browser 12 passed, benchmark 19093/18080 ms peak 514 MB with the ingress rule's isolated delta within noise, golden and kernel diffs empty, apps/control diff only route.ts and route.test.ts (owner-confirmed seam removal), three bites (request.json escape exit 1; arrow-const provable exit 0; factory-bound const proof failure exit 1), kz-checker PASS, ADR docs/adr/2026-09-09-require-ingress-parse.md | +| 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | PASS | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md. Step 3 restrict-state-transition: verify exit 0, unit 352/49, integration 7/3, self-policy pass 95 files, digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 identical twice, benchmark 6883/4768 ms at parity with step 2 (other samples up to 27 s under 97 percent external load, F3 filed), golden and kernel diffs empty, two bites exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-restrict-state-transition.md. Step 4 require-ingress-parse: verify exit 0, unit 379/50, integration 7/3, self-policy pass 96 files, digest sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37 identical twice, browser 12 passed, benchmark 19093/18080 ms peak 514 MB with the ingress rule's isolated delta within noise, golden and kernel diffs empty, apps/control diff only route.ts and route.test.ts (owner-confirmed seam removal), three bites (request.json escape exit 1; arrow-const provable exit 0; factory-bound const proof failure exit 1), kz-checker PASS, ADR docs/adr/2026-09-09-require-ingress-parse.md. Step 5: traceability 73 mappings incl. FR-LAY/ARG/STA/ING/DOG, kz-policy-rule skill lists the kinds and layers, sentence-5 rule runtime-opens-at-the-boundary added with bite exit 1, final verify exit 0 (unit 379/50, integration 7/3, self-policy pass 17 rules 96 files, digest sha256:f7010c9acca6e98fabaa77e35952f5972d8d82208d2269020f4ad68ecfbe6998 identical twice), kz-checker final gate PASS on every executable gate | | 4-branch | PR URL recorded | pending | | | 5-optimize | optimization loop termination | pending | | diff --git a/docs/prp/2026-09-09-validator-rule-language.md b/docs/prp/2026-09-09-validator-rule-language.md index fc82376..16ebabb 100644 --- a/docs/prp/2026-09-09-validator-rule-language.md +++ b/docs/prp/2026-09-09-validator-rule-language.md @@ -19,10 +19,14 @@ clock, or runtime instrumentation: 5. A given function cannot be called from named architectural layers. 6. Code from any author, human or agent, must satisfy all of the above before merge. -Sentences 1, 5, and 6 are already satisfied (`forbid-import-edge`, -`restrict-call-site`, and the protected `verify` check on `main`). This PRP -delivers the rest by adding one policy-level concept (named layers) and three -check kinds, and by making the repository's own policy use every one of them. +Sentences 1 and 6 are already satisfied (`forbid-import-edge` and the +protected `verify` check on `main`). Sentence 5 has its check kind +(`restrict-call-site`) but, as the final checker found on 2026-09-09, the +self-policy carried no rule of that kind; this PRP therefore also adds one +(`runtime-opens-at-the-boundary`, allowing `getRuntime` only from +`layer:transport` and the page route context). This PRP delivers the rest by +adding one policy-level concept (named layers) and three check kinds, and by +making the repository's own policy use every one of them. ## 2. Product principles diff --git a/kernel-zero.policy.json b/kernel-zero.policy.json index bf409fa..6cb6558 100644 --- a/kernel-zero.policy.json +++ b/kernel-zero.policy.json @@ -369,6 +369,22 @@ ] }, "remediation": "Pass the request only to the request-context reader and readEvidenceRequest; hand parsed values to one application service and build every response through errorResponse or Response.json." + }, + { + "id": "runtime-opens-at-the-boundary", + "title": "The runtime is opened only where a request enters", + "level": "error", + "check": { + "kind": "restrict-call-site", + "callee": [ + "getRuntime" + ], + "allowFrom": [ + "layer:transport", + "apps/control/src/app/app/route-context.ts" + ] + }, + "remediation": "Receive the runtime (or its prisma client and config) as a parameter from the route or page context instead of opening it inside a service or view." } ] } diff --git a/scripts/generate-traceability.ts b/scripts/generate-traceability.ts index bb81577..5d1a461 100644 --- a/scripts/generate-traceability.ts +++ b/scripts/generate-traceability.ts @@ -16,6 +16,12 @@ const groups: readonly RequirementGroup[] = [ { count: 6, evidence: "exception contract, persistence, signing-key, validator-consumption, gate 4 decision-race, and reserved-export tests", prefix: "FR-EXC", qualification: "local unit/cryptographic proof; production export custody intentionally unavailable" }, { count: 6, evidence: "audit, governed-action rollback, gate 3 transaction rollback, correlation proxy, request logging, redaction, and safe read-model tests", prefix: "FR-AUD", qualification: "local unit/schema proof; full hosted request tracing pending" }, { count: 9, evidence: "route/UI contracts, read models, production build, and desktop/320px Playwright axe checks over every page", prefix: "FR-UI", qualification: "local build/browser proof" }, + // Validator rule language, PRP docs/prp/2026-09-09-validator-rule-language.md, ADRs docs/adr/2026-09-09-*.md. + { count: 5, evidence: "policy schema layers/superRefine tests, layers expansion and fast-check order property, compatibility expansion, engine guard, runner expansion, self-policy rewrite with bite proof", prefix: "FR-LAY", qualification: "local unit/static proof; existing digests and golden fixture unchanged by assertion" }, + { count: 4, evidence: "argument-shape prover tests, call-argument fixture matrix, compatibility grammar tests, self-policy rule tenant-queries-carry-workspace with missing-key and opaque-spread bite proofs", prefix: "FR-ARG", qualification: "local unit/static proof; intra-procedural only" }, + { count: 5, evidence: "state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs", prefix: "FR-STA", qualification: "local unit/static proof; literal predicates only" }, + { count: 6, evidence: "ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites", prefix: "FR-ING", qualification: "local unit/static proof; intra-procedural, allowed-call results trusted by declaration" }, + { count: 5, evidence: "self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB", prefix: "FR-DOG", qualification: "local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators" }, ]; const rows = groups.flatMap((group) => Array.from({ length: group.count }, (_, index) => { From 7ceaaa658bd2fc4bbdf2859af90bd735fe9b76e8 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 13:29:20 -0700 Subject: [PATCH 7/7] Record the pull request and the optimization-loop blocker in the run state --- .../2026-09-09-validator-rule-language-advisor-log.md | 4 ++++ .../2026-09-09-validator-rule-language-run-state.md | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md index dab88b3..39690d6 100644 --- a/docs/plans/2026-09-09-validator-rule-language-advisor-log.md +++ b/docs/plans/2026-09-09-validator-rule-language-advisor-log.md @@ -74,3 +74,7 @@ Session `autonomous-validator-rule-language-2026-09-09`. Every decision and ever ### Decision 8: satisfy sentence 5 instead of weakening SC-V01 (orchestrator, advisor role) **Decided:** add self-policy rule `runtime-opens-at-the-boundary` (restrict-call-site, callee getRuntime, allowFrom layer:transport plus the page route context), prove it bites, correct the PRP premise in section 1, record the digest in the layers ADR addendum. **Reasoning:** PRP section 1 intends all six sentences to be rules in the self-policy and section 2 item 4 forbids an unexercised kind; adding the rule fulfils the intent without new scope, whereas deleting the clause would weaken a success criterion to pass it. + +### Decision 9: optimization loop not launched (orchestrator, advisor role) +**Decided:** record a blocker instead of scaffolding the optimization loop. +**Reasoning:** all PRP success criteria have evidence; the only backlog items are owner-gated (contract change, golden-fixture-moving ADR, environment); main is PR-only and the loop would commit onto the open PR. The skill accepts a recorded blocker in place of loop evidence. diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index e19f42a..2f53d29 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -5,9 +5,9 @@ - **Session:** autonomous-validator-rule-language-2026-09-09 - **Branch:** feat/validator-rule-language (off main at 618b4cc) - **MemBerry:** reachable, no entries for this project yet; decisions stored under tag `project:kernel-zero`; the project-level bootstrap (`memberry-setup`, which edits CLAUDE.md) was skipped to keep the run inside PRP scope -- **Current phase:** 4-finish -- **In-flight:** Phase 4 — push branch and open the pull request -- **Next action:** step 5 gate: npm run verify, golden and kernel diffs empty, apps/control diff only route files, kz-checker PASS, commit; then Phase 4 push branch and open the PR +- **Current phase:** 5-optimize (blocked, see below) +- **In-flight:** nothing; PR open, CI running +- **Next action:** owner reviews and merges https://github.com/AP3X-Dev/kernel-zero/pull/1; follow-ups F1-F3 are owner decisions ## Phase Gates | Phase | Gate | Result | Evidence (command + exit / artifact path) | @@ -15,8 +15,8 @@ | 1-design | spec exists + verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-spec.md revision 4; verifier PASS after three rejections (see advisor log verdicts 1-4) | | 2-plan | plan exists, every task names files + runnable check, verifier PASS | PASS | docs/plans/2026-09-09-validator-rule-language-plan.md revision 2; verifier PASS after one rejection; baseline measured: verify exit 0, 264/45 unit, 7/3 integration, self-policy 91 files digest sha256:435c3f70…9ed3bb8 | | 3-implement | `npm run verify` exit 0 with counts; bite proofs; benchmark | PASS | step 1 layers: verify exit 0, unit 287/46, integration 7/3, self-policy pass 92 files, digest sha256:3cf09d734a23a4470eb4bf3a238ec4a9782146df73c9cc3cb10be690a1bcc824 identical twice, golden diff empty, kernel diff empty, bite proof exit 1 on a transport probe, kz-checker PASS, ADR docs/adr/2026-09-09-policy-layers.md. Step 2 require-call-argument: verify exit 0, unit 324/48, integration 7/3, self-policy pass 94 files, digest sha256:4cb0114b6adc3abe40f17b19fbcc87f455edd60ca41d984a373fdd49675e2177 identical twice, benchmark 7674/5481 ms peak 513 MB (checker run), golden and kernel diffs empty, two bites (missing key, opaque spread) exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-require-call-argument.md. Step 3 restrict-state-transition: verify exit 0, unit 352/49, integration 7/3, self-policy pass 95 files, digest sha256:08c2bfa7735fcb4df6162a3feb74028b077e149a5f994f458b9afefaf3a0a0e1 identical twice, benchmark 6883/4768 ms at parity with step 2 (other samples up to 27 s under 97 percent external load, F3 filed), golden and kernel diffs empty, two bites exit 1, kz-checker PASS, ADR docs/adr/2026-09-09-restrict-state-transition.md. Step 4 require-ingress-parse: verify exit 0, unit 379/50, integration 7/3, self-policy pass 96 files, digest sha256:b28247eb5fc05949c27daf27a042a17321218ba3eb0cf0160dd5a0ba9f39af37 identical twice, browser 12 passed, benchmark 19093/18080 ms peak 514 MB with the ingress rule's isolated delta within noise, golden and kernel diffs empty, apps/control diff only route.ts and route.test.ts (owner-confirmed seam removal), three bites (request.json escape exit 1; arrow-const provable exit 0; factory-bound const proof failure exit 1), kz-checker PASS, ADR docs/adr/2026-09-09-require-ingress-parse.md. Step 5: traceability 73 mappings incl. FR-LAY/ARG/STA/ING/DOG, kz-policy-rule skill lists the kinds and layers, sentence-5 rule runtime-opens-at-the-boundary added with bite exit 1, final verify exit 0 (unit 379/50, integration 7/3, self-policy pass 17 rules 96 files, digest sha256:f7010c9acca6e98fabaa77e35952f5972d8d82208d2269020f4ad68ecfbe6998 identical twice), kz-checker final gate PASS on every executable gate | -| 4-branch | PR URL recorded | pending | | -| 5-optimize | optimization loop termination | pending | | +| 4-branch | PR URL recorded | PASS | https://github.com/AP3X-Dev/kernel-zero/pull/1 (six commits ff2ebf8..36fddf2 on feat/validator-rule-language; main is PR-only with the verify check required) | +| 5-optimize | optimization loop termination | BLOCKED (recorded) | Not launched. Every PRP success criterion SC-V01..V07 has evidence above and SC-V08 is the open PR. The remaining backlog (F1 digest EOL portability: contract change needing kz-grill; F2 alias-following: moves golden fixtures, needs its own ADR; F3 benchmark headroom: environment) consists of owner decisions the loop's guardrails forbid taking autonomously, and main is PR-only so a self-running loop would push commits onto a PR under owner review. Launching would violate the skill's own start conditions. | ## Failed Attempts | # | Phase/Task | What was tried | Why it failed | Do-not-retry note |