← Methodology library
Methodology · Route Assertions

Declare which routes Cipherwake should gate. No credentials required.

Route assertions verify your declared private routes are still gated on every deploy. The catastrophic regression — a middleware change flipping /api/admin/* from 401 to 200 — is invisible to drift-only checking but a few-line config away from being caught.

1. What we assert

An assertion is a small contract: this path, when probed without auth, should be in this state. Three states:

Expected stateMeaning
protectedReturns 401, 403, or a 3xx redirect to a login page. The route is behind auth.
exposedReturns 200. The route is intentionally public.
missingReturns 404. The route doesn't exist on this site.

Severity is a function of the mismatch AND the assertion source — customer-declared assertions are stricter than defaults/auto-detected, because a customer who declared a route in .cipherwake.json signed off on it being a contract.

The severity rubric (R87.3 — 2026-06-05)

MismatchCustomer-declaredDefault / autoRationale
protected → exposed (admin went public)critical — auto-blockhigh — reviewThe customer signed off on protection; default is our guess that could be a legitimate public route (e.g. SPA /account gated client-side). Auto-block only when the customer asked us to.
exposed → missing (public route gone)highhighUX break either way.
missing → exposed (deprecation leak)mediumlowCustomer-declared "should not exist" is a stronger claim.
protected → missing (route deleted)mediumsilently droppedCustomer named the path; default is our guess that probably doesn't apply.
any → blocked or errorlowlowWAF / network — ambiguous signal.

What this means for the AI Coder Protocol: on day 1 (no .cipherwake.json), default assertions surface as review — the AI coder asks the user whether the route is intentionally public, and if so, offers to add a one-line override to .cipherwake.json. On day 2+ (config present), customer-declared assertions auto-block on regressions — the strict gate the customer signed up for. This avoids day-1 false-positive cry-wolf while keeping the catastrophic-event catch intact.

Per-assertion severity can also be overridden via the optional severity field — sometimes a missing assertion is actually critical (e.g. a deprecation route that should never return content again).

2. Where assertions come from

Cipherwake evaluates three sources per scan:

  1. Customer config.cipherwake.json in your repo (committed). Listed first. App-specific routes go here.
  2. Defaults — Cipherwake ships a list of nearly-universal protected paths: /admin, /admin/, /account, /dashboard, /api/admin, /api/account, /api/me, /internal. These run unless explicitly overridden.
  3. Auto-detected — Cipherwake reads your robots.txt Disallow rules and parses your homepage for links to /login, /signin, /dashboard, etc. Any inferred assertion is tagged source: auto so you can see exactly where it came from.

On path collision the priority is customer > default > auto. Customer-declared assertions win — if your /admin is intentionally a public marketing page, you can declare { "path": "/admin", "expect": "exposed" } and the default protected-assertion goes away.

3. Body assertions — handling the "200 placeholder + server-gated mutation" pattern (R87.6)

The default App Router pattern in Next.js (and similar in Remix, SvelteKit, etc.) is to render a 200 response even on protected routes, then gate the dangerous surface (form action, mutation handler) server-side. The route returns 200 with an "Admin only" placeholder; the dangerous content never reaches the unauthenticated user. Pure status-code classification reads this as exposed — a false positive that customers would silence by declaring expected: exposed, defeating the entire feature.

Body assertions handle this cleanly. On an expect: protected assertion, add bodyContains (a string or array that MUST appear in the response body) and/or bodyAbsent (a string or array that MUST NOT appear). When set, Cipherwake fetches up to 16 KB of body bytes on a GET and runs substring checks:

{
  "path": "/admin",
  "expect": "protected",
  "bodyContains": "Sign in to continue",
  "bodyAbsent": ["data-admin-action=", "<email>"],
  "why": "Soft-gated admin page — server returns 200 with a login placeholder; the form actions are server-gated."
}

Decision logic:

The result includes a bodyCheck field on each assertion so AI coders can see why the assertion passed or failed. The CIPHERWAKE_ROUTE_ASSERTIONS block surfaces it inline: PASS [info] customer:/admin expected=protected actual=protected status=200 body=soft_gate_detected.

Performance: body fetches only fire when an assertion declares bodyContains / bodyAbsent. Default assertions don't include body checks (status-only). For body-checked paths we go GET instead of HEAD, capped at 16 KB to keep probes cheap (~1 RTT + a few KB transfer).

4. Customer config — .cipherwake.json

Drop a file named .cipherwake.json at the root of your repo (we walk up to 5 directories to find it). The CLI reads this once at pqcheck deploy-check time and forwards it as the routeAssertionsConfig field in the trust-diff request body.

{
  "routeAssertions": {
    "assertions": [
      {
        "path": "/api/admin/users",
        "expect": "protected",
        "why": "User management API — admin-only"
      },
      {
        "path": "/api/admin/exports",
        "expect": "protected",
        "why": "Bulk data export — must be gated"
      },
      {
        "path": "/api/internal/healthcheck",
        "expect": "protected",
        "why": "Internal health endpoint, not for public consumption"
      },
      {
        "path": "/api/public/version",
        "expect": "exposed",
        "why": "Public version endpoint — should always be reachable"
      },
      {
        "path": "/legacy/v1/admin",
        "expect": "missing",
        "severity": "critical",
        "why": "Deprecated route — should return 404, never content"
      }
    ]
  }
}

The CLI tolerates either { "routeAssertions": { "assertions": [...] } } or { "assertions": [...] } at the top level. Both forms work.

To replace Cipherwake's defaults entirely (rare — only for apps with non-standard route shapes), set "replace_defaults": true. Most customers want to extend defaults, not replace them, so we default to merge.

5. Auto-detection sources

Two public signals we read every scan, no config needed:

robots.txt Disallow rules

Every Disallow: line in your robots.txt becomes a candidate expect: protected assertion. Rationale: you told crawlers to avoid this path, which usually means it should also be auth-gated for direct anonymous probes. Wildcards, query patterns, and the apex Disallow: / are skipped.

Homepage auth-link parsing

We fetch your homepage HTML and look for anchor links matching /login, /signin, /auth/*, /dashboard, /account, /settings, /profile. The presence of these links tells us your app uses auth and lets us add conventional probes for paths that pair with the auth surface — login pages should be exposed, dashboard-style pages should be protected.

We do NOT crawl beyond the homepage. We do NOT enumerate or fuzz. Auto-detection only acts on signals the customer has already chosen to publish.

6. How this affects ship_decision

The trust-diff --ai guard block emits two new fields per scan:

ship_decision_assertions=pass|review|block
assertions_total=12
assertions_passed=11
assertions_failed=1
assertions_critical_failures=1
assertions_sources=customer=5,default=8,auto=2
assertion_top_failure=/api/admin/users: expected protected, got exposed

And a separate parseable block after the guard block:

CIPHERWAKE_ROUTE_ASSERTIONS
total=12
passed=11
failed=1
critical_failures=1
sources_customer=5
sources_default=8
sources_auto=2
PASS [info] customer:/api/admin/users expected=protected actual=protected status=401 — User management API
FAIL [critical] customer:/api/admin/exports expected=protected actual=exposed status=200 — Bulk data export
PASS [info] default:/admin expected=protected actual=protected status=302
…
END_CIPHERWAKE_ROUTE_ASSERTIONS

ship_decision is now worst-of(drift, route_assertions, optional posture). A critical assertion failure (declared protected, actually exposed) blocks the deploy unconditionally — this is the catastrophic case the feature exists to catch, and it does NOT require --strict-posture or any opt-in flag. High/medium failures promote to review.

For AI Coder Protocol users: the existing rule — "route on ship_decision" — keeps working. The protocol page documents that route assertions are part of ship_decision by default.

7. What this tool does NOT claim

8. Limitations + try it

Limitations

Try it

# Add a .cipherwake.json to your repo:
echo '{ "routeAssertions": { "assertions": [
  { "path": "/api/admin/users", "expect": "protected", "why": "User mgmt" }
] } }' > .cipherwake.json

# Then run deploy-check; the assertion shows up in the --ai block:
npx pqcheck deploy-check yourdomain.com --ai

9. Local-only stats — no data exhaust either

Cipherwake records per-check stats locally in .cipherwake/stats.json at the customer's repo root. No telemetry, no analytics, no cross-repo aggregation. The customer's results stay on their machine. This matches Cipherwake's "no credentials" stance — no credentials and now no data exhaust either.

Recording behavior:

Privacy guarantees:

10. Methodology version + changelog