Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MIGRATIONS_DATABASE_URL=postgresql://<schema-owner>:<password>@postgres:5432/reg
# Consolidated router exposure. Production requires this exact set; missing or
# extra names abort startup. DISABLED_ROUTERS and experimental bypasses are
# local/test-only.
ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance
ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance,sandbox
# Optional local/test fallback for Inflow Workbench persistence.
# Production tenants use Postgres tables from Alembic v073 when DATABASE_URL is available.
REGENGINE_INFLOW_WORKBENCH_PATH=/tmp/regengine_inflow_workbench.json
Expand Down
19 changes: 19 additions & 0 deletions PRODUCTION_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ FSMA pipeline as the export stage's public attestation surface, and both
assert they read no tenant data. **Neither has a recorded sign-off against
the Promotion Rule below** — that review is outstanding (#2838).

A third joined them by release-gate promotion rather than unconditional
mounting:

- `sandbox` — `POST /api/v1/sandbox/evaluate` and `POST /api/v1/sandbox/trace`
(`services/ingestion/app/sandbox/router.py`). Public FSMA 204 checker behind
the Inflow Lab CSV evaluator. No auth, no tenant context, and nothing
persisted; bounded by a per-IP rate limit and 2 MB / 500-event payload caps.
Promoted into `ENABLED_ROUTERS` by #3825 **with** sign-off against the
Promotion Rule below: tenant-isolation, contract, and failure-mode tests all
live in `services/ingestion/tests/test_sandbox_production_promotion_3825.py`.
It belongs to the FSMA pipeline as the `validate` stage's public front end —
the same rule engine the authenticated ingest path runs, over data the
caller supplies and the platform does not keep.

The persisted share endpoints that once shared this router were deliberately
left out, under the separate `sandbox_share` owner: that write path is
unauthenticated and tenant-less, its `share_url` points at a deleted page,
and its rate limit is per-process. See `sandbox/share_router.py`.

Together with `remediation` (15 routes across its two routers), these three
account for 17 routes that were absent from the router inventory before
2026-08-26.
Expand Down
30 changes: 25 additions & 5 deletions docs/engineering/ROUTER_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ These should be disabled in production unless explicitly promoted:
- `scraping`
- `discovery`
- `sources`
- `sandbox`
- `sandbox_share` — persisted sandbox share links (`POST /api/v1/sandbox/share`,
`GET /api/v1/sandbox/share/{id}`). Split out of `sandbox` by #3825 and held
back: the write path is unauthenticated and tenant-less, its `share_url`
points at the deleted `/sandbox/results/[id]` page, and its 10/hour/IP cap
is a per-process dict so it does not hold across replicas. Resolve all
three before promoting.
- `sensitech`
- `audit` (mock audit)
- `sop`
Expand All @@ -128,17 +133,32 @@ Rationale: these surfaces either expand the product beyond FSMA evidence flow, a
## Required Production Configuration

```text
ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance
ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance,sandbox
```

This value is intentionally exact, not a customizable production subset. A
router change is a release-gate change: update `server/router_policy.py`, add
the operation-level auth/tenant manifest and executable test adapters, and
repeat staging isolation checks before changing the environment.

Graph, NLP, scraping, discovery, sandbox, recall simulations, legacy export,
and every other omitted optional router are release-disabled and return 404.
Graph promotion additionally requires the tenant matrix against Neo4j.
Graph, NLP, scraping, discovery, sandbox share links, recall simulations,
legacy export, and every other omitted optional router are release-disabled
and return 404. Graph promotion additionally requires the tenant matrix
against Neo4j.

### `sandbox` (promoted, #3825)

Owner: ingestion. Surface: `POST /api/v1/sandbox/evaluate` and
`POST /api/v1/sandbox/trace` (`services/ingestion/app/sandbox/router.py`).

Both are unauthenticated by design — they are the public FSMA 204 checker
behind the Inflow Lab CSV evaluator — and both are stateless: they persist
nothing, accept no tenant identifier, and read no tenant data, so there is no
tenant boundary to cross. Abuse is bounded by a per-IP rate limit plus 2 MB
and 500-event payload caps. Promotion evidence lives in
`services/ingestion/tests/test_sandbox_production_promotion_3825.py`, which
asserts the statelessness the promotion rests on and fails if a write path or
tenant parameter is ever added to this router.

## Route-Complete Security Manifest

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"recall",
"request_workflow",
"rules",
"sandbox",
"score",
"sla_tracking"
],
Expand Down Expand Up @@ -241,5 +242,5 @@
"Graph and overlay remain release-disabled; current-main integration must hard-404 graph-backed /api/fsma aliases and all /api/controls aliases before authentication or upstream fetch.",
"Candidate staging database roles, the route-complete two-tenant matrix, cross-replica Redis revocation, enumeration timing, and the controlled CSRF/SSRF matrix have not run."
],
"notes": "Source candidate only. The review-driven account-erasure corrections, destructive-database guard, and corrected staging proof are locally verified; independent staging-spec and repaired-P2 code-quality/security re-reviews are approved. Current-main integration remains pending. Alembic revision b71d9e4c2a60 and local PostgreSQL 16 checks are historical source-development evidence, not staging evidence. Current frontend production d9c1d17fc5fcbf233d2362f885f370509aa635ce and Railway deployment 9f9ef063-8c94-4983-9624-ed4726f072b9 were inspected, but neither is this local checkpoint candidate and the rendered public contract differs from candidate source. Variable names only were audited: Vercel retains backend-only database/migration and obsolete generic API-key names, while Railway retains the retired shared administrative and generic API-key names. Values were not read. Promotion remains blocked until current-main integration hard-fails graph/overlay BFF aliases, explicitly defers remediation workers/UI with product/compliance signoff, binds immutable candidate deployments, migrates callers, revokes and removes retired credentials, corrects the environment boundary on a new deployment, and passes every required staging and production check."
"notes": "Source candidate only. The review-driven account-erasure corrections, destructive-database guard, and corrected staging proof are locally verified; independent staging-spec and repaired-P2 code-quality/security re-reviews are approved. Current-main integration remains pending. Alembic revision b71d9e4c2a60 and local PostgreSQL 16 checks are historical source-development evidence, not staging evidence. Current frontend production d9c1d17fc5fcbf233d2362f885f370509aa635ce and Railway deployment 9f9ef063-8c94-4983-9624-ed4726f072b9 were inspected, but neither is this local checkpoint candidate and the rendered public contract differs from candidate source. Variable names only were audited: Vercel retains backend-only database/migration and obsolete generic API-key names, while Railway retains the retired shared administrative and generic API-key names. Values were not read. Promotion remains blocked until current-main integration hard-fails graph/overlay BFF aliases, explicitly defers remediation workers/UI with product/compliance signoff, binds immutable candidate deployments, migrates callers, revokes and removes retired credentials, corrects the environment boundary on a new deployment, and passes every required staging and production check. The sandbox router was promoted into the production allowlist after this record's verification date by #3825 (stateless /api/v1/sandbox/evaluate and /trace only; the persisted share endpoints stayed unpromoted under the separate sandbox_share owner). It is listed in enabled_routers because the release-evidence validator requires that field to match PRODUCTION_ENABLED_ROUTERS exactly, but it was NOT exercised by the 2026-08-26 verification recorded here; its evidence is the promotion-gate suite in services/ingestion/tests/test_sandbox_production_promotion_3825.py."
}
57 changes: 31 additions & 26 deletions docs/security/route-security-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"recall",
"request_workflow",
"rules",
"sandbox",
"score",
"sla_tracking"
],
Expand Down Expand Up @@ -1588,6 +1589,28 @@
"storage_boundary": "postgres-rls:credential-bound-tenant",
"test_adapter": "tenant_api_key_postgres_rls"
},
{
"operation": "sandbox:POST:/api/v1/sandbox/evaluate",
"owner": "sandbox",
"method": "POST",
"path": "/api/v1/sandbox/evaluate",
"classification": "public",
"authentication": "none",
"required_permissions": [],
"storage_boundary": "none:stateless-no-persistence",
"test_adapter": "public_no_auth"
},
{
"operation": "sandbox:POST:/api/v1/sandbox/trace",
"owner": "sandbox",
"method": "POST",
"path": "/api/v1/sandbox/trace",
"classification": "public",
"authentication": "none",
"required_permissions": [],
"storage_boundary": "none:stateless-no-persistence",
"test_adapter": "public_no_auth"
},
{
"operation": "sla_tracking:GET:/api/v1/sla/alerts/{tenant_id}",
"owner": "sla_tracking",
Expand Down Expand Up @@ -4483,41 +4506,23 @@
"probe_path": "/api/v1/remediation/supplier/session/current"
},
{
"operation": "sandbox:POST:/api/v1/sandbox/evaluate",
"owner": "sandbox",
"method": "POST",
"path": "/api/v1/sandbox/evaluate",
"inventory_source": "development-route-table",
"probe_method": "POST",
"probe_path": "/api/v1/sandbox/evaluate"
},
{
"operation": "sandbox:POST:/api/v1/sandbox/share",
"owner": "sandbox",
"operation": "sandbox_share:POST:/api/v1/sandbox/share",
"owner": "sandbox_share",
"method": "POST",
"path": "/api/v1/sandbox/share",
"inventory_source": "development-route-table",
"probe_method": "POST",
"probe_path": "/api/v1/sandbox/share"
},
{
"operation": "sandbox:GET:/api/v1/sandbox/share/{share_id}",
"owner": "sandbox",
"operation": "sandbox_share:GET:/api/v1/sandbox/share/{share_id}",
"owner": "sandbox_share",
"method": "GET",
"path": "/api/v1/sandbox/share/{share_id}",
"inventory_source": "development-route-table",
"probe_method": "GET",
"probe_path": "/api/v1/sandbox/share/00000000-0000-4000-8000-000000000000"
},
{
"operation": "sandbox:POST:/api/v1/sandbox/trace",
"owner": "sandbox",
"method": "POST",
"path": "/api/v1/sandbox/trace",
"inventory_source": "development-route-table",
"probe_method": "POST",
"probe_path": "/api/v1/sandbox/trace"
},
{
"operation": "settings:GET:/api/v1/settings/{tenant_id}",
"owner": "settings",
Expand Down Expand Up @@ -4970,12 +4975,12 @@
}
],
"summary": {
"enabled_operation_count": 259,
"disabled_operation_count": 180,
"disabled_unique_path_count": 163,
"enabled_operation_count": 261,
"disabled_operation_count": 178,
"disabled_unique_path_count": 161,
"enabled_by_classification": {
"global-sysadmin": 16,
"public": 30,
"public": 32,
"tenant-scoped": 206,
"workload-only": 7
}
Expand Down
26 changes: 14 additions & 12 deletions frontend/src/lib/sandbox-cta.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
// #2757 — single source of truth for every "try it yourself" CTA on the
// marketing site (compare pages, homepage).
//
// Why this is not a plain constant: the free CSV sandbox is served by the
// `sandbox` router, which server/main.py lists in _EXPERIMENTAL_ROUTERS and
// auto-disables in production unless ENABLE_EXPERIMENTAL_ROUTERS=true. When
// the router is not mounted, POST /api/v1/sandbox/evaluate 404s, so a CTA
// promising "results in under 60 seconds" would be a claim the deployment
// cannot honor.
// Why this is not a plain constant: a CTA must never promise a destination
// the deployment cannot serve.
//
// PR #2730 hit exactly this: it repointed the dead homepage sandbox hash
// anchors at a dashboard page but left the sandbox prose above the buttons,
// so the copy promised a CSV sandbox while the button opened a dashboard.
// Resolving copy and destination together, from one flag, is what stops
// those two drifting apart again. The fallback destination is /walkthrough;
// the old /demo route was removed with the synthetic workspace cleanup.
// Resolving copy and destination together, from one place, is what stops
// those two drifting apart again. The old /demo route was removed with the
// synthetic workspace cleanup.
//
// Enabling NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES (or listing 'sandbox' in
// NEXT_PUBLIC_ENABLED_EXPERIMENTAL_TOOLS) alongside the backend's
// ENABLE_EXPERIMENTAL_ROUTERS lights up the whole funnel with no code change.
// #3825 note — the backend side of this comment is no longer what it was.
// Router exposure now lives in server/router_policy.py, not main.py, and
// production rejects ENABLE_EXPERIMENTAL_ROUTERS outright: optional routers
// are promoted by name into PRODUCTION_ENABLED_ROUTERS. The `sandbox` router
// was promoted that way and POST /api/v1/sandbox/evaluate is live in
// production, serving the Inflow Lab CSV checker. That does NOT bring back a
// public /sandbox page — that route stays retired and this CTA still points
// at /ingest. Only the persisted /share endpoints remain unpromoted, under
// the separate `sandbox_share` owner.

import { isExperimentalToolEnabled } from '@/lib/experimental-features';

Expand Down
8 changes: 8 additions & 0 deletions server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,14 @@ async def list_enabled_features():

include_owned_router(app, sandbox_router, owner="sandbox")

# #3825: the persisted share endpoints mount under their own owner so the
# stateless evaluator above can be production-enabled without them. This
# owner stays out of PRODUCTION_ENABLED_ROUTERS; see sandbox/share_router.py.
if _router_enabled("sandbox_share"):
from services.ingestion.app.sandbox.share_router import router as sandbox_share_router

include_owned_router(app, sandbox_share_router, owner="sandbox_share")

if _router_enabled("inflow_workbench"):
from services.ingestion.app.inflow_workbench import router as inflow_workbench_router

Expand Down
12 changes: 12 additions & 0 deletions server/route_security_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@
("root", "GET", "/"),
("proof_verify", "POST", "/api/v1/proof/verify"),
("proof_recall", "POST", "/api/v1/proof/recall-drill"),
# #3825: public FSMA 204 checker behind the Inflow Lab CSV evaluator.
# Both are stateless — they evaluate caller-supplied data against the
# rule engine and persist nothing, so there is no tenant boundary to
# cross. Bounded by a per-IP rate limit and 2MB/500-event caps. The
# persisted /share endpoints are deliberately NOT here: they belong to
# the unpromoted "sandbox_share" owner.
("sandbox", "POST", "/api/v1/sandbox/evaluate"),
("sandbox", "POST", "/api/v1/sandbox/trace"),
("csv", "GET", "/api/v1/templates"),
("csv", "GET", "/api/v1/templates/{cte_type}"),
("portal", "GET", "/api/v1/portal/{portal_id}"),
Expand Down Expand Up @@ -369,6 +377,10 @@ def _public_storage_boundary(owner: str, path: str) -> str:
return "postgres-rls:opaque-portal-capability"
if owner in {"proof_verify", "proof_recall"}:
return "public-proof-store:non-secret"
# #3825: the promoted sandbox operations touch no store at all — input is
# evaluated in memory and discarded with the request.
if owner == "sandbox":
return "none:stateless-no-persistence"
if owner == "admin" and path.startswith(("/auth/", "/v1/auth/", "/api/v1/tools/")):
return "admin-postgres:identity-bound-public-flow"
return "public-static-or-health:no-tenant-secret"
Expand Down
13 changes: 12 additions & 1 deletion server/router_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"discovery",
"sources",
"sandbox",
"sandbox_share",
"inflow_workbench",
"fda_export",
"csv",
Expand Down Expand Up @@ -79,6 +80,13 @@
# decision and must update the route-complete tenant/auth gate before deploy.
PRODUCTION_ENABLED_ROUTERS = frozenset(
{
# #3825: the stateless sandbox evaluator (/evaluate, /trace) backs the
# Inflow Lab CSV checker, which ships in production and 404'd without
# it. Promoted on the strength of its statelessness — it persists
# nothing, accepts no tenant id, and reads no tenant data. The
# persisted share endpoints deliberately stayed behind under the
# separate "sandbox_share" owner, which must NOT be added here.
"sandbox",
"inflow_workbench",
"fda_export",
"csv",
Expand Down Expand Up @@ -112,7 +120,10 @@
"scraping",
"discovery",
"sources",
"sandbox",
# #3825: "sandbox" itself is no longer experimental — it is in
# PRODUCTION_ENABLED_ROUTERS. Only the persisted share endpoints
# remain unpromoted; see share_router.py for why.
"sandbox_share",
"sensitech",
"audit",
"sop",
Expand Down
Loading
Loading