The threat model
This document names who can act on ExecBound, what each trust boundary must hold, which threats apply at each boundary, and what evidence shows that a mitigation works. It maps attacker goals to the security invariants in CLAUDE.md, the code that enforces them and the acceptance scenarios in the acceptance inventory (A to X), the context contracts guide (CC1 to CC12, CP1 to CP12 and CA1 to CA10) and the shared limit scopes (SL1 to SL12), external execution checkpoint (GC1 to GC18) and Arcade logic extension (AL1 to AL34) sections of the inventory. Residual risks are stated plainly with the issue that tracks each one. The architecture guide describes the components; this document describes how they fail and why they should not.
Scope and method
Covered: the gateway (HTTP and MCP), the human surface, PostgreSQL, the protected mock providers, monitoring intake and alert delivery, owner tooling and the CLI, offline replay and export, and the build and CI pipeline.
Not covered: real vendor systems (only controlled mock providers exist), operating-system sandboxing of the gateway process, the hosting platform of the Render pilot (Render and Supabase are trusted as described in DEPLOYMENT.md), and the security of a customer's own agent platform such as a Tines tenant. B9 below covers what a Gate caller can do to ExecBound; what it does at its own vendor with its own credential is outside this model entirely, which is why that boundary's rows are mostly limits rather than controls.
Method: for each trust boundary, threats are classified with STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege). Each row records the mitigation, where it lives in code, the scenario or test that exercises it, and a status:
| Status | Meaning |
|---|---|
| Verified | An acceptance scenario or named test exercises the mitigation on real PostgreSQL, real Chromium or the real Cedar engine. |
| Partial | A control exists, but a documented gap remains. |
| Open | No control yet; an issue tracks the work. |
| Accepted | A design limit stated in the documentation; not scheduled for change. |
Assets
- Execution authority: the ability to make a provider perform a consequential action. Only the durable execution claim inside one tenant transaction confers it.
- Approvals: scoped, expiring, server-side human decisions consumed with exactly one execution claim.
- Impact budgets and target claims: shared limits per principal, tenant, principal group, connector account and resource selector, and one unresolved claim per canonical target.
- Frozen plans and terminal evidence: the resolved request, plan hash, provider journals and audit events that replay and reconciliation rely on.
- Audit chain: per-tenant hash-chained events with a separately retained trusted head.
- Credentials and keys: agent tokens, human sessions and CSRF tokens (stored only as hashes), continuation signing keys, provider dispatch and control credentials, provider evidence keys, monitoring source tokens and the database roles.
- Tenant isolation: every table is tenant-scoped with forced row-level security.
- Policy bundles and context settings: immutable, digest-bound content whose activation is an audited ADMIN action.
- Monitoring findings and alerts: detective evidence that must stay separate from execution authority.
- Runtime configuration: private files that name connector destinations, keys and precondition classes; never derived from a request.
- Executor keys: per-tenant Ed25519 public keys an external executor's report is verified against. ExecBound holds no private half, so the asset is the ring's integrity — which keys are registered, when each began and whether one was retired or cut off — rather than a secret at rest.
Actors
| Actor | May | Must not | Source |
|---|---|---|---|
| Protected agent | Propose one bounded action and target with its own credential; read its own execution status. | Supply trusted facts, approvals, destinations or passthrough arguments; read provider credentials, configuration, signing material or control stores. | ARCHITECTURE.md trust boundaries; SURFACES.md |
| Gate caller | Propose one bounded action and target exactly as a protected agent does; receive a grant for an operation an owner-provisioned mapping marked externally executed; act at its own vendor with its own credential; report the outcome once, with the report secret that grant returned. | Select Gate, name an executor, set a precondition class or promote an evidence tier — all four are owner-provisioned bundle content or owner-registered configuration; settle any execution but the one its grant names; settle an operation ExecBound dispatched; release its own reservation or free its own target by assertion; reconcile, approve or dispatch through ExecBound. | CHECKPOINT.md; external checkpoint design |
| Control point | Call POST /pre and POST /post with an ExecBound agent credential held in its own configuration; carry a tool identity, the declared inputs, reported metadata and an end-user string; receive one of three enumerated codes. | Select which declaration applies, choose an action, a target or an incident, move a tool between governed and ungoverned, set a precondition class or promote a tier — all of that is the operator's map or the frozen plan; supply an identity or a budget scope; settle any execution but the one its own correlation row names; release a claim or refund a reservation by anything it says; correct a settlement it made. | ARCADE.md; arcade design |
| Human approver | Sign in for at most 15 minutes; approve or deny inside an owner-assigned target and action scope. | Reconcile; decide outside the assigned scope; dispatch anything by approving. | SURFACES.md; EXECUTION.md |
| ADMIN | View tenant activity; reconcile with a reason and terminal evidence; export audit history; view monitoring pages and acknowledge findings; activate bundles; issue and revoke agent and approver credentials and manage approver assignments through the administration functions. | Stand in for an assigned approver; settle an uncertain operation without proof; create or change administrators, tenants or the registry. | ARCHITECTURE.md; OPERATIONS.md |
| Owner tooling | Write tenancy, credentials, mappings, inventory, sources, aliases, canonical identity, approval assignments and resource_facts with the owner role; the execbound administration commands cover tenants, principals, credentials, approver assignments and principal groups, and the context-source-* and context-import commands cover context sources, inventory facts and incidents from adapter-produced manifests validated against the contract catalog; every change is audited under an ADMIN actor. | Be reachable from any HTTP route; run with the runtime role. | EXECUTION.md; CONTEXT_CONTRACTS.md |
| Protected mock provider | Accept a fixed configured account and frozen command; mutate atomically against an expected version; sign journals; fence late arrivals with a cancellation tombstone. | Accept agent credentials; expose provisioning, reset or passthrough routes. | PROVIDER.md; API_REFERENCE.md |
| PostgreSQL | Hold all gateway authority, execution state, impact, approvals and audit events under forced row-level security. | Be reached with the runtime role by anything but trusted gateway code. | ARCHITECTURE.md; DATA_MODEL.md |
| Monitoring source | Post allowlisted story metadata with a source token bound to one tenant, source and story. | Authorize execution, create approvals, settle effects or change limits. | TINES_MONITORING.md |
| Notification receiver | Receive authenticated at-least-once deliveries carrying only identifiers and a rule version. | Be chosen by a finding or an event; trigger any protected resend. | external monitoring design |
| CI | Run the full suite and both demonstrations on the exact source head and publish a manifest bound to that commit. | Replace security tests with documentation checks. | VALIDATION.md |
Trust boundaries
| ID | Boundary | What must hold |
|---|---|---|
| B1 | Protected agent to gateway (HTTP and MCP) | Identity comes only from an authenticated credential; every input is untrusted until validated; nothing an agent sends can grant authority. |
| B2 | Gateway to PostgreSQL | Trusted code sets transaction-local tenant context; runtime privileges are restricted; every consequential write commits atomically under the tenant fence. |
| B3 | Gateway to protected provider | Only a configured account and frozen command can dispatch; only authenticated, command-bound evidence can settle. |
| B4 | Human browser to human surface | A current session, exact origin, CSRF token, signed state, assigned scope and current role are all required for a decision. |
| B5 | Owner tooling and ADMIN to authority data | Registry, policy and trusted facts change only through owner or audited ADMIN paths. |
| B6 | Monitoring sources and receivers | Source-reported observations stay outside execution authority; destinations are operator-configured. |
| B7 | Live system to replay and export | Export needs current ADMIN authority; replay has no live capability. |
| B8 | Build, dependencies and CI | Locked, hash-pinned environments; evidence bound to the tested commit. |
| B9 | Gate caller to gateway, and its vendor beyond it | Placement, the executor's identity, the precondition class and the evidence tier are all owner-provisioned or owner-registered; a grant reserves before it is issued and settles exactly one execution once; nothing an executor says releases a canonical claim or refunds a reservation. What happens at the vendor is outside ExecBound entirely, and no row here claims otherwise. |
| B10 | Control point to hook surface, and the correlation table behind it | The tool map is deployment configuration and no hook field selects it; the extension translates and calls the same kernel methods an agent reaches, so nothing it does can permit what the kernel did not; a pre hook that answers OK on a governed decision has reserved and claimed first; a post hook settles at most the one execution its own correlation row names, at tier ASSERTED and never higher, and neither of its two statements refunds anything. The correlation row holds a usable report secret while its grant is live, which is a new trust boundary rather than an inherited one: a reader of that table can close a row and let the scheduled run free its target, and can never reduce what the tenant is charged. |
| B11 | Console to model provider | The assistant reads only through the asking person's session, writes nothing, sends nothing secret, and every answer is text whose links are only to ids a tool returned; the feature is absent without its block and off per tenant until an ADMIN turns it on. |
Threats and mitigations
B1: Protected agent to gateway
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T1.1 | S | Forged or guessed agent credential | Opaque tokens; the SHA-256 digest is compared in constant time even when no row exists; expiry and revocation are checked in the lookup (identity.py). | K; tests/test_runtime.py::test_http_foreign_status_and_bad_identity_are_opaque | Verified |
| T1.2 | S | MCP client metadata or a cached session used as identity | Stateless transport; every tools/call authenticates; metadata never establishes identity (surfaces/mcp.py). | M; tests/test_mcp_surface.py, tests/test_identity_surfaces.py | Verified |
| T1.3 | T | Caller claims replace trusted facts such as tier, incident or environment | Claims are selectors only; facts come from the owner-provisioned registry and contract sources (resolution/). | E, T, CC6; tests/test_policy_evaluation.py::test_caller_claim_changes_cannot_change_cedar_entities | Verified |
| T1.4 | T | Unknown operation, passthrough arguments, multiple or embedded targets | Strict wire models with forbidden extras and bounded patterns (requests.py); unknown mappings rejected before admission. | F, Q; tests/test_requests.py, tests/test_resolution.py::test_unknown_operation_fails_without_foreign_disclosure | Verified |
| T1.5 | E | Agent supplies an approval flag or approval identifier | No request schema accepts approval evidence; approvals are server-side rows consumed inside the execution claim (policy/approval.py, execution/). | C, G, R | Verified |
| T1.6 | E | Agent calls human, administrative or reconciliation routes | Rejected before any provider network activity and audited as a rejection. The served routes are asserted against a declared authority map, so a route added later fails the suite until it is classified, and every route needing a session is exercised with an agent credential rather than a sample of five. Re-examined for the checkpoint unit, which added one agent route: POST /v1/executions/{id}/outcome takes the same AGENT authentication and is the first agent-authenticated route that settles an execution — before it none did, and a dispatch ticket is a secret internal capability an agent never holds (ARCHITECTURE.md), so nothing about this is a capability an agent already had. What bounds it is the list that follows and not an equivalence. It refuses an execution whose frozen mapping does not declare an external executor before the report secret is compared, so a dispatched operation is unreportable whether or not the secret matches; it refuses another principal's execution as NOT_FOUND; and the connector-free administrator exit is reachable only from an ADMIN session, never from this route. The act grant is not an authenticator here and cannot be added to a report, because the body forbids extra fields. | K, GC9, GC15; tests/test_route_authority.py, tests/test_human_surface.py::test_agent_bearer_cannot_use_human_routes, tests/test_protected_path.py::test_agent_reconciliation_rejected_before_provider_network, tests/test_checkpoint.py::test_gc9_every_report_the_route_refuses_leaves_the_row_and_the_secret_where_they_were | Verified |
| T1.7 | T | Idempotency key replayed with a changed payload, or raced across adapters | Same-key conflicts; one pending approval per racing first intent; precommit failures roll back every claim. | H, V; tests/test_execution.py::test_same_key_changed_intent_conflicts_under_race, ::test_precommit_failures_roll_back_every_claim | Verified |
| T1.8 | I | Reading another tenant's or another agent's execution | Status requires the owning agent; foreign identifiers and bad identities return opaque responses. | S; opaque status test above | Verified |
| T1.9 | I | Engine, provider or database diagnostics leak through errors | Unexpected exceptions map to a generic 503 (app.py); Cedar and provider diagnostics are never echoed. | N; tests/test_cedar.py | Verified |
| T1.10 | D | Request flood exhausts admission | A process-wide in-flight limiter (32) returns 429; fixed-window limits per authenticated credential (shared by HTTP and MCP) and per direct peer address refuse with RATE_LIMITED before parsing or admission (surfaces/limits.py). | tests/test_rate_limits.py::test_agent_requests_beyond_the_credential_limit_are_refused_then_reset, ::test_refused_execute_requests_consume_nothing | Verified |
| T1.11 | R | Failed authentication leaves no durable record | Every surface records a bounded per-minute counter in authentication_failures by surface and reason, attributed only to a credential or monitoring source that authentication verified exists; responses are unchanged, recording is best effort and audited history is untouched (identity.py). | tests/test_authentication_failures.py (AF1 to AF14) | Verified, see R8 |
| T1.12 | S | Guessed, replayed or brute-forced sandbox invitation code on the public /onboard page | A code is ebi1.<tenant>.<invitation>.<43-character secret>; only its SHA-256 is stored and the database compares it under a row lock inside the onboarding_claim definer function, which also requires the slot to be unclaimed and the invitation unexpired and unredeemed, so a replay fails on redeemed_at. Five secret mismatches revoke the invitation server-side, the count committed before the refusal; the page adds a per-selector window shared with sign-in and the per-peer window, and refuses a forged CSRF field, a bearer header, a wrong origin, an extra field or a query string before the database. Every refusal is one sentence; the code and the selector reach only the structured log (onboarding.refused). A code sent by email (invite-create --email) is minted inside the send and exists only in memory and the one message, travels only over verified TLS, and its invitation is revoked unless the relay accepted the message, so a code from an uncertain attempt never works. | tests/test_onboarding.py (SO3, SO4, SO10, SO11, SO11b), tests/test_invitation_email.py | Verified |
| T1.13 | T | An agent retries a completed action under a new idempotency key and acts twice, consuming impact a second time | A caller that has no key management sends reference, the ticket, case or run id, and the gateway derives the key server-side with requests.derive_key byte for byte as the client's intent_key does, so one ticket lands on one intent whichever surface carries it and a retry through either progresses it; ActionRequest accepts exactly one of reference and idempotency_key, so a caller cannot send both and choose which applies. | tests/test_requests.py::test_reference_derives_the_key_exactly_as_the_client_does, tests/test_requests.py::test_reference_and_key_are_exactly_one, tests/test_mcp_surface.py::test_the_same_reference_over_http_and_mcp_is_one_intent | Verified |
| T1.14 | T | An agent names or infers a space, and acts on another customer's objects through a mapping that is not its own | No request field, header or credential attribute carries a space: the space is read from the agent's own principals row inside the fence (spaces.agent_space), and a mapping whose connector_accounts row belongs to another space and is not offered tenant-wide is refused INVALID_CONFIGURATION, audited, before any provider contact (execution/admission.py). The active bundle is the space's selection, so a space with none fails closed exactly as a tenant with none did. | SP1, SP5; tests/test_spaces_kernel.py | Verified |
| T1.15 | D | An agent of an archived space keeps working | "Takes no new work" has one meaning and is checked where the emergency stop is checked: admission refuses before any dispatch, audits the rejection, and answers 423 SPACE_ARCHIVED in the stop's own shape. An archived space stays fully readable and loses no history, and a DISPATCH_COMMITTED grant already issued is not recalled, exactly as the stop does not recall one. | SP9; tests/test_spaces_kernel.py | Verified |
B2: Gateway to PostgreSQL
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T2.1 | E | Tenant data reached across tenants | Transaction-local tenant context; all 40 tenant tables force row-level security; composite keys; the runtime role cannot bypass RLS and check_runtime refuses an unsafe role (db.py). | S, CC8; tests/test_execution_database.py::test_all_execution_tables_isolate_colliding_tenant_objects | Verified, see R1 |
| T2.2 | T | Runtime role rewrites authority data (mappings, credentials, inventory, facts) | Owner-owned tables and restricted grants; resource_facts is SELECT-only for the runtime. | CC8, CC9; tests/test_resource_facts_schema.py | Verified |
| T2.3 | T | Budget, approval or target-claim races | One global lock order, tenant control row then audit head (execution/store.py); concurrency proven on real PostgreSQL. | D, L, V; tests/test_execution.py::test_concurrent_principals_cannot_exceed_shared_first_capacity | Verified |
| T2.4 | T | Audit history truncated or altered | Per-tenant hash chain with a separately retained trusted head (audit.py); export verification detects tampering relative to that head. | tests/test_replay_verify.py, tests/test_audit.py | Verified, see R2 |
| T2.5 | D | Long transactions or lock waits starve admission | Statement timeout of 10 seconds and lock timeout of 5 seconds per transaction. Measured: about 40 denied admissions per second per tenant with latency growing linearly with concurrency; the runtime's concurrency cap now equals the pool capacity so callers queue on the runtime semaphore instead of timing out on the pool. | scripts/admission_benchmark.py; OPERATIONS.md | Accepted |
| T2.6 | I | Secrets at rest in the gateway database | Only token, session and CSRF hashes are stored; provider credentials never enter the gateway database. | SURFACES.md | Verified |
| T2.7 | T | A limit scope is satisfied or violated by mutable state: a membership, account or inventory change rewrites what retained impact means, or history recorded before the scope existed counts as room in its budget | Every scope counts a filter over retained execution_impact rows whose account_id, group_ids and facts were written once at reservation from the frozen plan and the memberships read inside the fence; counting never joins current membership or inventory, a row lacking an attribute never counts toward the scope that needs it, a group, account or selector budget is one counter for every agent, run and adapter under the same tenant fence, and membership changes take that fence (execution/store.py, administration.py). Replay reconstructs every scope from captures and reports unknown membership as UNKNOWN_GROUP_MEMBERSHIP, never as room. | SL1, SL2, SL3, SL5, SL6, SL8, SL9, SL12; tests/test_limit_scopes.py, tests/test_limit_scopes_schema.py::test_backfill_derives_account_and_facts_from_frozen_plans, tests/test_replay_scenarios.py::test_sl9_unknown_membership_of_a_retained_unit_is_a_lower_bound_not_room | Verified |
| T2.8 | E | The runtime writes a signup request, a slot's lifecycle or an invitation directly, or forges a delivery the outbox never queued | Every consequential write on signup_requests, onboarding_slots and onboarding_invitations happens inside a fenced SECURITY DEFINER function with no actor (onboarding_invite, onboarding_invitation_rekey, sandbox_expire, sandbox_notice, signup_request, signup_allocate, signup_mark), owned by the schema owner and executable by the runtime; the runtime holds SELECT and nothing else on the three, and on the outbox holds UPDATE on exactly seven delivery columns and no INSERT or DELETE, so a message names the request, class, slot and invitation the function wrote and nothing can re-point one. check_runtime pins the function set, the table set and the seven columns, and both new tables are forced row-level security under the tenant policy every table has (migrations/versions/0039_signups.py, db.py, signups.py). | tests/test_signups.py (SU1, SU9b, SU9c, SU9d, SU9e) | Verified |
| T2.9 | I | A store function reads without the space predicate and returns another space's rows | Forced row-level security does not catch this. A function that forgets its tenant predicate returns nothing because the database refuses it; one that forgets its space predicate returns another customer's rows, and there is no second line. One helper (spaces.space_clause) builds every space predicate from one computed visible set (spaces.visible_spaces), no store function writes its own, and a parametrized isolation test covers every space-carrying table, taking its parameter list from the migration's own column list so a table added later cannot skip a case. The test is the control, which is why it is named here and why R25 records the residual. | SP1, SP2; tests/test_spaces_isolation.py | Verified |
| T2.10 | E | Forced row-level security is asked to do a space's work, and a policy comes to depend on a session setting | Every tenant_isolation policy stays tenant-keyed in its once-per-statement subquery form, forced, on the three new tables as on every other, and a test asserts that no policy in the schema mentions a space setting at all. The space is a column and a WHERE clause and never a second fence (migrations/versions/0041_spaces.py). | SP10; tests/test_spaces_schema.py | Verified |
B3: Gateway to protected provider
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T3.1 | S | Agent-chosen destination, server-side request forgery or passthrough | Fixed configured origin per account; HTTPX transport with no retries, no redirects, no environment proxies and a bounded timeout (provider/connector.py). | F; tests/test_provider_connector.py, tests/test_repository_rules.py | Verified |
| T3.2 | S | Forged or replayed terminal evidence | Command-bound HMAC journals with key identifiers, verified in constant time; another operation's proof cannot settle this one. | I, P, W; tests/test_identity_reconciliation.py::test_later_signed_operation_cannot_settle_or_refund_original_effect | Verified, see R3 |
| T3.3 | T | Target changed between read and mutation | The provider checks the frozen expected version atomically at mutation. | O, U; tests/test_protected_path.py::test_provider_version_changes_after_gateway_commit_fail_atomically | Verified |
| T3.4 | T | Retry or refund after an uncertain outcome | A lost response retains INDETERMINATE with capacity and target guard held; no resend; a cancellation tombstone fences a late arrival. | I, P, W; tests/test_protected_path.py::test_missing_journal_does_not_refund_and_cancel_fences_delayed_dispatch | Verified |
| T3.5 | E | Agent reads provider dispatch or control credentials or evidence keys | Providers run as separate authenticated processes; configuration is private files; startup rejects credentials or keys shared across provider families. | K; tests/test_surface_configuration.py | Verified, see R4 |
| T3.6 | D | Slow or hung provider | One send attempt with a bounded timeout; uncertainty is retained rather than retried. | I | Verified |
| T3.7 | R | Evidence key loss or rotation | Keyed rings rotate from the private configuration without a restart, atomically for continuations, form state and every connector under the startup independence rules; a removed key refuses its proofs and leaves the operation uncertain until the key is restored, and rotation-readiness shows what still depends on a key (surfaces/configuration.py, rotation.py). | tests/test_key_rotation.py (KR1 to KR8) | Verified, see R9 |
| T3.8 | R | Unsettled operations hold claims and budgets until a person notices | execbound recover settles them on a schedule through the same fence, ADMIN recheck, audited request, journal lookup and trusted verifier as manual reconciliation; it never resends, never cancels an attempted operation, requests a tombstone only for never-attempted operations when explicitly enabled, and runs with a single-connection pool (recovery_run.py). | tests/test_recovery_run.py (RC1 to RC13) | Verified |
Transport decision (September 16, 2026, #183). A provider origin is HTTPS with a certificate the gateway verifies unless it is a loopback IP address, and there is no opt-in for cleartext on a private network. Two alternatives were considered for running the mock providers on a hosting platform whose private network is cleartext: a per-connector opt-in admitting private addresses, rejected because it adds a switch that weakens this boundary and would have to be proven unreachable from every agent input; and TLS served by the mocks themselves through a mounted CA, rejected as certificates for no benefit. The accepted posture is the one the Stage 1 pilot uses: the mocks are public HTTPS web services that authenticate every call with per-account bearer tokens and sign their evidence, which is the posture of the vendor APIs they stand in for, so the pilot exercises the connector rule rather than a relaxed one. Internet reachability adds no authority: a mock answers 401 without its token and mutates nothing without the dispatch token; a leaked token is rotated like any credential; a flood against a public mock is T3.6 and leaves operations uncertain rather than wrong.
B4: Human browser to human surface
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T4.1 | S | Session forgery or theft | Opaque hashed sessions of at most 15 minutes; HttpOnly and SameSite=strict cookies with the __Host- prefix when served over HTTPS; expiry rechecked inside decision transactions (surfaces/sessions.py). | X; tests/test_human_surface.py, tests/test_human_sessions.py | Verified |
| T4.2 | T | Cross-site or forged form submission | Exact Origin check, CSRF token and an HMAC-signed continuation binding credential, tenant, execution, plan, expiry and nonce (surfaces/tokens.py). | X; tests/test_human_surface.py::test_forged_human_forms_do_not_approve | Verified |
| T4.3 | E | Approval outside the assigned scope, by an ADMIN, or against a changed plan | Server-side scope and role checks before and after lock waits; the continuation binds the plan hash; a changed plan invalidates the approval. | G, K, U; tests/test_execution.py::test_pending_authority_changes_invalidate_approval | Verified |
| T4.4 | I | Agent or provider strings executed in the browser | The console renders every string as text through React and never through a DOM sink: dangerouslySetInnerHTML, innerHTML and its relatives, eval, new Function and string timers are lint errors with a fixture apiece, and its policy is default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' with require-trusted-types-for 'script' and no policy registered, so a raw string into a sink is refused by the browser itself. The pages that stay server-rendered keep Jinja autoescape with strict undefined variables and the pages' own policy, which allows only the packaged stylesheet, the packaged fonts and the showcase's one refresh module. Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY and HSTS when secure (app.py, surfaces/console.py); reviewer comments are bounded, printable and refused when credential-shaped. | X, CC12; tests/test_browser_surfaces.py, tests/test_browser_console.py, tests/test_approval_detail.py, tests/test_browser_approval_detail.py | Verified |
| T4.5 | D | Login brute force | Sign-in attempts are limited per credential selector before any lookup and per direct peer address; refusals carry Retry-After. The password form and a provider's callback share that limiter under a key that is the SHA-256 of the normalized address, so no address is held in the limiter's memory in the clear and neither way in walks around the other's bound (surfaces/sign_in_pages.py). | tests/test_rate_limits.py::test_login_attempts_beyond_the_limit_are_refused_even_with_the_right_secret, tests/test_sign_in_surface.py::test_attempts_beyond_the_limit_are_refused_even_with_the_right_password | Verified |
| T4.6 | D | One unreadable row fails the whole tenant listing | A frozen plan naming a contract absent from the runtime catalog renders an escaped label instead of a 503. | #19, PR #69 | Partial, in review |
| T4.7 | E | A single approver suffices for a high-risk action | Bundle quorum rules require two to five distinct human approvers for an obligation, frozen on the approval at creation; distinctness is the approval_decisions primary key, one denial ends the approval, partial approvals expire with the window, and consumption verifies the exact count and every approver's current credential and scope inside the fence (execution/store.py). A quorum of one is unchanged behavior. | #43; tests/test_approval_quorum.py (QA1 to QA10, QA12) | Verified |
| T4.9 | I | A notification leaks a secret or an untrusted string, or its link decides an approval | Payloads carry identifiers, catalog labels, timestamps and the fixed approval page link only; an email's subject and body carry labels reduced to letters, digits, spaces and ._:/()-, so no header, markup or link syntax survives; destinations, recipient addresses and the relay come solely from the operator's private file, webhooks are HMAC-signed and email travels only over verified TLS; Slack and Teams URLs are secrets pinned to the vendors' hosts, never logged or rendered, and their blocks and cards are plain_text with the same reduced labels; no message carries an approve or deny control, the decision still happens on the authenticated page with the scope check, and delivery failure changes nothing (notifications.py, transports.py). | tests/test_approval_notifications.py (AN1 to AN12), tests/test_notification_email.py, tests/test_notification_chat.py, tests/test_notification_transports.py | Verified |
| T4.10 | I | A newly issued secret is stored, logged or shown again | The token exists in memory for one response and is rendered once under Cache-Control: no-store; the listing, the audit chain and the logs carry identifiers only (surfaces/admin_pages.py, surfaces/console_api/credentials.py). | CA1, CA10; tests/test_admin_pages.py, tests/test_console_api_access.py, tests/test_browser_console_directory.py | Verified |
| T4.11 | E | A person ends sessions that are not theirs, or a stolen session ends the owner's other sessions | The Settings page lists and Sign out other devices revokes only rows whose principal_id is the acting principal's, inside the tenant fence, with the current session rechecked in the same transaction and never revoked; an ADMIN reaches another person's sessions only through the People page (#241). Ending them asks for no password: the session is at most eight hours old and the form carries the CSRF token and a confirmation, which is the accepted re-authentication here, recorded as such. The device label is one of a fixed set of strings chosen from the User-Agent at sign-in; the header is never stored or rendered (surfaces/sessions.py, surfaces/settings_pages.py, surfaces/console_api/settings.py). | tests/test_settings_pages.py, tests/test_console_api_settings.py, tests/test_browser_console_tenant.py | Verified |
| T4.12 | I | The live board discloses more of a tenant than the reader's authority allows, or a request string reaches the page as markup | The board reads the rows Home reads, under the same approver-scope predicate written out in each statement, in one REPEATABLE READ snapshot on the session's own connection, with the identity and session rechecked before and after the read. It writes nothing and dispatches nothing. Agent and target lanes are named by a principal label or an eight-character prefix, never by a full identifier; lane ids are server-assigned integers, so no agent, provider or request string becomes a class, an id, a data- name or a URL. The monitoring band is drawn for an administrator only, because an observation carries no canonical target and therefore cannot be held to an approver's scope. live is the only query key the route accepts and any other is refused by the shared boundary guard, so nothing in a query string selects a tenant. The console's board streams that same value and draws it with token colours alone; the server-rendered copy that /showcase renders publicly has no inline style, no SMIL and no connect-src, and its one module reloads the page and fetches nothing. | #280; tests/test_board_read.py, tests/test_showcase.py, tests/test_console_api_board.py, tests/test_browser_board.py, tests/test_browser_console_board.py | Verified |
| T4.13 | T | An ADMIN's rule text, or a note on a rule, reaches the compiled policy or is rendered as markup | Conditions are a closed grammar over declared facts and arguments, typed by the catalog and refused with a location; the draft is re-validated by the Ruleset model on every round trip and becomes a bundle only through ruleset_bundle() and the store, whose recompile refuses a mismatch; notes are inert and reach the console as text (surfaces/console_api/policy.py, policy/rulesets.py). | RS7, RS8, RS9; tests/test_console_api_rules.py::test_the_form_refuses_a_condition_the_compiler_would_not_accept, tests/test_console_api_rules.py::test_a_change_writes_a_successor_draft_retires_the_old_one_and_audits_both_ends, tests/test_browser_console_policy.py::test_a_bundle_reads_its_rules_as_sentences_with_a_note_as_text, tests/test_rulesets.py::test_a_note_is_inert_and_reaches_no_compiled_component | Verified |
| T4.14 | T | A page's reading of the rules is taken for the kernel's decision, and the two differ: a fact the page lacks, a limit, a quorum or a person's answer | Every reading is labelled "as the rules read; the decision is the kernel's"; project() follows the compiled bundle's own precedence and is held equal to the kernel's policy decision over the whole input space of every pack and to the seeder's stated effect on every fixture host; a fact the page was not given leaves its rule unread and is named rather than guessed; limits, quorum and approvals stay in their own sections and are never folded into the reading; a hand-written bundle shows no reading at all (policy/rulesets.py, surfaces/onboarding_pages.py, surfaces/agents_pages.py). | RS10, RS11, RS12; tests/test_pack_rulesets.py::test_the_rules_read_over_every_input_as_the_kernel_decides_it, tests/test_rulesets.py::test_the_rules_read_over_every_fixture_host_as_the_seeder_states, tests/test_browser_console_policy.py::test_a_bundle_reads_its_rules_as_sentences_with_a_note_as_text | Verified |
| T4.15 | E | The console's document is served at an address the application does not have, or a page that is not the console takes the console's policy | The console does not answer from a catch-all: CONSOLE_ROUTES in surfaces/console.py names every address the application has, tests/test_console_serving.py holds that list equal to the application's own generated route tree, and an address outside it answers 404 rather than a shell that would render "not found" with a 200. The policy is chosen by matching that same list rather than by a path prefix, because the console and the pages that stay now share the address space: /approvals/{id} takes the console's policy with Trusted Types and /approvals/{id}/classic keeps the pages'. The document needs a session, is no-store, and refuses a bearer credential with 403 audited as execution.rejected; the build's own files answer under /console/ and are as public as a stylesheet. | #333; tests/test_console_serving.py, tests/test_route_authority.py | Verified |
| T4.16 | S | A redirect kept for an old bookmark sends a reader somewhere a request chose | Each moved address redirects to a constant path written in LEGACY_REDIRECTS; only the path parameters the old address carried are substituted, percent-encoded, and nothing in the request selects a host, a scheme or a target. The search the old address carried is kept as a query string and never read. A redirect reads no session and touches no row, so it discloses nothing and answers the same signed out as signed in. | #333; tests/test_console_serving.py::test_the_paths_that_moved_redirect_so_a_bookmark_still_arrives, tests/test_route_authority.py | Verified |
| T4.17 | T | The one console control that posts a form is forged, or its token is left where a script can read it | Signing out ends the gateway's own session, which is a navigation rather than a change to read back, so it is the same POST /session/logout the pages have always used: the session's token in the body, the exact Origin check, and form-action 'self' in both policies. The token is the one the session route returned, held in the client's memory alone — never in browser storage, the query cache or the URL — and the button does not exist until it has arrived. | #333; tests/test_human_surface.py::test_logout_invalidates_retained_cookie, tests/test_browser_surfaces.py::test_browser_logout_rejects_retained_session_cookies | Verified |
| T4.18 | D | A stranger mines the public signup page: enumerating which addresses already hold a workspace, draining capacity, or flooding the queue from behind the proxy every visitor shares | Every submission answers one page with one status, for an admission, a place on the waitlist and each of the eight refusals alike, so nothing on the page distinguishes them; refusals reach the structured log and the operator's own list, never the browser. The controls are counts taken inside signup_request against the snapshot the row is written in, not anything the browser is asked to prove: one request per address per thirty days, at most five live workspaces per domain with free-mail domains exempted and held to their own lower daily cap, twenty an hour and a hundred a day overall, a vendored disposable-domain snapshot that is never fetched at runtime, and a clipped honeypot field recorded as a refusal. Since #243 (23 September 2026), open mode's hard ceiling is signups.capacity (the count of PROVISIONED requests in the operator tenant), not a seeded slot pool: a submission is admitted against it here and the count is retaken, authoritatively, under the operator's fence when the link is completed (T4.34 below); the pool remains the ceiling only for founder-minted invitations and for invitation messages a request already queued before that release. The page itself is CSRF-cookied like /login, refuses a query string, a bearer header, a wrong Origin or an extra field before the database, and keeps the per-peer window, which behind a proxy is a flood guard on the process rather than a limit on any one caller (surfaces/onboarding_pages.py, signups.py). No CAPTCHA and no proof-of-work: both need scripts these pages forbid. | tests/test_signups.py (SU5, SU5b, SU5c, SU5d, SU14c), tests/test_browser_accessibility.py | Verified |
| T4.19 | I | A prospect's email address escapes the operator's own list into an audit event, a log line, a page or a report | The address is written by a fenced function into signup_requests in the operator tenant and read back only by signups --list over the owner connection. Structured logs carry the request id, the domain and whether it is free mail, never the address; tenant.claimed, tenant.expired, tenant.extended and tenant.converted carry no address; the signup and confirmation pages render no submitted value at all; the funnel report is counts, domains and durations. Retention nulls the address thirty days after a refusal or an expiry and ninety after a claim, leaving the domain and the timestamps, and the message a prospect receives is addressed from the row rather than from anything the outbox holds (signups.py, logging_schema.py). | tests/test_signups.py (SU8, SU10, SU13) | Verified |
| T4.20 | S, D | Password guessing, credential stuffing or account enumeration at /login | A password is never stored: only a salted hashlib.scrypt hash at n=2^15, r=8, p=1, with the algorithm and all four parameters in their own columns beside it, so a row written under older parameters keeps verifying and raising them is a write. Attempts are refused per peer address and per address digest before any lookup. A wrong address and a wrong password answer 401 with one sentence, and cost the same: when the address belongs to nobody the hash runs anyway against a decoy record built at import under the current parameters. The private reason (UNKNOWN, MISMATCH, INACTIVE) reaches record_failure and the structured log alone, attributed to the person where there is one. Verification runs on a worker thread under a concurrency bound, so a flood queues rather than taking the process (passwords.py, surfaces/sign_in_pages.py). | Console sign-in; tests/test_passwords.py, tests/test_sign_in_store.py, tests/test_sign_in_surface.py, tests/test_browser_sign_in.py::test_a_wrong_password_and_an_unknown_address_say_the_same_thing | Verified |
| T4.21 | S | A forged, replayed or misdirected provider assertion signs the wrong person in | Each provider's endpoints are pinned in its own module and only the client id, the client secret and the redirect URI are configured; the redirect URI must equal this deployment's own origin plus that provider's callback path or the server refuses to start. Every flow carries PKCE S256, a random state and a random nonce in one __Host-, HttpOnly, SameSite=Lax cookie deleted on first use, so a replayed callback has nothing to match and a state mismatch is refused. Google's ID token is verified against Google's JWKS with alg settled before any key is fetched, one refetch on an unknown kid and then a refusal, and iss, aud, azp, exp, iat and nonce all checked; email_verified must be true. GitHub has no assertion to verify, so the account comes from two authenticated calls and the only address that may find a person is the one GitHub marks both primary and verified; the access token is user:email only and is discarded after the lookup. An account matching no person is refused and creates nothing, the subject is bound once and identifies the person afterwards, and a subject already bound to somebody else is refused rather than moved (oidc.py, github.py, signin.py, migration 0040_sign_in). | #177; tests/test_oidc.py, tests/test_github_sign_in.py, tests/test_sign_in_surface.py, tests/test_sign_in_store.py::test_a_subject_bound_to_one_person_is_never_moved_to_another | Verified |
| T4.22 | E, I | A set-password code reaches the wrong person, survives its use, or leaves the previous holder signed in | A code is ebp1.<tenant>.<reset>.<secret>, stored only as its SHA-256, single use, expiring between an hour and fourteen days, revoked by five wrong secrets and by the minting of the next one, and never present in a URL: what is handed over is the address to open and the code to type. An expired, redeemed, revoked, superseded and unknown code all fail identically. Redeeming revokes every live credential and unrevoked session of that person in the same statement sequence and returns their identifiers so the caller audits each one in the same transaction. The ebp1. prefix is part of the shared secret definition, so a rendered page or an export carrying a code fails the guard (migrations/versions/0040_sign_in.py, passwords.py, secrets.py). | #265; tests/test_sign_in_store.py, tests/test_secrets.py, tests/test_browser_sign_in.py::test_a_code_works_once_and_the_second_person_to_try_it_is_refused | Verified |
| T4.23 | I | Resolving an address before a tenant is known reads more than that one address | The login form takes an address and no tenant, so the read happens outside the fence — through two single-row RLS policies rather than a function with table-wide authority. A SELECT on principals is permitted only for the row whose own email equals execbound.sign_in_email, and on principal_identities only for the row whose own provider and subject equal execbound.sign_in_subject. Setting a marker therefore answers about the address or the account the caller already typed and about nothing else: no listing, no enumeration, no other tenant's row. Everything after the resolution is an ordinary fenced read (migrations/versions/0040_sign_in.py, passwords.py). | tests/test_sign_in_store.py::test_the_marker_reveals_one_row_and_never_a_listing, tests/test_sign_in_store.py::test_the_subject_marker_reveals_one_row_and_never_a_listing | Verified |
| T4.24 | T | A form submission that starts a provider flow is forged, or the redirect it follows is blocked or pointed elsewhere | Starting a flow is a POST with the login CSRF cookie compared with the field and the exact Origin check, like every other mutation, rather than a bare link. form-action governs the redirect a form submission follows, so the pages' policy names each configured provider's authorization host — pinned in that provider's module, present only because a block named the provider, and absent entirely from a deployment with none. Nothing a request sends adds a host (surfaces/console.py, app.py). | tests/test_sign_in_surface.py::test_a_configured_provider_is_named_in_form_action_and_nothing_else_is, tests/test_browser_sign_in.py::test_a_configured_provider_signs_a_matched_person_in | Verified |
| T4.25 | E | A person sets the session's current space to one they do not belong to, or a stale column grants a read after they were removed | session_set_space is a SECURITY DEFINER function rather than a runtime UPDATE grant, and it verifies the space against space_members (or tenant administration) in the database, so "a session's current space is one of the person's spaces" is a database invariant. It is belt and braces: every read recomputes the visible set inside the fence and narrows the current space to it, so a person removed between two page loads falls back to "All spaces" at once and a forged or stale column grants nothing (migrations/versions/0041_spaces.py, spaces.py). | SP4; tests/test_spaces.py, tests/test_console_api_spaces.py | Verified |
| T4.26 | I | A record in another space is opened by its identifier | Membership is the authority and the current space is only a view: a record in a space the person does not belong to is 404 whatever the session holds, and one in another of their own spaces is answered and names its space so the shell can follow it. There is no ?space= anywhere, so editing a URL cannot ask for another space's rows and a shared link is read in the reader's own space (spaces.Visibility.allows, surfaces/agents_pages.py, surfaces/space_pages.py). | SP1, SP11; tests/test_console_api_spaces.py | Verified |
| T4.27 | T | A decision lands while its space is being archived | The archive takes the tenant fence before it calls the definer function, and decide_approval rechecks the space inside the kernel's own transaction under that same fence, so the two are totally ordered: whichever takes the fence first commits and the other is refused SPACE_ARCHIVED. Exactly one of the two commits, under two connections on real PostgreSQL (surfaces/space_pages.py, execution/approvals.py). | SP9; tests/test_spaces_kernel.py | Verified |
| T4.28 | S, T | A tenant's registered directory returns a forged, replayed or misdirected assertion, or a browser is walked into somebody else's flow | One redirect URI serves every tenant and is checked against this deployment's own origin, so an authorization code can only ever be delivered here. Discovery is fetched once at registration and validated hard — the document's own issuer must equal the registered one, and the authorization, token and JWKS endpoints must be https on the issuer's own host — and the three endpoints are then stored on the row, so a document that changes later cannot move a live flow. Every flow carries PKCE S256, a random state and a random nonce in one __Host-, HttpOnly, SameSite=Lax cookie deleted on first use; the cookie also names the provider and either an invitation or a self-linking principal, never both, and carries a MAC over all six values. The MAC is what a session cookie cannot be here: the callback is a cross-site redirect back, so a SameSite=Strict session is not sent with it, and a cookie a caller could write would let them name somebody else's principal and bind their own directory account to it. Every key in the ring is tried, so a rotation strands no flow already in the air. The ID token is verified here with alg settled before any key is fetched and refused unless RS256 or PS256, one refetch on an unknown kid and then a refusal, and iss, aud, azp, exp, iat, nonce and tid all checked; an Entra issuer must name one directory, so common is refused at registration and an assertion from another Microsoft tenant is refused at sign-in. RFC 9207's iss is compared when the directory sends it. A subject with no link is refused and creates nothing (directory.py, surfaces/directory_pages.py, migration 0043_directory_sign_in). | #177; tests/test_directory.py::test_every_claim_this_server_checks_has_its_own_refusal, tests/test_directory.py::test_an_unknown_key_is_refetched_once_and_then_refused, tests/test_directory_surface.py::test_a_replayed_callback_has_nothing_to_match, tests/test_directory_surface.py::test_a_subject_nobody_linked_is_refused_and_creates_nothing | Verified |
| T4.29 | E, I | An invitation code reaches the wrong person, survives its use, or binds one account to two people | A code is ebv1.<tenant>.<invitation>.<secret>, stored only as its SHA-256, single use, expiring between an hour and fourteen days, revoked by five wrong secrets and by the minting of the next one, and never present in a URL, a log or an audit payload: what is handed over is /invite to open and the code to type. An expired, redeemed, revoked, superseded and unknown code all fail with one sentence. The sign-in that follows binds the subject to the invited person and stamps the invitation redeemed in one statement sequence under a row lock, so two browsers holding one code produce exactly one link and one session. The ebv1. prefix is part of the shared secret definition, so a rendered page or an export carrying a code fails the guard (migrations/versions/0043_directory_sign_in.py, directory_store.py, secrets.py). | #241, #265; tests/test_directory_store.py::test_only_one_of_two_concurrent_redemptions_wins, tests/test_directory_store.py::test_five_wrong_secrets_revoke_an_invitation, tests/test_directory_store.py::test_a_revoked_expired_and_unknown_code_all_fail_the_same_way | Verified |
| T4.30 | I | The sign-in page tells a stranger which addresses exist and which directory they belong to | /login takes an address and no tenant and asks the database nothing about one, so a directory button cannot be chosen by anything a stranger types. It is offered instead to a browser carrying __Host-eb_directory, a hint cookie set only after a sign-in through that provider succeeded, holding a provider id and no secret; a browser that has never been through one is shown the password form and nothing else, and a cookie naming a provider that is not enabled is ignored. The tenant's sign-in address is distributable on purpose: it sends a browser to the customer's own login page, and a subject with no link is refused on return (surfaces/human.py, surfaces/directory_pages.py). | tests/test_directory_surface.py::test_a_browser_that_has_never_been_through_a_directory_is_told_nothing, tests/test_directory_surface.py::test_the_remembered_provider_puts_one_button_on_the_sign_in_page | Verified |
| T4.31 | S | A Community signup verification link is leaked, forwarded, sits in a shared mailbox, or is opened by a mail scanner or link-prefetcher before the person does | The secret lives only in the URL fragment, which GET /signup/verify never reads server-side and signup-verify.js strips from the address bar and history before anything else can act on it; the page itself is static and side-effect free, so any number of opens by a scanner or a prefetcher changes nothing. The script sends the exchange by itself when the fragment is a valid token, so a scanner that runs scripts also posts it; the exchange only reads what the link is for, a correct token is not a miss, and nothing exists until the create form is sent with a password and a workspace name. The exchange is POST-only, and the token it posts is checked against verify_token_hash inside signup_verify_check/community_provision, which count a wrong digest toward a five-miss lockout (verify_failures) rather than answering differently for a guess and a hit. The link expires thirty minutes after it is minted (verify_expires_at, signup_verify_rekey) and a resend rotates verify_generation and the digest, so a previously leaked link dies the moment a fresh one is sent. Every signup page is Cache-Control: no-store, and the site's default Referrer-Policy: same-origin means a same-origin navigation carries no cross-origin Referer and the secret is never in a query string to be logged or cached along the way (community_signup.py, signup_pages.py, migrations/versions/0055_community_signup.py, surfaces/assets/signup-verify.js). Closing signups does not cancel a link already queued for delivery: drain still sends it, and its completion now refuses under closed mode (SIGNUPS_CLOSED, checked in provision before any write or password hash) rather than provisioning, leaving the request VERIFYING with its link untouched until the operator reopens signups or the link's own thirty minutes pass. | VS3, VS9, VS12, VS18, VS20, VS20b, VS32; tests/test_community_signup.py, tests/test_browser_signup.py | Verified |
| T4.32 | T | Two browsers, or one browser retried after a lost response, complete one verification link and end up with two workspaces or two accounts | community_provision takes a row lock (FOR UPDATE) on the signup_requests row before it reads or writes anything else, inside the operator tenant's own fence, so two concurrent completions of the same link serialize on that lock; the second to run reads state = 'PROVISIONED' and returns ALREADY with the first's tenant id rather than provisioning again. Two different requests sharing one account are still one account: user_accounts.email is unique, and a new account's insert or its email-holder check inside the same transaction turns a unique violation into ACCOUNT_EXISTS rather than a second row. The consumed Community allocation is a unique index on community_allocations keyed to the account, so a second self-created workspace for the same account fails closed the same way. Nothing here mints a session for a retried completion: an ALREADY outcome never carries the new account's version at all, and sign_in_new_account refuses to mint a credential without one, so only the one call that actually created the account signs anybody in: a losing racer or a later replay of the same form gets the "Your workspace is ready. Sign in." page instead. | VS8, VS10; tests/test_community_signup.py | Verified |
| T4.33 | S | An email link is used to attach a Community workspace to, or take over, an account other than the one the address names | A link never creates a password for an address an account or an active legacy principal already holds (ACCOUNT_EXISTS), and never accepts one from a signed-out browser for that address either: inspect's tri-state answer sends existing and legacy addresses to ordinary sign-in first, including whatever MFA that account requires, before /signup/workspace is reachable at all. That route then re-authenticates the session and account cookie inside the provisioning transaction (_prove_account) and community_provision checks the signed-in account's own email against the request's (v_account.email <> v_request.email is ACCOUNT_MISMATCH), so a session for a different address than the one the link names is refused rather than silently attached. An active legacy holder who appears between inspection and provisioning, or commits mid-transaction on the address's own unique index, is caught by the same predicate applied a second time inside community_provision; only an inactive legacy holder, who cannot sign in to contest it, lets the link make a new account. A session that still needs MFA is refused the same way: the exchange treats it as signed out and sends it to sign in first, /signup/workspace behind it never reaches provision at all, and _prove_account calls check_session without allow_pending_mfa, so calling provision directly with a pending session raises ACCOUNT_MISMATCH rather than completing. The account and session proof takes no fence of its own on the session's own tenant, so a session revocation that commits between that proof and the account's own lock does not stop this provisioning; account suspension is still caught, because community_provision locks the account row FOR UPDATE and refuses it unless it is still active (NOT v_account.active); the account must also match the request's email. | VS11, VS12g; tests/test_community_signup.py | Verified |
| T4.34 | D | A stranger exhausts admission capacity by submitting many addresses, or floods the verification/resend path once past the per-submission caps | The per-address, per-domain and global caps (T4.18 above) now count VERIFYING and PROVISIONED requests, so a workspace that exists counts against its domain's cap however it was created. Capacity itself is counted twice: advisory when a request is submitted, and authoritative (a fresh count(*) FROM signup_requests WHERE state = 'PROVISIONED' under the operator tenant's own fence) the instant a link is completed, so two links racing the last unit of capacity cannot both provision (T4.32's lock already serializes it) and a submission that looked admitted can still be waitlisted at completion with no partial workspace left behind. Resend is bounded independently of submission: signup_verify_queue refuses a resend at five sends or with a message already in flight or delivered in the last two minutes (RESEND_LIMIT), and a request bounced back to QUEUED by a capacity miss starts a fresh five-send cycle rather than inheriting an exhausted one, so a flood cannot be laundered through repeated waitlist/re-admission cycles either. | VS4, VS4b, VS5, VS10; tests/test_community_signup.py | Verified |
| T4.35 | S | A teammate invitation link at /join is leaked, forwarded, sits in a shared mailbox, or is opened by a mail scanner or link-prefetcher before the person does | The secret lives only in the URL fragment; GET /join is static and side-effect free, so any number of opens changes nothing, and invite.js moves the fragment into the form and out of the address bar and history before anything else can act on it, the same recipe T4.31 gives /signup/verify. The exchange is POST-only and only reads what the link is for through inspect_invitation/workspace_invitation_check; a wrong digest counts toward a five-miss lockout (failures) rather than answering a guess and a hit differently, and the count commits even though the calling transaction that found the mismatch does not. The link expires seven days after it is minted regardless of resends, and every resend mints a fresh digest and link (workspace_invitation_rekey, generation incremented), so a previously leaked link dies the moment a fresh one is sent; a revoked invitation's link answers the same "not valid" page without ever saying which. Every invite page is Cache-Control: no-store, and the site's default Referrer-Policy: same-origin keeps the secret out of any Referer, exactly as T4.31 records for signup (invitations.py, surfaces/invite_pages.py, surfaces/assets/invite.js, migrations/versions/0059_workspace_invitations.py). | TI1c, TI3, TI11, TI16, TI20; tests/test_teammate_invitations.py, tests/test_browser_invitations.py | Verified |
| T4.36 | S | A teammate invitation link is used to take over an existing account, or to sign somebody in as a person other than the address it names | The new-address path never creates a password for an address an account or an active legacy principal already holds: workspace_invitation_accept raises ACCOUNT_EXISTS under the same sign_in_email marker community_signup uses, checked once by accept_invitation before hash_password ever runs and rechecked by SQL itself inside the accepting transaction, so a holder who appears in between is still caught. An existing or legacy address is never accepted from a signed-out browser either: inspect_invitation's tri-state answer sends existing and legacy addresses to ordinary sign-in first. /join/confirm then re-authenticates the account cookie and the session inside the accepting transaction (_prove_account, the same recipe community_signup._prove_account uses), and the definer function checks the signed-in account's own email against the invitation's (v_account.email <> v_invitation.email is ACCOUNT_MISMATCH), so a session for a different address than the one the link names is refused rather than silently attached. A session still waiting for its MFA is treated as signed out by the exchange and sent to /mfa before /join/confirm is ever reached, so _prove_account's own check_session call, made with no allow_pending_mfa, is a second, independent refusal behind it (invitations.py, surfaces/invite_pages.py, migrations/versions/0059_workspace_invitations.py). | TI9, TI10, TI18; tests/test_teammate_invitations.py, tests/test_browser_invitations.py | Verified |
| T4.37 | E | Acceptance proceeds after the inviter's authority to grant those roles has been narrowed or removed, or after the invitation itself was revoked | The inviter's authority is read at acceptance, not cached from when they clicked Invite: workspace_invitation_accept re-checks that the inviter is still an active principal holding access.manage in its own step 5, immediately before it calls access_change as that inviter, so a demotion or a deactivation between the two refuses the acceptance FORBIDDEN and creates nothing, on either the new-address or the existing-account path. access_change itself re-validates every Space named in the frozen grants at that same moment, so a Space archived after the invitation was sent is INVALID_SPACE rather than silently dropped or silently granted. Revocation takes the same invitation row lock a concurrent acceptance would take, so the row lock (not a flag checked once) decides which of the two commits, and revoking also fails the invitation's own pending message closed (REVOKED) so delivery cannot outrun the revoke (invitations.py, migrations/versions/0059_workspace_invitations.py, decision 4). | TI1d, TI1e, TI6, TI11, TI12, TI12b; tests/test_teammate_invitations.py | Verified |
| T4.38 | I | The tenant-agnostic drain that reaches every workspace, including one no jobs token names, is used to read or send from a tenant the caller does not administer | workspace_invitation_due is the one place a cross-tenant read exists, and it exists only because migration 0059 grants a SELECT policy, owner_drain, TO CURRENT_USER — the exact role that owns every definer function in this file, never execbound_app. deliver_invitations calls nothing else across tenants: having listed a due pair, it leases, rekeys and sends inside that one message's own tenant fence, in its own transaction, so a message never crosses back out. The runtime role's ordinary reads stay fenced as every other table's do; workspace_invitation_due's own row carries a tenant id and a message id only, never an address, so even the cross-tenant list itself discloses nothing about who was invited. This is a new pattern for this codebase — every earlier tenant-agnostic pass (signups.py's sweep, the approval-notification and export drains) instead holds its own operator credential and walks a configured tenant list — and it is proven under a NOBYPASSRLS owner carrying the hosted database's own posture, not assumed from the local development owner's superuser bypass (migrations/versions/0059_workspace_invitations.py, invitations.py, decision 1). | TI1, TI1h, TI4; tests/test_teammate_invitations.py | Verified |
| T4.39 | T | Two browsers accepting one link, one account accepting twice, an acceptance racing a revoke, or an acceptance racing a verified signup for the same new address, produce more than one committed outcome | workspace_invitation_accept takes a row lock (FOR UPDATE) on the invitation before it reads state, and workspace_invitation_revoke takes the same lock, so the two are totally ordered: whichever commits first decides and the loser reads back its outcome (ALREADY, or INVITATION_REVOKED against an already-REVOKED row) rather than racing to double-write it. A signed-in account's own acceptance locks the account row (lock_account) before the tenant fence, so two clicks by the same account serialize on it and the second finds ALREADY. A new address racing a verified Community signup for that same address both insert into user_accounts, whose email column is the one true arbiter: its unique index turns the loser's insert, or the loser's own pre-insert holder check inside the same transaction, into ACCOUNT_EXISTS rather than a second row, and neither request consumes the other's own intent — the signup stays VERIFYING or the invitation stays PENDING, whichever lost (migrations/versions/0059_workspace_invitations.py, invitations.py, decision 8). | TI13a, TI13b, TI13c, TI13d; tests/test_teammate_invitations.py | Verified |
B5: Owner tooling, ADMIN and policy
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T5.1 | E | An agent or approver creates, edits or activates a bundle | ADMIN-only lifecycle inside the tenant fence, audited; content is immutable and digest-bound (policy/store.py). | tests/test_policy_store.py | Verified |
| T5.2 | T | Malformed policy, schema mismatch or engine error changes a decision | Structured parsing and validation fail closed; both policy sets are evaluated; any error denies. | N, CC1, CC7; tests/test_cedar.py, tests/test_context_bundle_validation.py | Verified |
| T5.3 | T | A writer allowlist admits a label the agent can write | Documented misconfiguration; scenario T protection depends on ADMIN naming only trusted writer labels. | CC6; POLICY.md | Accepted, see R5 |
| T5.4 | T | Wrong trusted provisioning (inventory, canonical mapping, facts, incidents) | Outside the agent input boundary. Facts and incidents enter only through the owner-connection importer, which validates every value against the contract catalog, accepts only a writer label the owner-provisioned source allows, refuses observations that go backwards and reused versions, never creates registry rows, and audits each import with the manifest digest (provisioning.py); canonical mapping stays owner SQL. | CP1, CP4 to CP9, CP11; tests/test_context_provisioning.py | Verified for facts and incidents; Accepted for canonical mapping, see R10 |
| T5.5 | E | A Cedar binding change silently alters decisions | Pinned cedarpy; the adapter rejects incomplete diagnostics; a guard test fails with a review message if the private entry points change. | #38, PR #68 | Partial, in review, see R6 |
| T5.6 | R | Administrative actions without attribution | Every lifecycle change, reconciliation and acknowledgement is audited with current authority rechecked. | tests/test_audit.py, replay export tests | Verified |
| T5.7 | E | Agents keep acting during an incident because nothing halts one tenant | ADMIN emergency stop on the tenant control row, read inside the fence by every writer: new admission, approval decisions and dispatch attempts refuse with TENANT_STOPPED while settlement continues; activation and release are audited with reasons and replay flags decisions inside a stop period (execution/stops.py). | tests/test_emergency_stop.py (ES1 to ES12) | Verified |
| T5.8 | E | An administrator weakens a tenant to observe-only, or an observed result is mistaken for authorization | The mode lives on the forced-RLS control row and changes only through a current ADMIN inside the fence with a required reason, audited as tenant.mode_changed; an observed request never receives a ticket, approval, reservation or claim, ends in the terminal OBSERVED state whose observed object is a record and not authority, never counts toward an enforced budget, and replay reports an observed execution with any effect as OBSERVED_WITH_EFFECT (execution/modes.py, execution/admission.py). Switching to OBSERVE is a trusted ADMIN capability accepted for onboarding. | #51; tests/test_observe_mode.py (OB1 to OB12) | Verified |
| T5.9 | T | A compromised or faulty adapter feeds false context | The adapter holds no database authority; the importer refuses values outside the catalog domains, sources of another tenant or kind, unknown writers, future and regressing observations and relinked incidents, and withdrawal of evidence (retraction, deactivation) can only deny. A wrong but well-formed value remains the operator's provisioning responsibility. | CP4, CP5, CP6, CP8, CP10 | Partial, see R10 |
| T5.10 | E | A web bug or a captured ADMIN session mints administrators or widens the registry | The gateway holds no table write privilege on principals, credentials or approval scopes; the five SECURITY DEFINER functions of migration 0021 read the tenant from the fence setting, verify the acting ADMIN inside the database, refuse ADMIN kinds, principals and credentials, bound lifetimes and clock skew, and cannot touch tenants, accounts, mappings or canonical targets; the pages recheck session and role before and after each call and audit every change with the acting credential; check_runtime asserts the exact function catalog, definer mode, ownership and privilege (db.py, surfaces/admin_pages.py). | CA1 to CA9; tests/test_admin_pages.py | Verified |
| T5.11 | E | An agent, approver or the runtime role widens a limit scope by creating or joining a group, or a bundle names a group, account or fact the tenant does not have | Groups and memberships are owner-provisioned authority data like approval scopes: forced RLS, runtime SELECT only, written only by group-create, group-assign and group-unassign over the owner connection under an ADMIN --actor, active AGENT principals only, at most 64 memberships each, audited as group.created, group.assigned and group.revoked; the store refuses a rule naming an unknown action, group, inactive account or unserved fact with its field location, at validation and at activation. Admission does not re-run those checks: it reads the active bundle through execution.store.active_bundle, which revalidates no component, so a group removed after activation is not refused there. What bounds that is the write path rather than a recheck — no command deletes a group, the runtime holds SELECT only on both tables, and the membership foreign key has no cascade, so removal takes the owner connection — and store.memberships reads the principal's groups inside the fence at every decision, so a rule's counting follows current provisioning rather than a stale bundle claim (administration.py, policy/store.py, execution/store.py, migration 0022_limit_scopes). See POLICY.md. | SL7, SL10, SL11; tests/test_admin_cli.py::test_group_lifecycle, tests/test_limit_scopes_schema.py::test_runtime_cannot_write_group_tables, tests/test_policy_store.py::test_scoped_rules_fail_validation_with_locations | Partial |
| T5.12 | E | A departed or compromised person keeps a credential or a session; a console user deprovisions an administrator or themselves | Deactivation is one definer function under the 0021 actor check that sets the principal inactive and revokes every live credential and unrevoked session in the same statement sequence, and the page appends the events in the same transaction, so there is no state in which the person is inactive but a credential or session still works; the function refuses an ADMIN target and the actor, so the console still cannot create, change or remove an administrator, and nothing is deleted (migrations/versions/0036_people.py, surfaces/people_pages.py). | tests/test_people_pages.py, tests/test_console_api_people.py, tests/test_browser_console_directory.py | Verified |
| T5.13 | S | A wrong or forged link attributes a principal's executions to an agent it is not, or the runtime role moves the link | A binding is written only by admin_bind_agent and admin_unbind_agent under the 0021 actor check, joining an active AGENT principal to an agent this tenant's connection still lists, one to one by partial unique indexes; the runtime role can write neither the table nor monitor_agents.linked_principal_id; the page and link-agent append agent.bound and agent.unbound in the same transaction; unbinding takes a reason and withdraws the ExecBound reach rows at once; nothing infers a binding from a name (migrations/versions/0037_agent_bindings.py, bindings.py). | tests/test_agents_bindings.py, tests/test_console_api_agents.py, tests/test_browser_console_directory.py | Verified |
| T5.14 | E | A sandbox outlives the fourteen days it was granted, someone who is not an administrator of it extends, revives or converts it, or an abusive sandbox cannot be ended before its own next boundary | The end is a property of the row, kept by a trigger at the claim plus fourteen days for every writer, and moved by extended_until, deactivated_at, converted_at and, since 0044_hosted_free, fell_back_at (T5.21 below). Day fourteen's default is now sandbox_fallback, which sets plan = 'free' and leaves the tenant active rather than ending it; sandbox_expire and sandbox_notice both refuse a slot that has already fallen back, so the two endings are mutually exclusive in both orders, and sandbox_notice refuses a second notice regardless. sandbox-extend runs over the owner connection, takes at most thirty days a call, revives a sandbox deactivated within the last ninety days, and, if the slot had fallen back, restores the full uncapped sandbox outright rather than merely moving its end date. sandbox-convert runs over the owner connection with no day bound, clears the expiry outright, and lands on team (uncapped, the default) or free (the fallback's own state) as named; it does not check how long a slot has been deactivated at any plan, which is the corrected reading of this row: that ninety-day reactivation window is sandbox-extend's own guard, over deactivated_at, and sandbox-convert's guard checks only claimed_at and converted_at, never deactivated_at, so converting a sandbox deactivated at any point in its past is reachable through this command and not through sandbox-extend. Both require an active ADMIN principal of that tenant as the actor and append tenant.extended, tenant.fell_back or tenant.converted to that tenant's own chain in the same transaction. sandbox-deactivate (T5.23 below) ends any claimed, unconverted sandbox at once regardless of fell_back_at and regardless of its end date, and refuses a converted tenant. Deactivation, by either path, is an inactive tenant, so sessions, credentials and agent requests fail at their next check as for any inactive tenant; a fallen-back tenant stays active and only its plan and cap change (migrations/versions/0039_signups.py, migrations/versions/0044_hosted_free.py, signups.py). | SU7, SU7b, SU7c, SU7d, HF10, HF17, HF18, HF20, HF21, HF24 to HF30, HF46; tests/test_signups.py, tests/test_hosted_free.py | Verified |
| T5.15 | E | A console administrator gives themselves or somebody else an administrator's way in | admin_set_email and admin_reset_password carry the 0021 actor check and refuse an ADMIN target, so an administrator's sign-in address and set-password code stay with owner tooling exactly as their credentials do; an address another person holds anywhere is refused by a global unique index; the runtime role holds no write on principals.email, principal_passwords, password_resets or principal_identities, and check_runtime asserts the whole function catalog and the absence of those grants. password_replace refuses a person who holds no password, so the change route can never become a way to set one, and it takes the principal from the session rather than from a body (migrations/versions/0040_sign_in.py, surfaces/people_pages.py, surfaces/console_api/settings.py). | tests/test_sign_in_store.py, tests/test_console_api_people.py::test_the_console_refuses_an_administrator_an_address_or_a_code, tests/test_console_api_settings.py::test_a_person_with_no_password_cannot_set_one_through_the_change_route | Verified |
| T5.16 | E | A space membership raises a role, or the tenant is left with nobody who can administer spaces | The space role narrows and only narrows: a person's authority is min(principals.kind, space role), space_assign refuses the ADMIN role for a HUMAN_APPROVER principal, and principals.kind stays a necessary condition wherever it is checked today, so no area module changes its authority. A tenant administrator is an active ADMIN principal with no membership, and the assignment that would leave a tenant with none is refused LAST_TENANT_ADMIN under a lock on the tenant's ADMIN rows, the way revoke_executor_key refuses a tenant's last live key (migrations/versions/0041_spaces.py). | SP3; tests/test_spaces.py, tests/test_admin_cli.py | Verified |
| T5.17 | R | A space and the history it owned are removed | There is no delete path: spaces archive, memberships are removed but the rows they governed keep their space, and the runtime holds no DELETE or TRUNCATE on either new table — SELECT and nothing else on spaces and space_members, and INSERT plus three update columns on space_policy_selection alone. An archived space keeps every row it owned in every view that could see it, and archiving the tenant's last unarchived space is refused (migrations/versions/0041_spaces.py, db.py). | SP9, SP10; tests/test_spaces_schema.py | Verified |
| T5.18 | E, I | A tenant's administrator registers an issuer they control, or a client secret and refresh token at rest are read | A provider an administrator registers can mint access for nobody on its own: a subject needs a link, a link needs that administrator's invitation, their direct link or the person's own self-link, and directory_link refuses an ADMIN target exactly as admin_set_email does, so administrator creation and cross-administrator links stay with the owner connection. The worst a hostile issuer buys is approver access for accounts in a directory that administrator already controls, which issuing approver credentials already buys them. At rest, the client secret and the refresh token are AES-256-GCM ciphertext under a key ring in the private configuration, with the tenant, the provider and the purpose as additional authenticated data, so a row moved to another tenant, another provider or the other column does not open; a key that left the ring is named rather than skipped. The key is never in the database, the runtime role holds SELECT and no write on all four tables, and neither value is logged, rendered, returned by any function or present in an audit payload. What this does not defend is a process that can already read the gateway's configuration, which is where the connector tokens and the assistant key already are (envelope.py, directory_store.py, migration 0043_directory_sign_in). | #177, #153; tests/test_envelope.py::test_a_ciphertext_moved_to_another_row_or_column_does_not_open, tests/test_directory_store.py::test_an_administrator_cannot_be_linked_by_anybody_but_themselves, tests/test_directory_store.py::test_the_runtime_role_cannot_write_a_directory_table, tests/test_console_api_sign_in.py::test_a_registration_is_validated_against_the_directory_and_lands_as_a_draft | Verified |
| T5.19 | E | Access here outlives an account being disabled at the customer's directory | Before a directory session is renewed, and only then, the refresh token is redeemed at the stored token endpoint outside every database transaction. invalid_grant, or an ID token whose subject or tid changed, revokes the credential and every session under it on that request, which is what both Entra ID and Okta answer for a disabled, deleted or session-revoked account. A session renews once half its fifteen minutes has passed, so a person using the console is rechecked about every seven and a half minutes and one who walked away is gone in fifteen. Every other error word, invalid_client included, is unavailability rather than a refusal, so a client secret rotated at the customer's end does not end every session in the tenant: the session is accepted while the last verification is within fifteen minutes and refused after it, and nothing is revoked, so an outage that resolves lets people carry on. Disabling the provider and unlinking a person both revoke every credential under them in the same function. The residual is the window: a person disabled at the directory keeps the console for up to the recheck interval, and one disabled during a directory outage keeps it for up to the grace; an administrator deactivating them is immediate (directory_store.recheck_session, surfaces/human.py). | See R26; tests/test_directory_store.py::test_a_refused_recheck_ends_the_session_on_this_request, tests/test_directory_store.py::test_an_unreachable_directory_is_tolerated_and_then_refuses, tests/test_directory_store.py::test_a_disabled_provider_refuses_a_sign_in_and_ends_every_session_under_it | Partial, see R26 |
| T5.20 | E | A future change reads a tenant's plan or its agent-principal count anywhere on the request path, turning an administrative entitlement (Hosted Free's cap) into a security control that can fail open on a bug or block a legitimate incident response | The cap is evaluated only where an allowance can be taken, both of them administrative definer functions, and raises PLAN_LIMIT there alone: admin_create_principal's own capped branch, and admin_reactivate_principal when its target is an agent a person deactivated by hand, because that deactivation freed an allowance and reactivating takes it back (without this, a tenant at three could deactivate one, create another and reactivate the first, holding four, and repeat without limit). Both take FOR NO KEY UPDATE on the tenant row before counting live and sleeping agents against the same CASE t.plan limit, so a creation and a reactivation contending for the last allowance serialize on one lock and exactly one wins. Waking a sleeping agent is never refused, because a sleeping agent never stopped counting. PLAN_LIMIT is an administrative refusal that never appears in a policy decision's reasons, is never consulted by admission, evaluation, dispatch or approval, and changes nothing about an already-created principal's ability to act (an over-cap tenant keeps every agent it holds). The guarantee is checked structurally rather than by sampling routes: execbound.requests and every module under execbound.execution, execbound.policy and execbound.resolution are read as source text and asserted to contain neither the string PLANS nor agent_principals, so a plan lookup added to any request-path module fails the suite closed rather than needing a reviewer to notice it (plans.py, administration.py, migration 0044_hosted_free). | HF9, HF53, HF54 to HF58; tests/test_hosted_free.py | Verified |
| T5.21 | E | A route reachable by a tenant, an agent or the public reaches sandbox_fallback, which (unlike sandbox_expire) leaves the tenant active rather than ending it, or the day-fourteen guard against an unclaimed, converted, already-deactivated or not-yet-due slot is missing from the function that replaced sandbox_expire as the cron's default | sandbox_fallback(p_now) is a fenced, no-actor SECURITY DEFINER function with exactly one caller in the codebase: _fall_back in signups.py, reached only from sweep, the execbound-signups cron's own pass, under its own fence (execbound.tenant_id set to the slot being processed and never to a caller-supplied value). No HTTP or MCP route, no agent-authenticated path and no console page calls it. sandbox-convert --plan free does not call this function: its early-fallback branch produces the identical state (plan = 'free', fell_back_at stamped, tenant left active) through its own direct UPDATEs on execbound.tenants and execbound.onboarding_slots, inside owner_transaction's fenced owner connection, a second, independent implementation of the same state change rather than a second caller of the function. sandbox_fallback's guard restates sandbox_expire's own: a slot that is unclaimed, converted, already deactivated, already fallen back or not yet due is INVALID_SLOT, and a clock more than five minutes from the database's is INVALID_CLOCK. sandbox_expire sets tenants.active = false; sandbox_fallback leaves it true and only lowers the tenant's cap from uncapped to three agent principals, a widening of what the tenant keeps relative to expiry's outcome and never a grant of authority the tenant did not already hold, and each refuses the state the other produces, so the two endings are mutually exclusive in both orders (migration 0044_hosted_free.py, signups.py). | HF10, HF17, HF18, HF46; tests/test_signups.py, tests/test_hosted_free.py, tests/test_route_authority.py | Verified |
| T5.22 | I | Computing which free tenant's agents have gone ninety days silent reads audit event payloads, or the derived signal cannot be reproduced once the second Hosted Free spec's retention pass redacts payload content | agents_sleep's own SQL and the cron's _silent_ninety_days both read only audit_events.kind (filtered to execution.%) and occurred_at; neither reads payload, so no retention or custody boundary is crossed to compute dormancy. Silence is measured from the later of the slot's own claimed_at and max(occurred_at) over the tenant's execution.* events, which survives the payload redaction the retention spec (the second half of #439, not built here) will apply, because that spec keeps every event's kind and occurred_at and drops only content. A tenant with no execution.* event at all is judged by its own claim's age rather than treated as silent the instant it becomes free (migration 0044_hosted_free.py, signups.py). | HF33, HF36, HF40, HF41; tests/test_hosted_free.py | Verified |
| T5.23 | E | sandbox-deactivate, the operator's own way to end an abusive sandbox tenant at once regardless of its end date, becomes reachable from the runtime role or a tenant-facing route, handing an undated any-tenant off switch to the request path | Unlike sandbox_expire, sandbox_fallback and every other lifecycle function in this family, deactivate_sandbox is deliberately not a SECURITY DEFINER function: check_runtime() requires every function in that catalog (db.py's FUNCTIONS) to be executable by the runtime role, and an off switch reachable by the runtime role is an off switch reachable from the request path however carefully its own guard is written. sandbox-deactivate instead runs as a direct owner write over the owner connection inside owner_transaction, the same shape convert_sandbox already has: it locks the slot row, checks claimed_at/converted_at/deactivated_at, updates tenants.active and onboarding_slots.deactivated_at and appends tenant.deactivated, reachable from nowhere but the CLI's run_sandbox_command (cli.py) under the operator's own credential. sandbox_deactivate is asserted absent from db.py's FUNCTIONS catalog by name, so a future change that turned it back into a definer function would fail this guard closed (signups.py, db.py, cli.py). | HF8, HF24, HF25, HF28; tests/test_hosted_free.py | Verified |
B6: Monitoring sources and notification receivers
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T6.1 | S | Forged or replayed story events | Source tokens bound to one tenant, source and story; agent and ADMIN tokens are rejected at intake; duplicate receipts are idempotent. | tests/test_monitoring_http.py, tests/test_monitoring_store.py; live trial in TINES_MONITORING.md | Verified |
| T6.2 | E | An observation grants execution authority or settles a provider effect | Separate monitoring domain: no execution mutation, approval, provider call or impact change. | tests/test_monitoring_store.py, tests/test_monitoring_worker.py | Verified |
| T6.3 | D | Intake flood | Per-source rate limit of 120 attempts per minute, intake concurrency of 8, and bounded sources and observations per tenant (monitoring/http.py, monitoring/store.py). | tests/test_monitoring_http.py | Verified |
| T6.4 | S | A notification destination chosen by untrusted content (server-side request forgery) | Only a loopback development receiver exists; operator-configured, validated destinations are not built. | #31 | Open |
| T6.5 | R | Source token rotation or revocation | ADMIN-only rotation with a bounded grace window for the previous token and final revocation, both inside the monitoring registry lock with current authority rechecked and the acting administrator recorded on the source; ingestion accepts the previous token only until its deadline (monitoring/store.py). | tests/test_monitoring_lifecycle.py (SL1 to SL10) | Verified |
| T6.6 | D | Cooperative visibility mistaken for enforcement; total outage cannot self-alert | Documentation states that monitoring cannot prevent execution and that a heartbeat proves reporting freshness only; an independent uptime check is required. | #29, #31 | Accepted, see R7 |
| T6.7 | R | Missing telemetry, a silent source or an implausible external clock goes unnoticed | Receipt-time health is persisted per source and each new gap records a delivered source-health.v1 finding; a reported time more than five minutes ahead of receipt or a day behind it records a clock-skew.v1 finding while the reported time stays untouched (monitoring/store.py). | tests/test_monitoring_health.py (SH1 to SH10) | Verified, see R7 |
| T6.8 | T | A configured detection silently stops applying, or a profile change hides why a finding exists | Profiles are immutable versions written only by a current ADMIN under the registry lock; every finding records the version that produced it; missing configuration is stated as inactive coverage on the page, never treated as nothing to report (monitoring/store.py). | tests/test_monitoring_rules.py (RS1 to RS13) | Verified |
| T6.9 | I | A vendor management key in the gateway database, or in a log or page, widens a database or backup exposure into platform access | The collection connection retains only the key's SHA-256 fingerprint; the key stays in an operator file read without interpolation, is compared to the fingerprint before any request, and never enters the database, logs, pages or audit chain (monitoring/collection.py, monitoring/store.py). | TD1, TD2, TD12; tests/test_tines_collection.py | Verified |
| T6.10 | T | Collection mutates the connected platform, follows a redirect or a proxy to another destination, or is used as a general request tool | The reader issues GET only to the connection's pinned .tines.com/.tines.io origin with redirects and ambient proxies disabled, no retry inside a run, a 4 MiB body cap and a 10 second timeout; the paths are module constants and no response field selects a destination (monitoring/collection.py). | TD1, TD6; tests/test_tines_collection.py, tests/test_repository_rules.py | Verified |
| T6.11 | E | One credential is both the collector and the executor, so a read-only collection secret can disable an account | Collection uses its own app registration, consented to AgentIdentity.Read.All, Application.Read.All and Directory.Read.All and nothing that can write; the registration that executes is a different application with a different secret in a different private file. The two share the directory and nothing else (monitoring/graph.py, SCOUT.md). | SR1, SR13; tests/test_scout_run.py | Verified against fixtures; the live probe is separately authorized |
| T6.12 | T | A directory response redirects the collector to a destination or a collection it did not ask for | Exactly one POST per run, to the pinned login origin, for a token; every read is a GET to the pinned Graph origin with redirects and ambient proxies disabled. Graph returns the next page as a URL inside the response, and it is followed only when it is the pinned origin and the same path; four shapes of redirected continuation are refused (monitoring/graph.py). | SR13; tests/test_scout_collection.py | Verified |
| T6.13 | I | A directory read carries a person's name, address or a credential into the gateway | Every parser is an allowlist that takes named fields rather than removing known ones, because agentIdentity is an open type; a sponsor keeps an object identifier and a kind and nothing else; a blueprint records which kinds of credential it holds, never a value or a hint; drops are counted (monitoring/graph.py). | SR2, SR3; tests/test_scout_collection.py, tests/test_scout_run.py | Verified |
| T6.14 | R | Inferred reach is mistaken for proof, or a governed route is mistaken for a closed one | A certainty is only what its own path establishes and reach is stored per path, so a grant never claims an enforced mode and a governed path is an additional row; an unexplained grant produces no row at all and is counted; a lapsed claim reads as unknown with the moment it lapsed; ungoverned-reach.v1 closes on the grant disappearing or an ADMIN acceptance, never on a mapping being registered (monitoring/reach.py, monitoring/store.py). | SR4 to SR8, SR11, SR14; tests/test_scout_reach.py, tests/test_scout_rules.py, tests/test_console_api_monitoring.py | Verified |
| T6.15 | I | Collected responses carry credential values, action options, audit inputs and outputs, model text, email addresses or request addresses into storage or a page | Field allowlists run in the parser before anything is retained; options, values, inputs, outputs, prompts, completions, names, email addresses, addresses and user agents are dropped, and a record the allowlist cannot normalize is counted, never stored raw or logged (monitoring/collection.py). | TD2, TD10, TD12; tests/test_tines_collection.py, tests/test_browser_console_monitor.py | Verified |
| T6.16 | T | A crash, a page boundary, a rate limit or a retention gap silently loses or duplicates collected evidence, or a response moves rows between tenants | A page, its pending evaluations and the cursor advance commit in one transaction and the cursor never passes an unaccepted record; a five-minute overlap with (tenant, connection, event_id) uniqueness makes a re-read free of duplicates and a reused identifier with different content records an integrity finding; rate limits, lost permission, retention gaps and backlogs mark health and record collection-health.v1 per episode instead of failing quietly; the first collected record pins the platform tenant and a differing one is quarantined (monitoring/store.py). | TD3 to TD7, TD11; tests/test_tines_collection.py | Verified |
| T6.17 | R | Discovered activity is mistaken for enforcement, or an unrecognized action type or operation name silently clears a story or a change | Collected observations are labelled collected and cannot admit, approve, settle, reserve or refund; an action type outside both known sets is coverage_unknown rather than "no outbound", an operation name outside the versioned mapping evaluates INSUFFICIENT_EVIDENCE, an unmapped AI tool name stays insufficient evidence, and every page states that an ordinary action's effect is not an audit record and that capabilities are fixture-verified until the live trial. | TD8 to TD10, TD12; tests/test_tines_collection.py | Verified |
| T6.18 | S | A chat webhook URL or an SMTP password is exposed, or a delivered notification carries authority | Destinations come from deployment configuration and never from a tenant field. A slack or teams destination's URL is itself the secret: it is accepted only for a pinned host, held with repr=False, and rendered as kind and host and never as the URL; an email destination carries neither a URL nor a secret. A payload carries identifiers, catalog labels, timestamps and the fixed approval page link, so no message carries a control that decides anything. | tests/test_notification_chat.py::test_chat_urls_outside_the_rules_refuse_without_quoting_them, tests/test_notification_chat.py::test_the_tenant_overview_shows_kind_and_host_never_the_url, tests/test_notification_chat.py::test_a_chat_destination_carries_neither_a_secret_nor_a_recipient, tests/test_approval_notifications.py::test_an10_invalid_destinations_are_refused_at_startup_without_leaking_the_secret | Verified |
| T6.19 | I | A secret-bearing message reaches a shared channel, or a resent code collapses into the message whose code no longer works | The invitations class is email only, one recipient, and is never rendered for a chat destination; it is the only message kind that carries a secret. The code is minted at send, exists in memory and in that one message, and only its SHA-256 is stored; each attempt takes a fresh Message-ID so a resend cannot collapse into an earlier message, and a crash during the send revokes the invitation rather than leaving a live code nobody received. | tests/test_invitation_email.py::test_i6_the_rendered_invitation_carries_the_code_once_and_never_collapses, tests/test_invitation_email.py::test_i4_a_crash_during_the_send_revokes_the_invitation | Verified |
B7: Live system to replay and export
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T7.1 | E | Replay executes a provider or mutates live state | File-only entry point; subprocess tripwires forbid database, provider, socket and dotenv access. | J; tests/test_replay_isolation.py | Verified |
| T7.2 | I | An export leaks secrets or another tenant's history | Current ADMIN authority rechecked before publication; secret-bearing input rejected against the one definition the log guard and the test helper also read (secrets.py); tenant-scoped reads. | S; tests/test_replay_export.py::test_foreign_tenant_bundle_id_collision_cannot_enter_export, tests/test_secrets.py | Verified |
| T7.3 | T | Tampered history, head or bundle content | Chain, retained head, bundle hashes and plan and approval bindings are verified offline. | tests/test_replay_verify.py | Verified |
| T7.4 | I | Logs or an event export leak a secret or an untrusted string, or the export skips or reorders history | Logs are schema-only with a closed event catalog and a suppression filter for private text; the export sends the replay export's sanitized representation after its secret check, in sequence order, advancing a per-destination cursor only on acknowledgement, and never writes the chain (logging_schema.py, export.py, secrets.py). | tests/test_structured_logging.py, tests/test_event_export.py (SE1 to SE13), tests/test_secrets.py | Verified |
| T7.5 | T | A one-approver consumption is presented as satisfying a two-approver requirement | Approval captures carry required_approvers and every counted approver; a VERIFIED capture with fewer distinct approvers than required, a repeated principal or a denial is INSUFFICIENT_QUORUM and makes the history incomplete; candidate comparison never assumes recorded approvers satisfy a changed requirement (replay/verify.py, replay/simulate.py). | tests/test_approval_quorum.py::test_qa11_replay_captures_every_approver_and_refuses_a_short_quorum | Verified |
B8: Build, dependencies and CI
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T8.1 | T | Vulnerable or substituted dependency | Locked, hash-pinned environments (uv sync --locked; the maintained MCP client is hash-locked separately); the pip-audit vulnerability audit runs weekly and on demand in the scheduled security-scans workflow, which retains its finding population for 90 days, and locally on demand; it is not a merge check, and Dependabot security updates are off. | #46, PR #67 | Partial, in review |
| T8.2 | I | A credential committed to the repository | Environment and key files are ignored; gitleaks scans the whole history weekly and on demand in the scheduled security-scans workflow, and with a local binary on demand. It is not a merge check, and a scan that is skipped is recorded as a coverage gap rather than a clean result. | #46, PR #67 | Partial, in review |
| T8.3 | T | Evidence not tied to the tested source | The CI manifest binds artifacts and collected test nodes to the exact commit and lock hashes. | VALIDATION.md | Verified |
| T8.4 | T | Parsing or canonicalization bugs under unexpected input | Example-based tests only; property-based and fuzz tests are planned. | #49 | Open |
| T8.5 | I | Static analysis limited to open-source tooling | Bandit runs weekly and on demand in the scheduled security-scans workflow, with its findings retained, and locally on demand; it is not a merge check. CodeQL and native secret scanning need GitHub Advanced Security on this private repository, and no remediation SLA or license review is adopted yet. | #46 | Accepted |
| T8.6 | T | The runtime image drifts from the locked environment, runs as root or carries private files | Built only from uv.lock with base images pinned by digest; non-root uid 10001, whose login shell and empty ~/.ssh serve only Render's operator sessions (opened by a key on the Render account; the image runs no SSH server); allowlist build context; CI builds and smoke-tests every image, then retains a CycloneDX SBOM of the image's own dependency set and a provenance document binding the revision, the locked input digests, both pinned base images and the built image id, with signing and deployment recorded as explicit gaps. | .github/workflows/ci.yml; DEPLOYMENT.md | Verified |
| T8.7 | T | A vulnerable package in the image's base layer goes unnoticed because only Python dependencies are audited | The scheduled scan builds the runtime image, exports it and scans the tarball with a digest-pinned Trivy container (no Docker socket is exposed), covering operating-system packages and every Python distribution on the filesystem, not only the application environment the SBOM describes; the whole population is retained for 90 days and a HIGH or CRITICAL advisory with a published fix fails the run, with the policy in scripts/scan_summary.py rather than a severity flag. It is not a merge check. A finding that cannot be fixed here is accepted in writing in security/acknowledged-advisories.toml with a reason and a review_by date capped at 180 days, after which the run fails as expired; an unreadable or incomplete record suppresses nothing, and the committed file is validated by the test suite rather than only by the weekly scan. | .github/workflows/security-scans.yml; tests/test_scan_summary.py | Verified |
| T8.8 | T | A forbidden rendering, transport or TLS flag enters the tree without a reviewer noticing | tests/test_repository_rules.py is a table of patterns the tree may never contain, each with the invariant it protects and where that invariant is written down: a connection string rendered with its password, a followed redirect, a transport reading the environment, an echoed SQL parameter, unverified TLS, a bandit marker that does not name its code and its reason, a traced shell step in a workflow, and a hand-written secret assertion. It runs in the suite, so it is a merge check rather than a weekly scan, and bandit has no rule of this shape. A file is allowed only by name and reason in the table, never by an inline comment, and an allowance that no longer matches fails, so an exception disappears the day it is unnecessary. | tests/test_repository_rules.py; DEVELOPMENT.md | Verified |
B9: Gate caller to gateway
The external execution checkpoint puts the last mile on the customer's credential, so this boundary has more Accepted rows than any other. Each says which of the guide's two tables it comes from.
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T9.1 | E | A caller selects Gate for itself, names an executor, declares a precondition class or claims a tier | All four are owner-provisioned bundle content or owner-registered deployment configuration; external_executor, precondition_class and expected_version are refused on the wire as unknown fields, the class is keyed by the provider binding the frozen plan names, and the tier is derived from what ExecBound could check rather than from anything the report claims (policy/models.py, execution/admission.py, execution/outcomes.py). | GC15; tests/test_checkpoint.py | Verified |
| T9.2 | E | A grant is used as authority to execute, approve, dispatch or reconcile through ExecBound | Admission returns no dispatch ticket for a Gate mapping and the runtime's checkpoint branch never reaches the connector table; take_dispatch refuses a row carrying a report window as well as one already stamped; the grant's only power is to settle the one execution it names. The MCP tool description states that a grant is neither an execution nor an approval. | GC1, GC18; tests/test_checkpoint.py, tests/test_checkpoint_workflow.py, tests/test_mcp_surface.py | Verified |
| T9.3 | S | A forged, replayed or cross-execution report settles a grant | One report secret per execution, returned once and retained only as a SHA-256 digest in owner_hash, compared in constant time; another principal's execution answers NOT_FOUND; a settled row accepts only a restatement of its own tier, outcome and reason, and every other report against one is refused rather than discarded (execution/outcomes.py). | GC6, GC9, GC11; tests/test_checkpoint.py | Verified |
| T9.4 | S | A forged attestation, or a registered key nobody holds the private half of | Ed25519 public keys only, so no secret at rest can forge the attested tier; the ten small-order points and every non-canonical encoding are refused at registration and again when the ring is built, because such a key makes one fixed signature verify every message; the signed envelope carries its own domain and version and binds the key id, so it can never be relabelled or be the same bytes as a provider journal's (provider/executor.py, administration.py). | tests/test_checkpoint_evidence.py, tests/test_admin_cli.py | Verified |
| T9.5 | E | A stolen executor key settles grants it was holding, refunding reservations and freeing targets | A retirement is deliberately backward-permissive, so a separate act records a compromise, which the verifier reads retroactively and which reaches every id of the tenant carrying that public key; it is asked inside the settlement's own fenced transaction, so a cut-off committing while a record is being verified still refuses; recording it may empty the ring, and admission then refuses new grants for that tenant (administration.py, provider/executor.py, migration 0026_key_compromise). | tests/test_checkpoint_evidence.py, tests/test_checkpoint.py, tests/test_admin_cli.py | Verified, see R13 |
| T9.6 | T | An executor frees its own target or refunds its own budget by what it says | A reservation and a canonical claim are committed before the grant is issued; the claim is released exactly when a verifier returned the evidence, so no assertion releases a claim or refunds a reservation. What an assertion may settle is narrower than a reader of the earlier wording would take: an asserted EXECUTED settles and confirms its impact, an asserted FAILED_EFFECT_UNKNOWN with reason REPORTED_FAILURE settles terminally and confirms its impact too — so a stated failure costs exactly what a stated success costs, and there is nothing to gain by choosing between them — while an asserted FAILED_NOT_EXECUTED, the one outcome that claims the vendor was never reached, and an unknown result settle nothing at all, which is the refund on absent evidence the contract forbids (execution/outcomes.py). One consequence belongs in this row rather than in the key-compromise row above: revocation as COMPROMISED no longer freezes such a grant. The verifier is loaded only for a report presenting a signed record, so the executor's or the control point's bare word still settles the row, confirms its impact and puts it in front of the recovery run, which frees the target at the act window; what the revocation takes away for good is the tenant's ability to answer that word, because the correction needs a key usable at the grant instant. | GC7, GC8, AL12, AL13, AL18, AL33; tests/test_checkpoint.py::test_no_route_to_an_asserted_execution_releases_the_claim, tests/test_arcade.py | Verified |
| T9.7 | T | A report settles an operation ExecBound dispatched, or a provider journal settles a grant | The report route re-reads the owner-provisioned bundle the execution was frozen under, inside the fence, and refuses any execution whose mapping does not declare an external executor, before the secret is compared; the connector paths refuse a row carrying a report window; and each side refuses the other's kind of verifier, so no settlement rests on the wrong kind of evidence for the row it names (execution/outcomes.py, execution/reconciliation.py, provider/gateway.py). | GC9, GC10; tests/test_checkpoint.py | Verified |
| T9.8 | D | An executor takes grants and never reports, or spams refused reports | Outstanding grants are capped at eight per principal inside the fence and a refused attempt rolls back entirely; the report route shares the runtime's capacity bound, so refusals cannot serialize a tenant's writers; an unreported grant is swept into retained uncertainty and shown in the uncertain queue. The budget it holds until then is the point: a grant charges its scopes exactly as a dispatch does. | GC16; tests/test_checkpoint.py | Verified, see R14 |
| T9.9 | R | An executor acts and never asks, ignores a denial, or acts twice on one grant | Nothing prevents any of the three, and the guide says so in the same table. ExecBound has no record of the first at all; the second leaves a denial in the history with nothing charged; the third is invisible and is charged once. Only monitoring coverage of that path can notice an action with no matching checkpoint, and it is classified as missing evidence, never as a proven bypass. | CHECKPOINT.md §5 | Accepted, see R7 |
| T9.10 | T | The vendor's target changes between the grant and the executor's action | The frozen expected version travels with the grant and is only as strong as the vendor's conditional write. Under ASSERTED_BY_EXECUTOR the executor can only compare and refuse, and under UNAVAILABLE a concurrent change is undetectable; the class the grant carried is the only record that the question was unanswerable. Pair an UNAVAILABLE binding with an approval obligation. | GC12; tests/test_checkpoint.py | Accepted |
| T9.11 | S | An operator declares a precondition class the vendor does not offer | Nothing checks it, and nothing can: the class is a statement about a vendor's API. A declaration naming a binding the running catalog does not bind is refused at load, and an undeclared binding answers UNAVAILABLE, so the failure mode is an operator's own overclaim rather than a silent default. | SURFACES.md | Accepted, see R15 |
| T9.12 | E | A compromised Gate caller's credential also dispatches through ExecBound | A Gate caller holds a vendor credential and is a different trust class from a protected agent, which holds none, so a principal should serve Gate mappings or Execute mappings and not both. Nothing in the code enforces that separation today; admission refuses a Gate mapping whose account already has a connector, which keeps one operation from being both, and says nothing about a principal that has one mapping of each kind. | CHECKPOINT.md | Open, see R16 |
| T9.13 | R | An administrator settles a grant its executor is still authorized to act on | Deliberately admitted: the evidence decides and not the actor, so an executor-signed record presented by an administrator is the record the outcome route would accept. It closes ExecBound's route on that executor and does not stop it acting at the vendor, and the console says so where the administrator is about to act. | RECOVERY.md; tests/test_checkpoint.py | Accepted |
| T9.14 | S | A forged or replayed ES256 JWS settles a grant | The header is parsed strictly (alg: "ES256" and kid only) and the payload strictly (the domain, version 1 and the seven record fields, nothing else); the 64-byte concatenated r-and-s signature is verified over the exact signing input; the binding checks the same four fields and reported_at an Ed25519 record checks, against the same command. A second, differently signed JWS can only restate a settled outcome, which retains and appends nothing. | GC6, TG7, TG8; tests/test_gate_evidence.py | Verified |
| T9.15 | E | Algorithm confusion: a token labelled for one scheme verifies under a key of another | The algorithm always comes from the registered key's own algorithm column, never from the header or from anything the message claims: an Ed25519 key never verifies a JWS and an ES256 key never verifies the Ed25519 envelope, so a header naming HS256 keyed with the public key, RS256, EdDSA or a mismatched kid is refused before a signature is checked. | TG8; tests/test_gate_evidence.py | Verified |
| T9.16 | S | A provider's journal for one command, execution or operation settles a grant it does not name | CheckpointVerifier compares journal.record.command against the frozen provider command for this execution byte for byte, as a command hash and then as canonical JSON (the operation, account, intent hash, plan hash, target, version and arguments together), and refuses a journal dated before the grant. A checkpoint_providers entry is keyed by tenant, account and key id, so a journal signed for another tenant's or another account's Gate mapping verifies under no key this one holds. | TG3, TG14; tests/test_gate_evidence.py, tests/test_gate_vendor_walk.py | Verified |
| T9.17 | I | A reader of the gateway's private configuration forges a provider journal | The journal key is HMAC and sits in the same file as connector evidence keys, so a reader of that configuration is in the same trust boundary a connector's own journal key is: the key is verification-only, the gateway never calls the provider with it, and the executor never holds it, but the file's own confidentiality is what stands behind it rather than a cryptographic separation ExecBound enforces. | CHECKPOINT.md; SURFACES.md | Accepted, see R3 |
| T9.18 | E | The Tines ES256 signing credential's reach is wider than the story that uses it | Tines refuses a credential in a cryptographic function while domain restriction is enabled, so the signing credential carries no host restriction and any story in the team with access to it can produce a valid attestation; the report secret is readable in the same team's event data for the life of the grant. Neither is a defect the protocol can close from ExecBound's side: the recipe states both and recommends a dedicated Tines team and short event retention. | CHECKPOINT.md | Accepted, see R27 |
B10: Control point to hook surface
The Arcade logic extension makes ExecBound the decision service behind a platform's hooks, so this boundary is about a caller that calls on every tool invocation and about the correlation table that stands behind it. Every row here is fixture-verified; nothing has contacted Arcade.
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T10.1 | E | A hook field selects the mapping, the action, the target, the incident or the tier | The tool map is deployment configuration in the file that already carries connectors and precondition classes, and it is part of the configuration shape a key reload may not change; the exact toolkit, name and version pick one declaration and nothing else does; the target and the incident are each one declared input key taken by name with no expression language, path syntax or fallback; the tier is derived from what ExecBound could check. Reported metadata may only refuse (arcade/map.py). | AL8, AL9, AL10, AL24, AL34 | Verified |
| T10.2 | T | An input the map does not model changes what the vendor does behind a decision made without it | inputs is an allowlist and any other key is refused before a kernel call, so the residual is a brittle tool rather than a smuggled field: a vendor adding an optional input breaks every governed call of that tool until an operator declares it. | AL9 | Verified, see R18 |
| T10.3 | E | The extension permits something the kernel did not | It is a translator: it builds the wire bodies ActionRequest and OutcomeReport already define and calls Runtime.execute and Runtime.report, opens no transaction of its own around a decision and has no other path into the kernel; everything it decides by itself can only deny, and a tenant emergency stop is refused ahead of both of its OK answers (arcade/routes.py). | AL1, AL5, AL6, AL22 | Verified |
| T10.4 | S | A duplicate or racing delivery gets a second OK, or a lost one strands a grant the platform is told to act on | The OK rule is keyed on what the kernel returned and not on what the extension recorded: a DISPATCH_COMMITTED answer is OK only while this Arcade execution's own correlation row holds that grant's report secret. The kernel mints a grant's secret exactly once and a re-grant carries none, so the concurrent second Arcade id, the abandoned-deadline row and the crashed row are all refused with no special case. | AL2, AL3, AL4 | Verified |
| T10.5 | I | A reader of the correlation table settles a tenant's row or frees its target | Deliberately admitted and stated as the bound rather than claimed against: the secret is usable only inside the report window, it is nulled at settlement and cleared opportunistically afterwards, and a reader that presents it closes the row and lets the scheduled run free the target once the act window has passed. What no reader of it can do is reduce what the tenant is charged, because both statements the carrier can make confirm the impact. | AL18 | Accepted, see R17 |
| T10.6 | R | The platform reports a failure the tenant cannot answer | A reported failure settles terminally at FAILED_EFFECT_UNKNOWN, tier ASSERTED, with the impact confirmed — which is what keeps the project working, because silence wedges the intent. The tenant's answer is the correction: an ADMIN-signed non-execution through reconcile, on one row, restoring exactly the power the same administrator held over that execution one state earlier. It is refused in every other direction, needs a key usable at the grant instant, and an ADMIN performs it from the Activity page's settlement control on that row. | AL13, AL20, AL30, AL31, AL33 | Accepted, see R19, R20 |
| T10.7 | D | An ungoverned toolkit's traffic drives the tenant's audit head | An ungoverned pass makes no kernel call, no execution row, no reservation and no audit event, asserted against the tenant's chain head before and after. The reason is the lock rather than the volume: append_event takes the per-tenant serializing structure, and the ungoverned pass is the success path of every tool call in the project. The residual is that neither the chain nor the correlation table can say how many such calls passed. | AL8 | Accepted, see R18 |
| T10.8 | D | The deployment under load stops answering in a shape the platform can act on | The boundary rewrites the application's own non-hook answers per route: on POST /pre a 429 becomes RATE_LIMIT_EXCEEDED and everything else that is not a 200 or a 401 becomes CHECK_FAILED, so an overloaded pre hook blocks whatever failure mode the project is configured with; on POST /post nothing is rewritten, because converting an error to OK would tell the platform its report was accepted when nothing was settled. The extension's own deadline is required with no default and answers CHECK_FAILED rather than the retryable code. | AL7, AL21, AL25, AL32 | Verified |
| T10.9 | I | Third-party identity material or a vendor string reaches ExecBound's storage, logs or the agent's context | authorization[].oauth2.at is dropped at the edge — not parsed, not retained, not logged — and context.secrets carries names and no values; output is opaque, dropped before anything else happens and never retained or logged; error_message carries a stable ExecBound code, the execution id where one exists and, for a pending approval, the approvals page, and never an input value, a vendor string, a rule id, a secret or a continuation. | AL5, AL14 | Verified |
| T10.10 | E | A second logic extension changes the inputs after this one decided, or a denied call runs anyway | Neither can be prevented and the guide says so in the same table. Two arrivals are recorded: a post hook whose correlation row is a denial, and one whose inputs differ from the frozen ones, each appended once to the tenant's chain under execution.control_point_contradiction exactly when it moves the correlation row. Both are missing or contradictory evidence and neither is a proven bypass. | AL16, AL17 | Accepted |
| T10.11 | S | An operator declares a precondition class for a binding this carrier serves | Nothing checks it, and on this path it can mean nothing at all: the hook response has no field for a grant and the extension sets no override, so no version reaches Arcade and no record can say whether the target moved. A class declared for such a binding is an overclaim on every page and report that shows it; the honest declaration is none. | ARCADE.md | Accepted, see R15 |
| T10.12 | S | A caller reaches policy through a fact the surface does not evaluate | The one context field the extension fills is the incident id, and it is a claim: registry.incident binds it to the resolved target under the mapping's own tenant, account, provider and resource type inside an RLS-scoped transaction, so an id naming nothing, another tenant's, another account's and another resource's are each refused. What it does not bind is which of the target's incidents, so a second or a closed one resolves and the tenant's policy pack is what refuses it on the frozen facts. severity, environment, role and criticality are never set from a hook. | AL9, AL34 | Verified |
B11: Console to model provider
The console assistant (design, #330) puts a model between a person and the tenant's records. The boundary is about what reaches the model, what comes back from it, and who can read either. The engine is execbound/assistant/; the console routes are a later pull request and add their rows here when they land.
| ID | STRIDE | Threat | Mitigation | Evidence | Status |
|---|---|---|---|---|---|
| T11.1 | E | The assistant reads or changes something the asking person could not | Every tool is a thin wrapper over a function a page already uses, run with the person's own session inside the tenant fence and rechecked per call; there is no generic query, no tool that writes and no tool that reaches a provider; a page function's refusal is a found: false answer; an agent principal can own no turn (assistant/tools.py, assistant/turns.py). | AS3, AS4; tests/test_assistant_tools.py::test_an_approver_reads_a_scoped_request_whole_and_nothing_outside_the_scope, tests/test_assistant_tools.py::test_agent_reads_are_the_administrators_and_every_string_is_enveloped, tests/test_assistant_turns.py::test_turns_are_private_to_the_person_who_asked | Verified |
| T11.2 | T | Text an agent, a provider or a tenant user wrote instructs the model, or reaches the console as markup | Every string from tenant data travels inside an untrusted envelope the instructions declare data; the answer is structured output of plain paragraphs the server stores as text; an id becomes a citation only if a tool returned it in the same turn; the console renders text under its policy (assistant/tools.py, assistant/render.py, assistant/loop.py). | AS5; tests/test_assistant_loop.py::test_injected_instructions_in_tenant_data_reach_the_model_only_as_data, tests/test_assistant_client.py::test_an_answer_keeps_only_the_ids_this_turn_saw_and_no_control_characters | Verified |
| T11.3 | I | A credential, token, code or key reaches the model | Tool projections select no secret column; every result passes find_secrets before it is sent and a hit fails the turn with REFUSED_SECRET, audited, never retried; the transport records what it was sent so the test asserts the absence (assistant/tools.py, assistant/loop.py). | AS6; tests/test_assistant_loop.py::test_a_secret_in_a_tool_result_fails_the_turn_and_never_reaches_the_transport, tests/test_assistant_tools.py::test_a_secret_shaped_string_in_tenant_data_ends_the_turn_before_it_leaves | Verified |
| T11.4 | E | An explanation tells an approver what to decide | The answer schema has no field for a recommendation, the instructions forbid one, and the renderer refuses a fixed phrase list in an approval context, storing the turn as FAILED/RECOMMENDATION with no answer (assistant/render.py, assistant/loop.py). | AS7; tests/test_assistant_loop.py::test_an_explanation_of_an_approval_cannot_carry_a_recommendation, tests/test_assistant_client.py::test_a_recommendation_in_an_approval_context_fails_the_answer_and_nowhere_else | Verified |
| T11.5 | I | One person reads another's questions or answers, or the audit chain carries either | Turn rows are read by the asker alone and written only through definer functions that check the actor owns the turn; the audit events carry the turn id, tools, counts and reasons and never text; retirement nulls the text at thirty days (migrations/versions/0038_assistant_turns.py, assistant/turns.py). | AS2, AS8; tests/test_assistant_turns.py::test_turns_are_private_to_the_person_who_asked, tests/test_assistant_turns.py::test_retirement_nulls_the_text_at_thirty_days_and_keeps_the_counts, tests/test_assistant_loop.py::test_a_turn_reads_through_a_tool_answers_with_citations_and_is_audited_without_text | Verified |
| T11.6 | D | A turn runs unbounded, or a tenant's use runs up the provider bill | Eight tool calls, sixty seconds and two thousand output tokens each end a turn with its reason and nothing is retried past a bound; a turn left running reads abandoned at ninety seconds; the operator's per-person hourly and per-tenant daily caps are counted in the transaction that creates a turn (assistant/loop.py, assistant/turns.py). | AS9; tests/test_assistant_loop.py::test_the_bounds_each_end_the_turn_with_their_reason_and_nothing_is_retried_past_them, tests/test_assistant_turns.py::test_the_caps_refuse_the_next_turn_and_are_counted_in_the_same_transaction, tests/test_assistant_turns.py::test_a_turn_left_running_reads_abandoned_after_ninety_seconds | Verified |
| T11.7 | I | The provider's key is written to the database, a log or a diagnostic, or the feature exists where nobody chose it | The key arrives only as EXECBOUND_ASSISTANT_API_KEY, is merged into the block by boot.py under the mail password's rule (one place, never both, a refusal names the variable and never the value), is consumed from the environment, and lives on a frozen value whose repr hides it; without the block the feature does not exist, and with it a tenant's switch is off until an ADMIN sets it with a reason, audited (boot.py, surfaces/configuration.py, assistant/config.py, assistant/turns.py). | AS1; tests/test_boot.py::test_the_assistant_key_is_merged_into_its_block_and_scrubbed, tests/test_surface_configuration.py::test_the_assistant_block_is_optional_and_makes_the_feature_exist, tests/test_assistant_turns.py::test_the_switch_is_off_until_an_admin_turns_it_on_with_a_reason | Verified |
| T11.8 | E | An assistant address is reachable where no model was configured, or for a tenant that never turned it on | The area module registers no route unless the configuration carries an assistant block, so the addresses do not exist rather than refusing; with the block, a turn for a tenant whose switch is off is refused ASSISTANT_OFF, and the switch is an ADMIN's with a reason, audited (surfaces/console_api/assistant.py, assistant/turns.py). | AS10, AS12; tests/test_console_api_assistant.py::test_the_addresses_exist_only_where_a_model_is_configured, tests/test_route_authority.py::test_an_assistant_block_adds_its_routes_and_changes_nothing_else | Verified |
| T11.9 | I | One person reads another's turn through the routes, or an agent credential reaches one | Every route reads the acting person's own rows and answers 404 for another's, on the read, the stream and the opened mark alike; an agent credential is refused 403 and audited on every assistant address, and the route inventory pins all seven as session routes (surfaces/console_api/assistant.py). | AS11; tests/test_console_api_assistant.py::test_a_turn_is_the_askers_and_no_one_elses, tests/test_console_api_assistant.py::test_every_route_makes_the_refusals_the_console_api_owes, tests/test_route_authority.py::test_an_assistant_route_refuses_an_agent_credential | Verified |
| T11.10 | D | A stream outlives its turn, or a person watching one holds a connection open indefinitely | The stream polls the turn row on an interval and closes on the turn's ending or at its own deadline, by which time a reader has marked an unfinished turn abandoned; it holds no channel to the loop, so a restart or a second process changes nothing it reports (assistant/events.py). | AS13; tests/test_assistant_events.py::test_the_stream_closes_at_its_deadline_saying_what_the_row_says, tests/test_console_api_assistant.py::test_a_turn_answers_into_its_row_and_the_stream_ends_with_the_rows_own_ending | Verified |
| T11.11 | R | A packaged help text tells a person something the product does not do | Each text is held to the document that states its facts by phrases that must appear in both, as the integrations manifest holds its claims; the texts carry no advice about a decision and read nothing of the tenant's (assistant/help/). | AS14; tests/test_assistant_help.py::test_each_text_says_what_its_document_says | Verified |
| T11.12 | E | A drafted rule reaches policy without a person, or widens what a bundle governs | The proposal path writes nothing: it creates no draft, adds no rule and activates nothing, and its answer is a form an ADMIN submits through the rules page, after which validation and activation recompile as always. A draft's mappings are the draft's, so a proposal can only name actions that draft already serves (assistant/proposals.py). | AS16, AS18; tests/test_assistant_rules.py::test_a_proposal_is_a_rule_the_compiler_took_read_back_in_the_editors_own_words, tests/test_assistant_rules.py::test_only_a_draft_written_as_rules_can_be_drafted_into | Verified |
| T11.13 | T | A model's answer reaches the form as something other than a valid rule | The answer is constrained to the rule model's own JSON schema, validated with Rule.model_validate_json (the validator the form uses) and compiled with the draft before it is offered; an unknown fact, an out-of-domain value, an unmapped action, a duplicate id or an answer that is not a rule at all is refused with the field that caused it (assistant/proposals.py, policy/rulesets.py). | AS17; tests/test_assistant_rules.py::test_a_proposal_the_compiler_would_refuse_is_refused_here_with_its_field, tests/test_assistant_rules.py::test_an_answer_that_is_not_a_rule_at_all_is_refused_and_nothing_is_written | Verified |
| T11.14 | I | A proposal is drafted for a draft the person cannot read, or by someone who cannot edit rules | The draft is read with the asking person's own authority through the store, so a bundle they cannot read refuses as the page refuses it, and the route is an administrator's alone (assistant/proposals.py, surfaces/console_api/assistant.py). | AS18; tests/test_assistant_rules.py::test_the_route_offers_the_form_to_an_administrator_and_refuses_everyone_else | Verified |
Residual risks
| ID | Risk | Where stated |
|---|---|---|
| R1 | Tenant isolation relies on trusted runtime code setting the tenant context. Anyone with arbitrary SQL access and the runtime credential can change that context; that attacker is outside the isolation guarantee. | README.md design authority; ARCHITECTURE.md trust boundaries |
| R2 | A privileged writer who replaces both the audit history and the retained head defeats chain verification. Independently retained checkpoints are the intended detection; the restore verifier compares a restored chain against them, but keeping checkpoints off the database host and scheduling backups remain the operator's responsibility. | ARCHITECTURE.md; RECOVERY.md |
| R3 | Provider HMACs establish authenticity between trusted components sharing a key, not third-party nonrepudiation. | ARCHITECTURE.md; REPLAY.md |
| R4 | The process boundary between the gateway, providers and replay is an application capability boundary, not an independently administered operating-system sandbox. A configured gateway process and its cross-tenant database credential remain trusted. | ARCHITECTURE.md; SURFACES.md; VALIDATION.md |
| R5 | A bundle whose writer allowlist admits a label the protected agent can write removes the scenario T protection against agent-written context. | CONTEXT_CONTRACTS.md; POLICY.md |
| R6 | The Cedar engine is a pinned community binding, used through private entry points because the public wrapper hides missing diagnostics. Every upgrade is a review item. | POLICY.md; #38 |
| R7 | External monitoring is detective and cooperative. It cannot prevent execution, a heartbeat proves reporting freshness rather than complete coverage, and a total ExecBound outage cannot self-alert. | TINES_MONITORING.md; external monitoring design |
| R8 | Failed authentication is counted per surface, reason, minute and verified subject, and execbound failures lists the counters; requests refused by the request limits are not counted, the counters carry no caller address or raw token, no alerting reads them, and the limits themselves remain per process rather than distributed. | EXECUTION.md; OPERATIONS.md |
| R9 | Key rotation reloads each process's private file independently, so a multi-process deployment can briefly verify with different rings; provider dispatch and control tokens still need a restart to change; and a key removed while an operation is unsettled leaves it uncertain until the key is restored. Backup, restore and verification are documented and rehearsed in CI on synthetic data, not yet on a real deployment. | OPERATIONS.md; RECOVERY.md; #50 |
| R10 | Authority is a bounded snapshot. Registry and credential changes are observed by fresh reads, but an independent writer can change them after the final read. Incorrect trusted provisioning is outside the agent input boundary. | EXECUTION.md |
| R11 | One hosted deployment has been validated: the Render pilot of September 16 to 17, 2026, with synthetic data and mock providers, TLS terminated by Render, the database reached through the Supabase pooler with a pinned CA bundle, operator identities provisioned from the workstation, and inventory freshness kept by manual imports. Secret rotation on the host, log forwarding and hosted backups remain deployment work, and named host trials are unperformed. | DEPLOYMENT.md; RENDER_PILOT.md; #26 |
| R12 | Only controlled mock providers exist. Real CrowdStrike and Entra semantics, including their precondition and journal guarantees, are unverified. | IDENTITY_PROVIDER.md; VALIDATION.md |
| R13 | Cutting off a compromised executor key leaves a grant only that key could have attested with no attested exit: every report carrying a signed record, the administrator's settlement and a replacement key are refused at the same verifier. A grant nobody reports at all keeps its reservation and its canonical claim indefinitely, because the sweep leaves the row INDETERMINATE and the write-off that would close it — releasing such a claim without settling the execution and audited as its own administrator act — does not exist. A grant whose holder still has its report secret is now a different case and a worse one: the bare word settles it, the impact is confirmed, the recovery run frees the target, and the correction of that settlement is permanently unreachable in that tenant, so the cut-off ends with charged, closed, uncorrectable rows where it used to leave open ones. | CHECKPOINT.md; ARCADE.md; OPERATIONS.md |
| R14 | The per-principal cap bounds unsettled grants, not held claims. A settlement on an executor's bare word leaves the unsettled set and frees a cap slot while its canonical claim is still held, so between that settlement and the next recovery run past the act window one principal can hold more live claims than the cap admits. Closing it means counting held claims at admission. | CHECKPOINT.md |
| R15 | A precondition class is a statement about a vendor's API that ExecBound cannot verify, and an UNAVAILABLE binding makes a concurrent change undetectable. No vendor's conditional write, version semantics or journal behaviour has been exercised: the whole checkpoint path is fixture-verified against the protected mocks, and the live half of its workflow contract is unrun pending a separately authorized trial. | CHECKPOINT.md; SURFACES.md |
| R16 | A Gate caller holds a vendor credential and a protected agent holds none, so one principal should serve Gate mappings or Execute mappings and not both. Nothing in the code enforces that separation: admission refuses a Gate mapping whose account already has a connector, which keeps one operation from being both, and says nothing about a principal holding one mapping of each kind. Owner provisioning is the only control today. | CHECKPOINT.md; ARCHITECTURE.md trust boundaries |
| R17 | The Arcade correlation table holds a usable report secret while a grant is live, which is a property this carrier introduces rather than inherits: a Gate secret is returned and never stored, and only its SHA-256 digest is kept. A reader of that table can settle the row it names and, once the act window has passed, cause the scheduled run to release its canonical claim. The secret authenticates nothing past the report window, and it is nulled at settlement and cleared opportunistically afterwards, so what is left is the bytes of a row nobody touches again. No reader of it can reduce what the tenant is charged. | ARCADE.md; CHECKPOINT.md |
| R18 | An ungoverned toolkit is a standing operator decision with no record behind it: its calls create no execution, no reservation and no audit event, so neither the tenant's hash chain nor the correlation table can say how many passed, and tools the vendor adds to that toolkit later pass under the same decision. A declared tool is the opposite trade — it fails closed on vendor schema drift, so an added optional input breaks every governed call until an operator declares it. No coverage may be claimed for an ungoverned toolkit, and its enforcement mode stays Unknown even where the governed tools are later qualified. | ARCADE.md; AGENT_PLATFORM_LANDSCAPE.md |
| R19 | A settlement at FAILED_EFFECT_UNKNOWN overcharges a call that did nothing: the state says the effect was never established, and the impact is charged as though it had been, for the length of each limit rule's own window. That is deliberate, because the alternative is a refund on nobody's evidence, and it is what stops a single failed tool call wedging the intent permanently. The way back is an administrator signing a non-execution for that one row, which is a human act and not an automatic refund, and which is unavailable in a tenant whose executor key ring has been revoked as compromised. | ARCADE.md; CHECKPOINT.md |
| R20 | The correction is a per-row human act, and ExecBound cannot tell a false report from a true one. The Activity page offers an ADMIN the control on a correctable row and the kernel checks the record against a registered key, so what is verified is that the executor signed a non-execution for this operation — never that the platform's earlier statement was a lie. Whatever established that is evidence an operator gathered outside ExecBound, one row at a time, and a tenant facing many falsely reported failures corrects them one at a time or carries the charge. execbound reconcile is not this exit: it looks the account up in the live connector inventory and refuses a grant in every mode. | CHECKPOINT.md; OPERATIONS.md |
| R21 | No claim about a real Arcade deployment is established at all. The hook contract was read from a beta published schema on one day; nothing here has contacted Arcade, installed arcade-mcp or used a real credential; the failure mode, the hook timeout, whether another extension is registered and whether the project routes every tool through this one are all on Arcade's side and unreadable from here. Three wire facts in particular cost a settlement or a tool if the deployment differs: success is optional on the wire while every settlement rests on it, execution_id is unconstrained on the wire and bounded here, and a content-type framed otherwise is a 400 the published contract does not retry. | ARCADE.md; VALIDATION.md |
| R22 | A gateway compromise could mint invitation codes for free sandbox slots and mail them to an attacker's addresses. The prize is a mock-backed sandbox tenant with no real connector in it, bounded by the seeded pool and by the per-domain and global caps, and every slot is single use and never reissued; the operator sees the funnel diverge from the pool. Nothing about it reaches a customer tenant. | open signups design; OPERATIONS.md |
| R23 | A space is ownership and view, not isolation. A compromise of the gateway, or arbitrary SQL with the runtime credential, reaches every space of a tenant exactly as it reaches every row of that tenant today. No document, page or sentence may describe a space as a boundary; the tenant is the boundary and the discipline is the one already applied to ENFORCED_BY_PROVIDER against CHECKED_BY_CONNECTOR. | spaces design |
| R24 | A tenant-wide limit and the canonical target claim are cross-space denial and inference channels by design: a tenant-scoped rule exhausted by space A denies space B, and a claim held in A refuses B's execution on the same canonical target with the conflict it returns today. B learns that something in the tenant is holding that target; the refusals say nothing about which space or what, and no page names another space's rows. A per-customer ceiling is the space-scoped limit rule. | spaces design |
| R25 | The space predicate has no database backstop. A missing one is a correctness bug with a customer-visible consequence rather than a refusal, and the parametrized isolation test over every space-carrying table is the whole of the mitigation (T2.9). | SP1; tests/test_spaces_isolation.py |
| R26 | Deprovisioning at a customer's directory reaches ExecBound on a poll rather than a push, so access here outlives a disabled account by up to the recheck interval, and by up to the fifteen-minute grace while the directory is unreachable. Neither Entra ID nor Okta pushes an event this relying party can receive today: Entra transmits no Shared Signals event to an outside application at all, and redeeming a refresh token is the one mechanism both honour. A Shared Signals receiver and SCIM provisioning shorten this and are Enterprise (T5.19). | #177; tests/test_directory_store.py::test_an_unreachable_directory_is_tolerated_and_then_refuses |
| R27 | A Tines ES256 signing credential cannot be host-restricted, because Tines refuses a credential in a cryptographic function while domain restriction is enabled; any story in the team holding it can produce a valid attestation, and the checkpoint's own report secret is readable in the same team's event data for the life of a grant. The recipe's answer is a dedicated Tines team and short event retention, an operator practice rather than a control the protocol enforces. | CHECKPOINT.md |
Assumptions
- The gateway process, its private configuration files and its database credential are trusted. Compromise of the host running them is outside this model.
- Owner provisioning is correct: canonical identities, approval assignments, inventory and incident sources describe the real environment.
- The PostgreSQL administrator and the owner role are trusted; migrations run only through the owner connection.
- Approvers and administrators are the people their credentials were issued to. Credential issuance happens outside the gateway.
- In a deployment, TLS termination and origin configuration are set up as the operations guide requires; the secure cookie and HSTS behavior depend on it.
- Provider origins are HTTPS with a certificate the gateway verifies; cleartext is accepted only for a loopback IP address and there is no opt-in for private networks (#183).
Out of scope
- Denial of service against the host, network or PostgreSQL from outside the application.
- Vulnerabilities in the customer's agent platform, identity provider or SOAR system.
- Physical or hypervisor-level attacks.
- Social engineering of approvers outside the approval page.
Maintaining this document
Update this document in the same PR whenever a change adds an HTTP route, MCP tool, human page, CLI command or monitoring route; adds a connector or external integration; adds a migration that touches authority tables; or closes an item marked Open or Partial. Keep threat and risk identifiers stable so issues and reviews can cite them. When an acceptance scenario is added, add its identifier to the row it evidences. A change that weakens a Verified row needs the failing test first, as the development guide requires for security behavior.
tests/test_threat_model_references.py checks what this section asks for, so a stale citation fails a run instead of waiting for a reviewer to click it: every cited test file exists and every cited node is defined in it, every acceptance identifier is a row or a group in the acceptance inventory, every document link resolves, every "see Rscripts/threat_model_index.py --next-ids, so the helper an implementing pull request uses and the check it has to pass read the table the same way (#269).
Take a new row's id from that helper rather than from the table, and take it again before merging. The helper answers with the highest id a boundary has used plus one, so two branches open at the same time will either choose the same number or leave one unused, the way two Alembic revisions collide on the same head. That is how B4 came to have no T4.8 and how three rows of B6 were numbered a second time. Because identifiers stay stable once published, the repair moved the later rows to T6.15, T6.16 and T6.17 rather than resequencing the boundary, and T4.8 stays unused for good. The check requires each boundary's ids to be unique and ascending rather than contiguous, and the helper names every unused and duplicated id, so a reader can tell a deliberate gap from a mistake.