Skip to content

Document read-only threat boundaries per feature; add safe public service mode (#157, #221) - #324

Merged
BigDella merged 3 commits into
Parcel-Protocol:mainfrom
Jeyrio:feat/threat-boundaries-and-service-status
Oct 21, 2026
Merged

BigDella merged 3 commits into
Parcel-Protocol:mainfrom
Jeyrio:feat/threat-boundaries-and-service-status

Conversation

@Jeyrio

@Jeyrio Jeyrio commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

closes #157
closes #221

Summary

Two independent changes that both make Utix's safety promises checkable instead of assumed.

  1. [Docs] Document read-only threat boundaries per feature #157: Read-only threat boundaries per feature. Each slice README gets a ## Threat boundary section with three answers: Data sources, Outbound requests and Cannot sign or submit. verify:features now enforces it. A migration ledger covers the existing slices, and an automated scan fails any slice whose runtime code calls a signing or submission API. trustline-checker is the documented reference example.
  2. Utix: Add safe public-status model for degraded, paused, or maintenance states #221: Safe public service mode. Operators can declare degraded, paused or maintenance with a short public message. The mode is validated against secret leakage, audited, expires on its own, and is published consistently in three places: the UI (site banner, and a paused kill switch on every tool), the API (GET /api/status) and a background worker (expiry job). It builds on the existing Utix: Add schema-backed public status page data feed #184 status feed without changing that feed's 1.0.0 schema.

A third, mechanical commit regenerates the stale committed registry (details below).

#157: Threat boundaries

What changed

  • scripts/verify-features.mjs
    • evaluateThreatBoundaryDoc(slug, readme): the section must exist, and each of the three bold-labelled fields needs a real answer on the same line. An empty or TODO answer fails.
    • findSigningCalls(sources): scans runtime sources for Keypair.fromSecret / fromRawEd25519Seed / random, .sign(…) / signDecorated, signTransaction / signAuthEntry / signMessage / signBlob, submitTransaction / submitAsyncTransaction and the RPC sendTransaction. Comments are stripped first. __tests__/, fixtures/, e2e/ and msw/ are skipped, because fixtures legitimately sign sample envelopes from fixed seeds.
    • verifySlice reports threatBoundaryPending / threatBoundaryMissing, and the summary line counts pending docs.
  • scripts/threat-boundary-migration.json: ledger of the 70 slices that predate the rule. They warn instead of failing.
  • scripts/new-feature.mjs: the scaffold README now includes the section with TODO placeholders, so a new slice fails until it is filled in.
  • features/trustline-checker/README.md: the worked example. A new schema test backs up its claim that a pasted S… secret in either address field is rejected before any request.
  • docs/FEATURE_CONTRACT.md: rule 15, with the section format, a reviewer checklist and what the verifier enforces. Also an extra "done" checklist item and the layout note.
  • docs/adr/0007-documented-read-only-threat-boundaries.md and the ADR index. CONTRIBUTING.md requires an ADR for contract changes, so it is marked Proposed for maintainers to accept.
  • SECURITY.md: a "Read-only threat boundaries" section explaining how to review a slice against its boundary.

Design decisions

  • Ledger rather than a big-bang migration. This follows ADR 0005. Documenting all 70 slices in one PR would be unreviewable and would conflict with every open slice PR. The ledger may only shrink, so each slice can be documented in its own small PR.
  • The signing scan has no ledger. No existing runtime code matches, so the rule applies to every slice from day one. A ledger here would only create a loophole.
  • The scan is a tripwire, not a proof. The contract and the ADR both say so explicitly. The reviewer checklist asks reviewers to read the documented boundary against lib/ and msw/handlers.ts.

#221: Public service mode

Model (core/status/service-mode.ts)

  • ServiceModeState is trusted state. It has mode, a public message, since and until, plus internal-only reason and changedBy.
  • toPublicServiceNotice copies only the five public fields. validatePublicServiceNotice enforces that exact schema (1.0.0).
  • checkServiceMessage refuses an operator message, rather than scrubbing it, if it contains any of these:
    • a Stellar secret;
    • a bearer token;
    • credentials in a URL;
    • a secret=, token: or api_key= style pair;
    • a private, loopback or link-local IP, or localhost;
    • a stack frame;
    • more than 280 characters.
  • effectiveServiceMode is normal outside the since–until window, so a forgotten mode lifts itself.
  • applyServiceMode raises public components (degraded → degraded, maintenance → under_maintenance, paused → major_outage) and never lowers them. The feed's overall status therefore agrees with the notice, and a declared mode can't hide an observed outage.
  • createServiceModeStore():
    • only maintainer and system actors may change the mode; a user gets service_mode_forbidden;
    • changes are audit-first, following the core/admin-config pattern: every attempt is recorded as service_mode.changed, and if the trail rejects it nothing changes;
    • expire() records service_mode.expired.
  • registerServiceModeJobs(framework, store) registers the status.expire_service_mode worker job. That job is the background path.

Configuration and API (core/status/service-config.ts, app/api/status/route.ts)

  • Configured through UTIX_SERVICE_MODE, UTIX_SERVICE_MESSAGE, UTIX_SERVICE_SINCE and UTIX_SERVICE_UNTIL. These are server-side and deliberately not NEXT_PUBLIC_*.
  • Parsing never throws:
    • an unknown mode is published as degraded, not normal, so an operator's typo stays visible;
    • an unsafe message is dropped and generic copy is shown;
    • a bad or inverted window falls back to open-ended.
    • Every such problem is emitted as telemetry with a code, never the value.
  • GET /api/status returns { notice, feed }. It is force-dynamic and sends Cache-Control: no-store. If either part fails its schema, it answers 503 { error: "status_unavailable" } rather than publishing anything partial.

UI

  • ServiceStatusBanner sits under the header in AppShell. It uses the existing StatusMessage primitive and shows the message (or generic copy from core/status/copy.ts), the expected end time and a link to /api/status.
  • While paused, app/tools/[slug]/page.tsx renders PausedToolNotice instead of mounting the panel, so no tool can issue a request.
  • app/layout.tsx sets revalidate = 60, so the statically rendered pages pick up a since/until boundary within about a minute.

Audit

  • service_mode.changed and service_mode.expired are added to SENSITIVE_ACTIONS, and their emitters are registered in scripts/verify-audit.mjs.

Design decisions and tradeoffs

  • A separate notice instead of a feed schema bump. Adding a field to the 1.0.0 feed would break consumers that validate exact keys, which validatePublicStatusFeed itself does. The notice has its own versioned schema, and the mode is reflected in the feed through component statuses only.
  • Environment configuration rather than a database. The app has no persistent server store, and env vars need no migration and roll back by being unset. The store API exists for programmatic callers such as an admin tool or the rollout guard. The tradeoff is that changing a variable needs a redeploy or restart on most hosts. The runbook recommends always setting UNTIL so a mode can end without a deploy.
  • One app-wide mode, not per-component scoping. The issue asks for a small, well-tested vertical slice, and per-tool scoping can be added later without breaking the notice schema.
  • paused maps to major_outage in the feed. From a user's point of view, deliberately unavailable tools are an outage. Feed consumers that only know the existing statuses still get a truthful signal.

Operational docs

docs/PUBLIC_STATUS.md covers:

  • the mode semantics table;
  • configuration;
  • where the mode surfaces;
  • the secret-safety rules;
  • a failure-mode table;
  • freshness semantics;
  • a runbook for entering, verifying, leaving, emergency and code rollback.

docs/DEPLOYMENT.md, .env.example and docs/ARCHITECTURE.md are updated.

Tests

New and updated tests, all passing:

File Covers
core/__tests__/threatBoundaryDocs.test.ts Doc evaluation: complete, missing, empty and TODO fields, section boundaries. Signing scan: hits per pattern, comments, Math.sign, non-runtime dirs. verifySlice with and without the ledger. Every real slice is either documented or in the ledger, the ledger has no stale or unknown slugs, and no real slice has signing calls.
features/trustline-checker/__tests__/schema.test.ts A pasted secret key is rejected in both address fields.
core/status/__tests__/service-mode.test.ts Message safety (each unsafe class, length, whitespace), window and expiry, notice projection and schema, applyServiceMode raise-only behaviour, store authorization, audit-first rejection, denied-attempt auditing, telemetry, the expiry job through a real createWorkerFramework.
core/status/__tests__/service-config.test.ts Env parsing and fallbacks, a buildStatusResponse 200 response per mode with headers, internal fields never in the body, 503 path, telemetry codes.
core/status/__tests__/ServiceStatusBanner.test.tsx Renders nothing in normal; title, tone, fallback copy, end time and status link; internal fields never rendered; axe-clean in degraded and paused states; PausedToolNotice.

Verification

  • Unit tests: npx vitest run passes for every area this PR touches: core/status, core/audit, core/admin-config, core/ledger, core/contract, core/workers, core/__tests__, core/ui, features/trustline-checker and the bundle tests. One core/admin-config audit test fails identically on main (see below).

  • Lint: clean for every file in this PR.

  • verify:features, verify:boundaries, verify:audit, verify:issues: no new failures compared with main.

  • Typecheck: tsc --noEmit reports no new errors (same locations and codes as main).

  • next build: compiles successfully, then stops at Next's type-check step on a pre-existing error in an untouched file (core/admin-config/store.ts:23, Role not exported). main fails the same way, because it already has 271 tsc errors.

Pre-existing failures on main, not touched here

These reproduce identically on da96089 and are out of scope:

  • tsc: 271 errors.
  • eslint .: 36 errors in files this PR does not touch.
  • verify:features: 6 slices fail structural rules (activity-timeline, duplicate-transaction-scanner, explorer-links, soroban-auth-inspector, soroban-fee-estimator, soroban-spec-viewer).
  • verify:audit: 11 declared actions with no registered emitter.
  • vitest: 51 failing tests and 3 file-level errors, across files such as __tests__/core-modules.test.ts, core/ui/__tests__/*, core/admin-config/__tests__/audit.test.ts, core/analytics, core/sessions, core/webhooks and features/testnet-faucet. They were run on main and on this branch with the same command and produced identical failure lists, test for test.

Files changed

#157

  • scripts/verify-features.mjs, scripts/verify-features.d.mts, scripts/threat-boundary-migration.json (new), scripts/new-feature.mjs
  • features/trustline-checker/README.md, features/trustline-checker/__tests__/schema.test.ts
  • docs/FEATURE_CONTRACT.md, docs/adr/0007-documented-read-only-threat-boundaries.md (new), docs/adr/README.md, SECURITY.md
  • core/__tests__/threatBoundaryDocs.test.ts (new)

#221

  • core/status/service-mode.ts, service-config.ts, ServiceStatusBanner.tsx, copy.ts (all new); core/status/feed.ts (adds the exported worseComponentStatus helper); core/status/index.ts
  • core/status/__tests__/service-mode.test.ts, service-config.test.ts, ServiceStatusBanner.test.tsx (new)
  • app/api/status/route.ts (new), app/layout.tsx, app/tools/[slug]/page.tsx, core/layout/AppShell.tsx
  • core/audit/audit.ts, scripts/verify-audit.mjs
  • docs/PUBLIC_STATUS.md, docs/DEPLOYMENT.md, docs/ARCHITECTURE.md, .env.example

Chore

  • core/registry/*.generated.ts: output of npm run registry. main's committed copies were missing duplicate-transaction-scanner, multisig-analyzer, offer-inspector and sponsorship-planner, which fails CI's "generated registry has no uncommitted changes" step.

Migration and config

  • No data or schema migration. The status feed stays at 1.0.0. The notice is a new endpoint with its own 1.0.0 schema.
  • New optional env vars: UTIX_SERVICE_MODE, UTIX_SERVICE_MESSAGE, UTIX_SERVICE_SINCE, UTIX_SERVICE_UNTIL. When unset the mode is normal and behaviour is unchanged.
  • Contributors: new slices must fill in the ## Threat boundary section. Existing slices are on the ledger and only warn.
  • ADR 0007 is Proposed and needs a maintainer to accept it.

Deployment notes

  • Safe to deploy with no env changes: nothing renders differently in normal.
  • /api/status is a new dynamic route. Pages now revalidate every 60 s instead of being fully static, so expect a small increase in render invocations on hosts that bill for them.
  • To pause in an incident: set UTIX_SERVICE_MODE=paused, a public message and preferably UTIX_SERVICE_UNTIL, then redeploy. Confirm with curl -s https://<host>/api/status | jq .notice. To roll back, unset the variables and redeploy. The full runbook is in docs/PUBLIC_STATUS.md.
  • Code rollback: reverting removes the banner, the paused gate and /api/status. The existing feed module and its consumers are unaffected.

Notes for reviewers

🤖 Generated with Claude Code

Jeyrio and others added 3 commits October 21, 2026 18:09
The committed registry on main was missing duplicate-transaction-scanner,
multisig-analyzer, offer-inspector and sponsorship-planner, so CI's
"generated registry has no uncommitted changes" step fails. Output of
`npm run registry`; no hand edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every slice README now carries a "## Threat boundary" section (data
sources, outbound requests, proof it cannot sign or submit). verify:features
fails a missing/empty/TODO section, with a shrinking migration ledger for
the 70 pre-existing slices, and fails any runtime source that calls a
signing or submission API. trustline-checker is the reference example.
Adds FEATURE_CONTRACT rule 15 with a reviewer checklist, ADR 0007 and a
SECURITY.md section.

Refs Parcel-Protocol#157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…enance

Adds an operator-declared, app-wide service mode on top of the Parcel-Protocol#184 status
feed: a validated public message (secrets, tokens, credential URLs, private
addresses and stack frames are refused), an audit-first store restricted to
maintainer/system actors, clock-based expiry with a background worker job,
GET /api/status, a site banner, and a paused kill switch on the tool route.
The 1.0.0 feed schema is unchanged. Configured with UTIX_SERVICE_* env vars;
runbook and failure modes in docs/PUBLIC_STATUS.md.

Refs Parcel-Protocol#221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@Jeyrio Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@BigDella
BigDella merged commit 6efa351 into Parcel-Protocol:main Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Utix: Add safe public-status model for degraded, paused, or maintenance states [Docs] Document read-only threat boundaries per feature

2 participants