Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"zod"
],
"dependencies": {
"@bodanglin/verdict-contracts": "^0.1.0",
"@bodanglin/verdict-contracts": "^0.2.0",
"@types/express": "^5.0.6",
"zod": "^3.25.76"
},
Expand Down
71 changes: 71 additions & 0 deletions specs/002-fail-closed-next-handler/checklists/fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Fail-closed Requirements Checklist: Fail-closed after policy refusal

**Purpose**: Reviewer-owned unit tests for the written requirements of refuse-then-stop, remaining bypasses, and README disclosure timing
**Created**: 2026-09-06
**Feature**: [spec.md](../spec.md)

**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied.
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete.

## Requirement Completeness

- [x] CHK001 Are all five policy-refusal cases named (no decision, deny, timeout, invalid payload, no endpoint)? [Completeness, Spec §FR-002]
- [x] CHK002 Is “do not forward after refusal” specified as a hard stop, not a best-effort? [Completeness, Spec §FR-001]
- [x] CHK003 Are missing-envelope, un-rechecked local model, and missing integrity evidence listed as bypasses that must close? [Completeness, Spec §FR-006]
- [x] CHK004 Is README disclosure required to remain until the fix and tests are proven? [Completeness, Spec §FR-004]
- [x] CHK005 Is this lane specified as separate from the already-shipped docs-accuracy work? [Completeness, Spec §FR-005]

## Requirement Clarity

- [x] CHK006 Is “refusal” defined as the response already written when policy cannot allow the request? [Clarity, Spec §Key Entities]
- [x] CHK007 Is “every bypass found during the fix” bounded enough that “done” means none remain open? [Clarity, Spec §FR-006, Spec §Clarifications]
- [x] CHK008 Is the compatibility opt-out distinguished from the default policy-gated path? [Clarity, Spec §US1, Plan contracts]

## Requirement Consistency

- [x] CHK009 Do FR-004 / US2 and SC-003 agree that the warning is not removed before green required checks? [Consistency, Spec §FR-004, Spec §SC-003]
- [x] CHK010 Do FR-006 / US3 and SC-004 agree that newly found bypasses are in this lane, not deferred? [Consistency, Spec §FR-006, Spec §SC-004]
- [x] CHK011 Does “allow path unchanged” conflict with requiring an envelope on allow (default path)? [Consistency, Spec §Edge Cases, Plan data-model]

## Acceptance Criteria Quality

- [x] CHK012 Can “zero upstream forwards” be judged without implementation internals? [Measurability, Spec §SC-001]
- [x] CHK013 Can “100% of found bypasses have a test” be counted from an audit list plus any new finds? [Measurability, Spec §SC-004]
- [x] CHK014 Can README presence of the warning be judged by reading the primary README? [Measurability, Spec §SC-003]

## Scenario Coverage

- [x] CHK015 Are primary-path requirements complete for a true allow that still forwards? [Coverage, Spec §SC-002]
- [x] CHK016 Are exception-path requirements complete for all five refusal cases? [Coverage, Spec §US1]
- [x] CHK017 Are recovery/rollback requirements needed, or is revert of this change enough? [Coverage, Spec §Assumptions]

## Edge Case Coverage

- [x] CHK018 Is a refusal that was written while continuation would still run specified as must-stop? [Edge Case, Spec §Edge Cases]
- [x] CHK019 Is a missing envelope on the default path specified as refuse, not forward? [Edge Case, Spec §FR-006]
- [x] CHK020 Is an un-rechecked local model swap specified as refuse or recheck? [Edge Case, Spec §FR-006]

## Non-Functional / Policy Requirements

- [x] CHK021 Is fail-closed specified without inventing latency or throughput numbers this lane does not need? [Non-Functional, Plan Technical Context]
- [x] CHK022 Does the spec keep the product framed as an enforcement client of the decision service? [Non-Functional, Spec §Assumptions, Constitution I]

## Dependencies & Assumptions

- [x] CHK023 Is issue #51 named as the tracking issue for this defect? [Dependency, Spec §FR-005]
- [x] CHK024 Is the isolated-worktree / do-not-write-dirty-checkout constraint documented? [Assumption, Spec §Assumptions]

## Ambiguities & Conflicts

- [x] CHK025 Does “allow path does not change” need a note that default-path envelope enforcement is a close of a bypass, not a change to a true allow? [Conflict, Spec §Edge Cases, Spec §FR-006]
- [x] CHK026 Are plan-level details (file paths, test runner) kept out of the spec and only in the plan? [Clarity, Spec vs Plan]

## Notes

- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers
- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify`
- This run used defaults (depth: standard; audience: PR reviewer; focus: refuse-then-stop, bypass closure, README timing) because `/speckit-checklist` had no extra arguments
- Reviewer evaluation 2026-09-06: all 26 items pass after spec edits (true allow vs envelope bypass, FR-004 same-PR rewrite, rollback=revert, compatibility opt-out named) plus tasks T007/T016
35 changes: 35 additions & 0 deletions specs/002-fail-closed-next-handler/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Fail-closed after policy refusal

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-06
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- Clarify 2026-09-06: Q1 → C. This lane closes every audit bypass found, including new ones during the fix.
- Next: `/speckit-plan`. GitHub issue #51. README disclosure stays until implement+CI.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Contract: Handler continuation after policy check

Public factory: `createNextApiHandler` (higher-level HTTP handler).

## Refusal (must not forward)

When the policy check cannot allow the request, the handler:

- Writes a refusal to the caller (existing 503 body for unavailable/denied).
- Does **not** call the upstream model endpoint.
- Applies for: no decision, explicit deny, timeout, invalid payload, no decision endpoint.

## Allow (default path)

When the policy check allows:

- An envelope MUST be present and enforced before upstream fetch.
- Any locally chosen fallback model MUST be rechecked against that envelope.
- Integrity evidence MUST be independent or the request is refused.

## Compatibility opt-out

Explicit opt-out of policy (`requireCoreDecision: false`) is not a policy-gated path. It must stay labeled as compatibility, not as Core-authorized execution.

## Tests

See [quickstart.md](../quickstart.md). Spies on upstream `/chat/completions` must stay at zero on every refusal and every closed bypass.
39 changes: 39 additions & 0 deletions specs/002-fail-closed-next-handler/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Data Model: 002-fail-closed-next-handler

## Entities

### Policy check result

| Field | Meaning |
|-------|---------|
| allowed | True only when a valid allow decision is present |
| refusal | Already-written caller response when not allowed |
| envelope | Authorization record attached only on allow |

### Continuation decision

| State | Rule |
|-------|------|
| refuse | Return the refusal. Zero upstream forwards. |
| allow | Forward only after envelope checks (default path). |
| compatibility opt-out | Explicit non-default path; not represented as policy-gated. |

### Bypass (must be closed on default path)

| Bypass | Required close |
|--------|----------------|
| Continue after refusal | Stop; do not forward |
| Missing envelope | Refuse; do not forward |
| Local model swap | Recheck against envelope or refuse |
| Missing integrity evidence | Refuse or attach independent digest evidence |

## State transitions

```text
request
-> policy check
-> refusal written -> STOP (no upstream)
-> allow + envelope ok -> forward
-> allow + envelope missing/invalid (default path) -> STOP
-> new bypass found -> STOP until closed
```
74 changes: 74 additions & 0 deletions specs/002-fail-closed-next-handler/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Implementation Plan: Fail-closed after policy refusal

**Branch**: `002-fail-closed-next-handler` | **Date**: 2026-09-06 | **Spec**: [spec.md](./spec.md)

**Input**: Feature specification from `/specs/002-fail-closed-next-handler/spec.md`

## Summary

Stop the higher-level handler from forwarding after a policy refusal, and close every remaining default-path bypass (missing envelope, un-rechecked local model, missing integrity evidence, plus any found during the fix). Tests first. README disclosure last.

## Technical Context

**Language/Version**: TypeScript, Node.js >= 18

**Primary Dependencies**: Existing `@bodanglin/verdict-node` library (`src/index.ts` handler + `src/middleware/forwarder.ts` envelope checks)

**Storage**: N/A

**Testing**: Jest (`npm test -- tests/router.test.ts`)

**Target Platform**: Node HTTP adapter (Express and Next-style handler)

**Project Type**: TypeScript library

**Performance Goals**: N/A — correctness of refuse vs forward

**Constraints**: Fail-closed; constitution I/III/IV; clarify C (every found bypass); isolated worktree; no dirty `feat/nod002-parity-gate` writes

**Scale/Scope**: One public handler factory, proxy continuation, envelope/ladder checks, README paragraphs after green tests

## Constitution Check

| Principle | Status | Evidence |
|-----------|--------|----------|
| I. Downstream enforcement client | Pass | Handler enforces Core decisions; does not become a peer router |
| II. Verifiable claims | Pass | README rewrite only after tests prove fail-closed |
| III. Disclosed defects stay disclosed | Pass | Warning remains until FR-001/FR-003/FR-006 tests green |
| IV. Alpha until declared | Pass | Alpha label stays |

Post-Phase 1: still pass. Design does not hide the defect in docs first.

## Project Structure

### Documentation (this feature)

```text
specs/002-fail-closed-next-handler/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/handler-continuation.md
└── tasks.md # /speckit-tasks — not this command
```

### Source Code (repository root)

```text
src/index.ts # nextApiHandler, middleware, proxy
src/middleware/forwarder.ts # envelope helpers to reuse
tests/router.test.ts # extend Next.js /api compatibility
README.md # last, after tests
docs/adr/ADR-001-*.md # align defect paragraph after tests
```

**Structure Decision**: No new packages. Fix continuation and default-path enforcement in the existing handler.

## Complexity Tracking

> No constitution violations.

| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
29 changes: 29 additions & 0 deletions specs/002-fail-closed-next-handler/quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Quickstart: 002-fail-closed-next-handler

Work only in this worktree. Do not edit the dirty `feat/nod002-parity-gate` checkout. Do not rewrite README until tests pass.

## 1. Tests first

From the worktree root:

```bash
npm test -- tests/router.test.ts
```

Add cases for timeout, invalid decision payload, and no decision endpoint. Each must be 503 (or the existing refusal) with **no** `/chat/completions` fetch.

## 2. Bypass cases

Same file (or adjacent): missing envelope on the default path does not forward; substituted ladder model is rechecked or refused.

## 3. Allow path still works

Existing compatibility and allow tests stay green.

## 4. README last

Only after the suite is green, replace the "Critical limitation / Critical defect" paragraphs so they match fail-closed behavior. Keep alpha and envelope-reconciliation statements.

## 5. CI

`npm test` and lint as in `package.json`.
31 changes: 31 additions & 0 deletions specs/002-fail-closed-next-handler/research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Research: 002-fail-closed-next-handler

**Date**: 2026-09-06

## Decision: Guard continuation, not only the boolean return

- **Decision**: After the policy middleware runs, the higher-level handler must not call the forwarder if the middleware returned false **or** if a refusal status was already written.
- **Rationale**: `origin/master` already returns false after a 503 in some cases and has two tests (unavailable, denied). The README still documents unconditional forwarding. Remaining gaps are missing refusal cases and other bypasses (clarification C). A second check on "response already written" catches a true/false mismatch.
- **Alternatives considered**: Trust only the boolean — rejected; that is the current claimed fix and still disagrees with the README/ADR. Remove the handler — rejected; it is the public Next-style API.

## Decision: Close remaining bypasses on this path in the same lane

- **Decision**: On the default (policy required) path: do not forward without an envelope; do not swap in a local ladder model without rechecking it against the envelope; supply independent policy-digest evidence or refuse. Any new bypass found while fixing is closed here (spec C).
- **Rationale**: FR-006 / User Story 3. Standalone Express forwarder already fail-closes missing envelopes; the higher-level proxy still skips when envelope is absent.
- **Alternatives considered**: Track envelope/ladder as later issues — rejected by clarify C.

## Decision: README warning stays until tests for all FR-002 cases and found bypasses pass

- **Decision**: Do not rewrite the critical-limitation paragraphs until the regression suite is green in this worktree. Then rewrite to match fail-closed behavior and keep alpha + envelope-reconciliation language.
- **Rationale**: Constitution III.
- **Alternatives considered**: Update README first — forbidden.

## Decision: Tests live next to the existing Next.js handler tests

- **Decision**: Extend `tests/router.test.ts` "Next.js /api compatibility" with timeout, invalid payload, no endpoint, envelope-required, and ladder-recheck cases. Spy on `fetch` for `/chat/completions`.
- **Rationale**: Same harness as the two existing 503 tests.
- **Alternatives considered**: New file only — extra split without need.

## Live `origin/master` note

`src/index.ts` `nextApiHandler` already has `if (!authorized) return`. Middleware returns false after 503 when policy is required. Tests cover unavailable and denied only. README/ADR still describe the old unconditional `proxy()` call. This plan treats that as incomplete, not done.
Loading