Skip to main content

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:

StatusMeaning
VerifiedAn acceptance scenario or named test exercises the mitigation on real PostgreSQL, real Chromium or the real Cedar engine.
PartialA control exists, but a documented gap remains.
OpenNo control yet; an issue tracks the work.
AcceptedA 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

ActorMayMust notSource
Protected agentPropose 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 callerPropose 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 pointCall 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 approverSign 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
ADMINView 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 toolingWrite 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 providerAccept 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
PostgreSQLHold 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 sourcePost 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 receiverReceive 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
CIRun 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

IDBoundaryWhat must hold
B1Protected 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.
B2Gateway to PostgreSQLTrusted code sets transaction-local tenant context; runtime privileges are restricted; every consequential write commits atomically under the tenant fence.
B3Gateway to protected providerOnly a configured account and frozen command can dispatch; only authenticated, command-bound evidence can settle.
B4Human browser to human surfaceA current session, exact origin, CSRF token, signed state, assigned scope and current role are all required for a decision.
B5Owner tooling and ADMIN to authority dataRegistry, policy and trusted facts change only through owner or audited ADMIN paths.
B6Monitoring sources and receiversSource-reported observations stay outside execution authority; destinations are operator-configured.
B7Live system to replay and exportExport needs current ADMIN authority; replay has no live capability.
B8Build, dependencies and CILocked, hash-pinned environments; evidence bound to the tested commit.
B9Gate caller to gateway, and its vendor beyond itPlacement, 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.
B10Control point to hook surface, and the correlation table behind itThe 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.
B11Console to model providerThe 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

IDSTRIDEThreatMitigationEvidenceStatus
T1.1SForged or guessed agent credentialOpaque 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_opaqueVerified
T1.2SMCP client metadata or a cached session used as identityStateless transport; every tools/call authenticates; metadata never establishes identity (surfaces/mcp.py).M; tests/test_mcp_surface.py, tests/test_identity_surfaces.pyVerified
T1.3TCaller claims replace trusted facts such as tier, incident or environmentClaims 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_entitiesVerified
T1.4TUnknown operation, passthrough arguments, multiple or embedded targetsStrict 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_disclosureVerified
T1.5EAgent supplies an approval flag or approval identifierNo request schema accepts approval evidence; approvals are server-side rows consumed inside the execution claim (policy/approval.py, execution/).C, G, RVerified
T1.6EAgent calls human, administrative or reconciliation routesRejected 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_wereVerified
T1.7TIdempotency key replayed with a changed payload, or raced across adaptersSame-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_claimVerified
T1.8IReading another tenant's or another agent's executionStatus requires the owning agent; foreign identifiers and bad identities return opaque responses.S; opaque status test aboveVerified
T1.9IEngine, provider or database diagnostics leak through errorsUnexpected exceptions map to a generic 503 (app.py); Cedar and provider diagnostics are never echoed.N; tests/test_cedar.pyVerified
T1.10DRequest flood exhausts admissionA 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_nothingVerified
T1.11RFailed authentication leaves no durable recordEvery 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.12SGuessed, replayed or brute-forced sandbox invitation code on the public /onboard pageA 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.pyVerified
T1.13TAn agent retries a completed action under a new idempotency key and acts twice, consuming impact a second timeA 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_intentVerified

| 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

IDSTRIDEThreatMitigationEvidenceStatus
T2.1ETenant data reached across tenantsTransaction-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_objectsVerified, see R1
T2.2TRuntime 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.pyVerified
T2.3TBudget, approval or target-claim racesOne 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_capacityVerified
T2.4TAudit history truncated or alteredPer-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.pyVerified, see R2
T2.5DLong transactions or lock waits starve admissionStatement 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.mdAccepted
T2.6ISecrets at rest in the gateway databaseOnly token, session and CSRF hashes are stored; provider credentials never enter the gateway database.SURFACES.mdVerified
T2.7TA 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 budgetEvery 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_roomVerified

| 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

IDSTRIDEThreatMitigationEvidenceStatus
T3.1SAgent-chosen destination, server-side request forgery or passthroughFixed 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.pyVerified
T3.2SForged or replayed terminal evidenceCommand-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_effectVerified, see R3
T3.3TTarget changed between read and mutationThe provider checks the frozen expected version atomically at mutation.O, U; tests/test_protected_path.py::test_provider_version_changes_after_gateway_commit_fail_atomicallyVerified
T3.4TRetry or refund after an uncertain outcomeA 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_dispatchVerified
T3.5EAgent reads provider dispatch or control credentials or evidence keysProviders run as separate authenticated processes; configuration is private files; startup rejects credentials or keys shared across provider families.K; tests/test_surface_configuration.pyVerified, see R4
T3.6DSlow or hung providerOne send attempt with a bounded timeout; uncertainty is retained rather than retried.IVerified
T3.7REvidence key loss or rotationKeyed 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.8RUnsettled operations hold claims and budgets until a person noticesexecbound 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

IDSTRIDEThreatMitigationEvidenceStatus
T4.1SSession forgery or theftOpaque 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.pyVerified
T4.2TCross-site or forged form submissionExact 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_approveVerified
T4.3EApproval outside the assigned scope, by an ADMIN, or against a changed planServer-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_approvalVerified
T4.4IAgent or provider strings executed in the browserThe 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.pyVerified
T4.5DLogin brute forceSign-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_passwordVerified
T4.6DOne unreadable row fails the whole tenant listingA frozen plan naming a contract absent from the runtime catalog renders an escaped label instead of a 503.#19, PR #69Partial, in review
T4.7EA single approver suffices for a high-risk actionBundle 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.9IA notification leaks a secret or an untrusted string, or its link decides an approvalPayloads 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.pyVerified
T4.10IA newly issued secret is stored, logged or shown againThe 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.pyVerified
T4.11EA person ends sessions that are not theirs, or a stolen session ends the owner's other sessionsThe 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.pyVerified
T4.12IThe live board discloses more of a tenant than the reader's authority allows, or a request string reaches the page as markupThe 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.pyVerified
T4.13TAn ADMIN's rule text, or a note on a rule, reaches the compiled policy or is rendered as markupConditions 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_componentVerified
T4.14TA 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 answerEvery 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_textVerified
T4.15EThe 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 policyThe 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.pyVerified
T4.16SA redirect kept for an old bookmark sends a reader somewhere a request choseEach 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.pyVerified
T4.17TThe one console control that posts a form is forged, or its token is left where a script can read itSigning 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_cookiesVerified

| T4.18 | D | A stranger mines the public signup page: enumerating which addresses already hold a sandbox, draining the slot pool, or flooding the queue from behind the proxy every visitor shares | Every submission answers one page with one status, for an allocation, 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 sandboxes 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, a clipped honeypot field recorded as a refusal, and the slot pool as the hard ceiling. 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 |

B5: Owner tooling, ADMIN and policy

IDSTRIDEThreatMitigationEvidenceStatus
T5.1EAn agent or approver creates, edits or activates a bundleADMIN-only lifecycle inside the tenant fence, audited; content is immutable and digest-bound (policy/store.py).tests/test_policy_store.pyVerified
T5.2TMalformed policy, schema mismatch or engine error changes a decisionStructured 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.pyVerified
T5.3TA writer allowlist admits a label the agent can writeDocumented misconfiguration; scenario T protection depends on ADMIN naming only trusted writer labels.CC6; POLICY.mdAccepted, see R5
T5.4TWrong 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.pyVerified for facts and incidents; Accepted for canonical mapping, see R10
T5.5EA Cedar binding change silently alters decisionsPinned cedarpy; the adapter rejects incomplete diagnostics; a guard test fails with a review message if the private entry points change.#38, PR #68Partial, in review, see R6
T5.6RAdministrative actions without attributionEvery lifecycle change, reconciliation and acknowledgement is audited with current authority rechecked.tests/test_audit.py, replay export testsVerified
T5.7EAgents keep acting during an incident because nothing halts one tenantADMIN 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.8EAn administrator weakens a tenant to observe-only, or an observed result is mistaken for authorizationThe 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.9TA compromised or faulty adapter feeds false contextThe 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, CP10Partial, see R10
T5.10EA web bug or a captured ADMIN session mints administrators or widens the registryThe 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.pyVerified
T5.11EAn 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 haveGroups 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_locationsPartial
T5.12EA departed or compromised person keeps a credential or a session; a console user deprovisions an administrator or themselvesDeactivation 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.pyVerified
T5.13SA wrong or forged link attributes a principal's executions to an agent it is not, or the runtime role moves the linkA 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.pyVerified

| T5.14 | E | A sandbox outlives the fourteen days it was granted, or someone who is not an administrator of it extends, revives or converts it | The end is a property of the row, kept by a trigger at the claim plus fourteen days for every writer, and moved only by extended_until, deactivated_at and converted_at. sandbox_expire refuses a slot that is unclaimed, already deactivated, converted or not yet due, so a slow or repeated cron writes one deactivation and one tenant.expired and no more; sandbox_notice refuses a second notice. sandbox-extend and sandbox-convert run over the owner connection, take at most thirty days a call, refuse a sandbox deactivated more than ninety days ago, require an active ADMIN principal of that tenant as the actor, and append tenant.extended or tenant.converted to that tenant's own chain in the same transaction. Deactivation is an inactive tenant, so sessions, credentials and agent requests fail at their next check as for any inactive tenant (migrations/versions/0039_signups.py, signups.py). | tests/test_signups.py (SU7, SU7b, SU7c, SU7d) | 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 |

B6: Monitoring sources and notification receivers

IDSTRIDEThreatMitigationEvidenceStatus
T6.1SForged or replayed story eventsSource 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.mdVerified
T6.2EAn observation grants execution authority or settles a provider effectSeparate monitoring domain: no execution mutation, approval, provider call or impact change.tests/test_monitoring_store.py, tests/test_monitoring_worker.pyVerified
T6.3DIntake floodPer-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.pyVerified
T6.4SA notification destination chosen by untrusted content (server-side request forgery)Only a loopback development receiver exists; operator-configured, validated destinations are not built.#31Open
T6.5RSource token rotation or revocationADMIN-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.6DCooperative visibility mistaken for enforcement; total outage cannot self-alertDocumentation states that monitoring cannot prevent execution and that a heartbeat proves reporting freshness only; an independent uptime check is required.#29, #31Accepted, see R7
T6.7RMissing telemetry, a silent source or an implausible external clock goes unnoticedReceipt-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.8TA configured detection silently stops applying, or a profile change hides why a finding existsProfiles 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.9IA vendor management key in the gateway database, or in a log or page, widens a database or backup exposure into platform accessThe 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.pyVerified
T6.10TCollection mutates the connected platform, follows a redirect or a proxy to another destination, or is used as a general request toolThe 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.pyVerified
T6.11EOne credential is both the collector and the executor, so a read-only collection secret can disable an accountCollection 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.pyVerified against fixtures; the live probe is separately authorized
T6.12TA directory response redirects the collector to a destination or a collection it did not ask forExactly 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.pyVerified
T6.13IA directory read carries a person's name, address or a credential into the gatewayEvery 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.pyVerified
T6.14RInferred reach is mistaken for proof, or a governed route is mistaken for a closed oneA 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.pyVerified
T6.15ICollected responses carry credential values, action options, audit inputs and outputs, model text, email addresses or request addresses into storage or a pageField 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.pyVerified
T6.16TA crash, a page boundary, a rate limit or a retention gap silently loses or duplicates collected evidence, or a response moves rows between tenantsA 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.pyVerified
T6.17RDiscovered activity is mistaken for enforcement, or an unrecognized action type or operation name silently clears a story or a changeCollected 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.pyVerified
T6.18SA chat webhook URL or an SMTP password is exposed, or a delivered notification carries authorityDestinations 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_secretVerified
T6.19IA secret-bearing message reaches a shared channel, or a resent code collapses into the message whose code no longer worksThe 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_invitationVerified

B7: Live system to replay and export

IDSTRIDEThreatMitigationEvidenceStatus
T7.1EReplay executes a provider or mutates live stateFile-only entry point; subprocess tripwires forbid database, provider, socket and dotenv access.J; tests/test_replay_isolation.pyVerified
T7.2IAn export leaks secrets or another tenant's historyCurrent 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.pyVerified
T7.3TTampered history, head or bundle contentChain, retained head, bundle hashes and plan and approval bindings are verified offline.tests/test_replay_verify.pyVerified
T7.4ILogs or an event export leak a secret or an untrusted string, or the export skips or reorders historyLogs 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.pyVerified
T7.5TA one-approver consumption is presented as satisfying a two-approver requirementApproval 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_quorumVerified

B8: Build, dependencies and CI

IDSTRIDEThreatMitigationEvidenceStatus
T8.1TVulnerable or substituted dependencyLocked, 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 #67Partial, in review
T8.2IA credential committed to the repositoryEnvironment 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 #67Partial, in review
T8.3TEvidence not tied to the tested sourceThe CI manifest binds artifacts and collected test nodes to the exact commit and lock hashes.VALIDATION.mdVerified
T8.4TParsing or canonicalization bugs under unexpected inputExample-based tests only; property-based and fuzz tests are planned.#49Open
T8.5IStatic analysis limited to open-source toolingBandit 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.#46Accepted
T8.6TThe runtime image drifts from the locked environment, runs as root or carries private filesBuilt 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.mdVerified
T8.7TA vulnerable package in the image's base layer goes unnoticed because only Python dependencies are auditedThe 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.pyVerified
T8.8TA forbidden rendering, transport or TLS flag enters the tree without a reviewer noticingtests/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.mdVerified

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.

IDSTRIDEThreatMitigationEvidenceStatus
T9.1EA caller selects Gate for itself, names an executor, declares a precondition class or claims a tierAll 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.pyVerified
T9.2EA grant is used as authority to execute, approve, dispatch or reconcile through ExecBoundAdmission 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.pyVerified
T9.3SA forged, replayed or cross-execution report settles a grantOne 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.pyVerified
T9.4SA forged attestation, or a registered key nobody holds the private half ofEd25519 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.pyVerified
T9.5EA stolen executor key settles grants it was holding, refunding reservations and freeing targetsA 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.pyVerified, see R13
T9.6TAn executor frees its own target or refunds its own budget by what it saysA 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.pyVerified
T9.7TA report settles an operation ExecBound dispatched, or a provider journal settles a grantThe 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.pyVerified
T9.8DAn executor takes grants and never reports, or spams refused reportsOutstanding 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.pyVerified, see R14
T9.9RAn executor acts and never asks, ignores a denial, or acts twice on one grantNothing 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 §5Accepted, see R7
T9.10TThe vendor's target changes between the grant and the executor's actionThe 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.pyAccepted
T9.11SAn operator declares a precondition class the vendor does not offerNothing 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.mdAccepted, see R15
T9.12EA compromised Gate caller's credential also dispatches through ExecBoundA 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.mdOpen, see R16
T9.13RAn administrator settles a grant its executor is still authorized to act onDeliberately 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.pyAccepted

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.

IDSTRIDEThreatMitigationEvidenceStatus
T10.1EA hook field selects the mapping, the action, the target, the incident or the tierThe 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, AL34Verified
T10.2TAn input the map does not model changes what the vendor does behind a decision made without itinputs 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.AL9Verified, see R18
T10.3EThe extension permits something the kernel did notIt 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, AL22Verified
T10.4SA duplicate or racing delivery gets a second OK, or a lost one strands a grant the platform is told to act onThe 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, AL4Verified
T10.5IA reader of the correlation table settles a tenant's row or frees its targetDeliberately 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.AL18Accepted, see R17
T10.6RThe platform reports a failure the tenant cannot answerA 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, AL33Accepted, see R19, R20
T10.7DAn ungoverned toolkit's traffic drives the tenant's audit headAn 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.AL8Accepted, see R18
T10.8DThe deployment under load stops answering in a shape the platform can act onThe 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, AL32Verified
T10.9IThird-party identity material or a vendor string reaches ExecBound's storage, logs or the agent's contextauthorization[].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, AL14Verified
T10.10EA second logic extension changes the inputs after this one decided, or a denied call runs anywayNeither 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, AL17Accepted
T10.11SAn operator declares a precondition class for a binding this carrier servesNothing 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.mdAccepted, see R15
T10.12SA caller reaches policy through a fact the surface does not evaluateThe 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, AL34Verified

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.

IDSTRIDEThreatMitigationEvidenceStatus
T11.1EThe assistant reads or changes something the asking person could notEvery 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_askedVerified
T11.2TText an agent, a provider or a tenant user wrote instructs the model, or reaches the console as markupEvery 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_charactersVerified
T11.3IA credential, token, code or key reaches the modelTool 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_leavesVerified
T11.4EAn explanation tells an approver what to decideThe 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_elseVerified
T11.5IOne person reads another's questions or answers, or the audit chain carries eitherTurn 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_textVerified
T11.6DA turn runs unbounded, or a tenant's use runs up the provider billEight 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_secondsVerified
T11.7IThe provider's key is written to the database, a log or a diagnostic, or the feature exists where nobody chose itThe 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_reasonVerified
T11.8EAn assistant address is reachable where no model was configured, or for a tenant that never turned it onThe 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_elseVerified
T11.9IOne person reads another's turn through the routes, or an agent credential reaches oneEvery 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_credentialVerified
T11.10DA stream outlives its turn, or a person watching one holds a connection open indefinitelyThe 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_endingVerified
T11.11RA packaged help text tells a person something the product does not doEach 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_saysVerified
T11.12EA drafted rule reaches policy without a person, or widens what a bundle governsThe 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_intoVerified
T11.13TA model's answer reaches the form as something other than a valid ruleThe 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_writtenVerified
T11.14IA proposal is drafted for a draft the person cannot read, or by someone who cannot edit rulesThe 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_elseVerified

Residual risks

IDRiskWhere stated
R1Tenant 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
R2A 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
R3Provider HMACs establish authenticity between trusted components sharing a key, not third-party nonrepudiation.ARCHITECTURE.md; REPLAY.md
R4The 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
R5A 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
R6The 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
R7External 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
R8Failed 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
R9Key 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
R10Authority 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
R11One 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
R12Only controlled mock providers exist. Real CrowdStrike and Entra semantics, including their precondition and journal guarantees, are unverified.IDENTITY_PROVIDER.md; VALIDATION.md
R13Cutting 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
R14The 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
R15A 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
R16A 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
R17The 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
R18An 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
R19A 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
R20The 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
R21No 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
R22A 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 |

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 R" names a residual risk that exists, each boundary table carries the six columns, each status is in the vocabulary above, and no Verified row leaves its evidence column empty. It reads the document through the parser that also feeds scripts/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.