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 workspace, draining capacity, or flooding the queue from behind the proxy every visitor shares | Every submission answers one page with one status, for an admission, a place on the waitlist and each of the eight refusals alike, so nothing on the page distinguishes them; refusals reach the structured log and the operator's own list, never the browser. The controls are counts taken inside signup_request against the snapshot the row is written in, not anything the browser is asked to prove: one request per address per thirty days, at most five live workspaces per domain with free-mail domains exempted and held to their own lower daily cap, twenty an hour and a hundred a day overall, a vendored disposable-domain snapshot that is never fetched at runtime, and a clipped honeypot field recorded as a refusal. Since #243 (23 September 2026), open mode's hard ceiling is signups.capacity (the count of PROVISIONED requests in the operator tenant), not a seeded slot pool: a submission is admitted against it here and the count is retaken, authoritatively, under the operator's fence when the link is completed (T4.34 below); the pool remains the ceiling only for founder-minted invitations and for invitation messages a request already queued before that release. The page itself is CSRF-cookied like /login, refuses a query string, a bearer header, a wrong Origin or an extra field before the database, and keeps the per-peer window, which behind a proxy is a flood guard on the process rather than a limit on any one caller (surfaces/onboarding_pages.py, signups.py). No CAPTCHA and no proof-of-work: both need scripts these pages forbid. | tests/test_signups.py (SU5, SU5b, SU5c, SU5d, SU14c), tests/test_browser_accessibility.py | Verified | | T4.19 | I | A prospect's email address escapes the operator's own list into an audit event, a log line, a page or a report | The address is written by a fenced function into signup_requests in the operator tenant and read back only by signups --list over the owner connection. Structured logs carry the request id, the domain and whether it is free mail, never the address; tenant.claimed, tenant.expired, tenant.extended and tenant.converted carry no address; the signup and confirmation pages render no submitted value at all; the funnel report is counts, domains and durations. Retention nulls the address thirty days after a refusal or an expiry and ninety after a claim, leaving the domain and the timestamps, and the message a prospect receives is addressed from the row rather than from anything the outbox holds (signups.py, logging_schema.py). | tests/test_signups.py (SU8, SU10, SU13) | Verified |

| T4.20 | S, D | Password guessing, credential stuffing or account enumeration at /login | A password is never stored: only a salted hashlib.scrypt hash at n=2^15, r=8, p=1, with the algorithm and all four parameters in their own columns beside it, so a row written under older parameters keeps verifying and raising them is a write. Attempts are refused per peer address and per address digest before any lookup. A wrong address and a wrong password answer 401 with one sentence, and cost the same: when the address belongs to nobody the hash runs anyway against a decoy record built at import under the current parameters. The private reason (UNKNOWN, MISMATCH, INACTIVE) reaches record_failure and the structured log alone, attributed to the person where there is one. Verification runs on a worker thread under a concurrency bound, so a flood queues rather than taking the process (passwords.py, surfaces/sign_in_pages.py). | Console sign-in; tests/test_passwords.py, tests/test_sign_in_store.py, tests/test_sign_in_surface.py, tests/test_browser_sign_in.py::test_a_wrong_password_and_an_unknown_address_say_the_same_thing | Verified | | T4.21 | S | A forged, replayed or misdirected provider assertion signs the wrong person in | Each provider's endpoints are pinned in its own module and only the client id, the client secret and the redirect URI are configured; the redirect URI must equal this deployment's own origin plus that provider's callback path or the server refuses to start. Every flow carries PKCE S256, a random state and a random nonce in one __Host-, HttpOnly, SameSite=Lax cookie deleted on first use, so a replayed callback has nothing to match and a state mismatch is refused. Google's ID token is verified against Google's JWKS with alg settled before any key is fetched, one refetch on an unknown kid and then a refusal, and iss, aud, azp, exp, iat and nonce all checked; email_verified must be true. GitHub has no assertion to verify, so the account comes from two authenticated calls and the only address that may find a person is the one GitHub marks both primary and verified; the access token is user:email only and is discarded after the lookup. An account matching no person is refused and creates nothing, the subject is bound once and identifies the person afterwards, and a subject already bound to somebody else is refused rather than moved (oidc.py, github.py, signin.py, migration 0040_sign_in). | #177; tests/test_oidc.py, tests/test_github_sign_in.py, tests/test_sign_in_surface.py, tests/test_sign_in_store.py::test_a_subject_bound_to_one_person_is_never_moved_to_another | Verified | | T4.22 | E, I | A set-password code reaches the wrong person, survives its use, or leaves the previous holder signed in | A code is ebp1.<tenant>.<reset>.<secret>, stored only as its SHA-256, single use, expiring between an hour and fourteen days, revoked by five wrong secrets and by the minting of the next one, and never present in a URL: what is handed over is the address to open and the code to type. An expired, redeemed, revoked, superseded and unknown code all fail identically. Redeeming revokes every live credential and unrevoked session of that person in the same statement sequence and returns their identifiers so the caller audits each one in the same transaction. The ebp1. prefix is part of the shared secret definition, so a rendered page or an export carrying a code fails the guard (migrations/versions/0040_sign_in.py, passwords.py, secrets.py). | #265; tests/test_sign_in_store.py, tests/test_secrets.py, tests/test_browser_sign_in.py::test_a_code_works_once_and_the_second_person_to_try_it_is_refused | Verified | | T4.23 | I | Resolving an address before a tenant is known reads more than that one address | The login form takes an address and no tenant, so the read happens outside the fence — through two single-row RLS policies rather than a function with table-wide authority. A SELECT on principals is permitted only for the row whose own email equals execbound.sign_in_email, and on principal_identities only for the row whose own provider and subject equal execbound.sign_in_subject. Setting a marker therefore answers about the address or the account the caller already typed and about nothing else: no listing, no enumeration, no other tenant's row. Everything after the resolution is an ordinary fenced read (migrations/versions/0040_sign_in.py, passwords.py). | tests/test_sign_in_store.py::test_the_marker_reveals_one_row_and_never_a_listing, tests/test_sign_in_store.py::test_the_subject_marker_reveals_one_row_and_never_a_listing | Verified | | T4.24 | T | A form submission that starts a provider flow is forged, or the redirect it follows is blocked or pointed elsewhere | Starting a flow is a POST with the login CSRF cookie compared with the field and the exact Origin check, like every other mutation, rather than a bare link. form-action governs the redirect a form submission follows, so the pages' policy names each configured provider's authorization host — pinned in that provider's module, present only because a block named the provider, and absent entirely from a deployment with none. Nothing a request sends adds a host (surfaces/console.py, app.py). | tests/test_sign_in_surface.py::test_a_configured_provider_is_named_in_form_action_and_nothing_else_is, tests/test_browser_sign_in.py::test_a_configured_provider_signs_a_matched_person_in | Verified | | T4.25 | E | A person sets the session's current space to one they do not belong to, or a stale column grants a read after they were removed | session_set_space is a SECURITY DEFINER function rather than a runtime UPDATE grant, and it verifies the space against space_members (or tenant administration) in the database, so "a session's current space is one of the person's spaces" is a database invariant. It is belt and braces: every read recomputes the visible set inside the fence and narrows the current space to it, so a person removed between two page loads falls back to "All spaces" at once and a forged or stale column grants nothing (migrations/versions/0041_spaces.py, spaces.py). | SP4; tests/test_spaces.py, tests/test_console_api_spaces.py | Verified | | T4.26 | I | A record in another space is opened by its identifier | Membership is the authority and the current space is only a view: a record in a space the person does not belong to is 404 whatever the session holds, and one in another of their own spaces is answered and names its space so the shell can follow it. There is no ?space= anywhere, so editing a URL cannot ask for another space's rows and a shared link is read in the reader's own space (spaces.Visibility.allows, surfaces/agents_pages.py, surfaces/space_pages.py). | SP1, SP11; tests/test_console_api_spaces.py | Verified | | T4.27 | T | A decision lands while its space is being archived | The archive takes the tenant fence before it calls the definer function, and decide_approval rechecks the space inside the kernel's own transaction under that same fence, so the two are totally ordered: whichever takes the fence first commits and the other is refused SPACE_ARCHIVED. Exactly one of the two commits, under two connections on real PostgreSQL (surfaces/space_pages.py, execution/approvals.py). | SP9; tests/test_spaces_kernel.py | Verified | | T4.28 | S, T | A tenant's registered directory returns a forged, replayed or misdirected assertion, or a browser is walked into somebody else's flow | One redirect URI serves every tenant and is checked against this deployment's own origin, so an authorization code can only ever be delivered here. Discovery is fetched once at registration and validated hard — the document's own issuer must equal the registered one, and the authorization, token and JWKS endpoints must be https on the issuer's own host — and the three endpoints are then stored on the row, so a document that changes later cannot move a live flow. Every flow carries PKCE S256, a random state and a random nonce in one __Host-, HttpOnly, SameSite=Lax cookie deleted on first use; the cookie also names the provider and either an invitation or a self-linking principal, never both, and carries a MAC over all six values. The MAC is what a session cookie cannot be here: the callback is a cross-site redirect back, so a SameSite=Strict session is not sent with it, and a cookie a caller could write would let them name somebody else's principal and bind their own directory account to it. Every key in the ring is tried, so a rotation strands no flow already in the air. The ID token is verified here with alg settled before any key is fetched and refused unless RS256 or PS256, one refetch on an unknown kid and then a refusal, and iss, aud, azp, exp, iat, nonce and tid all checked; an Entra issuer must name one directory, so common is refused at registration and an assertion from another Microsoft tenant is refused at sign-in. RFC 9207's iss is compared when the directory sends it. A subject with no link is refused and creates nothing (directory.py, surfaces/directory_pages.py, migration 0043_directory_sign_in). | #177; tests/test_directory.py::test_every_claim_this_server_checks_has_its_own_refusal, tests/test_directory.py::test_an_unknown_key_is_refetched_once_and_then_refused, tests/test_directory_surface.py::test_a_replayed_callback_has_nothing_to_match, tests/test_directory_surface.py::test_a_subject_nobody_linked_is_refused_and_creates_nothing | Verified | | T4.29 | E, I | An invitation code reaches the wrong person, survives its use, or binds one account to two people | A code is ebv1.<tenant>.<invitation>.<secret>, stored only as its SHA-256, single use, expiring between an hour and fourteen days, revoked by five wrong secrets and by the minting of the next one, and never present in a URL, a log or an audit payload: what is handed over is /invite to open and the code to type. An expired, redeemed, revoked, superseded and unknown code all fail with one sentence. The sign-in that follows binds the subject to the invited person and stamps the invitation redeemed in one statement sequence under a row lock, so two browsers holding one code produce exactly one link and one session. The ebv1. prefix is part of the shared secret definition, so a rendered page or an export carrying a code fails the guard (migrations/versions/0043_directory_sign_in.py, directory_store.py, secrets.py). | #241, #265; tests/test_directory_store.py::test_only_one_of_two_concurrent_redemptions_wins, tests/test_directory_store.py::test_five_wrong_secrets_revoke_an_invitation, tests/test_directory_store.py::test_a_revoked_expired_and_unknown_code_all_fail_the_same_way | Verified | | T4.30 | I | The sign-in page tells a stranger which addresses exist and which directory they belong to | /login takes an address and no tenant and asks the database nothing about one, so a directory button cannot be chosen by anything a stranger types. It is offered instead to a browser carrying __Host-eb_directory, a hint cookie set only after a sign-in through that provider succeeded, holding a provider id and no secret; a browser that has never been through one is shown the password form and nothing else, and a cookie naming a provider that is not enabled is ignored. The tenant's sign-in address is distributable on purpose: it sends a browser to the customer's own login page, and a subject with no link is refused on return (surfaces/human.py, surfaces/directory_pages.py). | tests/test_directory_surface.py::test_a_browser_that_has_never_been_through_a_directory_is_told_nothing, tests/test_directory_surface.py::test_the_remembered_provider_puts_one_button_on_the_sign_in_page | Verified | | T4.31 | S | A Community signup verification link is leaked, forwarded, sits in a shared mailbox, or is opened by a mail scanner or link-prefetcher before the person does | The secret lives only in the URL fragment, which GET /signup/verify never reads server-side and signup-verify.js strips from the address bar and history before anything else can act on it; the page itself is static and side-effect free, so any number of opens by a scanner or a prefetcher changes nothing. The script sends the exchange by itself when the fragment is a valid token, so a scanner that runs scripts also posts it; the exchange only reads what the link is for, a correct token is not a miss, and nothing exists until the create form is sent with a password and a workspace name. The exchange is POST-only, and the token it posts is checked against verify_token_hash inside signup_verify_check/community_provision, which count a wrong digest toward a five-miss lockout (verify_failures) rather than answering differently for a guess and a hit. The link expires thirty minutes after it is minted (verify_expires_at, signup_verify_rekey) and a resend rotates verify_generation and the digest, so a previously leaked link dies the moment a fresh one is sent. Every signup page is Cache-Control: no-store, and the site's default Referrer-Policy: same-origin means a same-origin navigation carries no cross-origin Referer and the secret is never in a query string to be logged or cached along the way (community_signup.py, signup_pages.py, migrations/versions/0055_community_signup.py, surfaces/assets/signup-verify.js). Closing signups does not cancel a link already queued for delivery: drain still sends it, and its completion now refuses under closed mode (SIGNUPS_CLOSED, checked in provision before any write or password hash) rather than provisioning, leaving the request VERIFYING with its link untouched until the operator reopens signups or the link's own thirty minutes pass. | VS3, VS9, VS12, VS18, VS20, VS20b, VS32; tests/test_community_signup.py, tests/test_browser_signup.py | Verified | | T4.32 | T | Two browsers, or one browser retried after a lost response, complete one verification link and end up with two workspaces or two accounts | community_provision takes a row lock (FOR UPDATE) on the signup_requests row before it reads or writes anything else, inside the operator tenant's own fence, so two concurrent completions of the same link serialize on that lock; the second to run reads state = 'PROVISIONED' and returns ALREADY with the first's tenant id rather than provisioning again. Two different requests sharing one account are still one account: user_accounts.email is unique, and a new account's insert or its email-holder check inside the same transaction turns a unique violation into ACCOUNT_EXISTS rather than a second row. The consumed Community allocation is a unique index on community_allocations keyed to the account, so a second self-created workspace for the same account fails closed the same way. Nothing here mints a session for a retried completion: an ALREADY outcome never carries the new account's version at all, and sign_in_new_account refuses to mint a credential without one, so only the one call that actually created the account signs anybody in: a losing racer or a later replay of the same form gets the "Your workspace is ready. Sign in." page instead. | VS8, VS10; tests/test_community_signup.py | Verified | | T4.33 | S | An email link is used to attach a Community workspace to, or take over, an account other than the one the address names | A link never creates a password for an address an account or an active legacy principal already holds (ACCOUNT_EXISTS), and never accepts one from a signed-out browser for that address either: inspect's tri-state answer sends existing and legacy addresses to ordinary sign-in first, including whatever MFA that account requires, before /signup/workspace is reachable at all. That route then re-authenticates the session and account cookie inside the provisioning transaction (_prove_account) and community_provision checks the signed-in account's own email against the request's (v_account.email <> v_request.email is ACCOUNT_MISMATCH), so a session for a different address than the one the link names is refused rather than silently attached. An active legacy holder who appears between inspection and provisioning, or commits mid-transaction on the address's own unique index, is caught by the same predicate applied a second time inside community_provision; only an inactive legacy holder, who cannot sign in to contest it, lets the link make a new account. A session that still needs MFA is refused the same way: the exchange treats it as signed out and sends it to sign in first, /signup/workspace behind it never reaches provision at all, and _prove_account calls check_session without allow_pending_mfa, so calling provision directly with a pending session raises ACCOUNT_MISMATCH rather than completing. The account and session proof takes no fence of its own on the session's own tenant, so a session revocation that commits between that proof and the account's own lock does not stop this provisioning; account suspension is still caught, because community_provision locks the account row FOR UPDATE and refuses it unless it is still active (NOT v_account.active); the account must also match the request's email. | VS11, VS12g; tests/test_community_signup.py | Verified | | T4.34 | D | A stranger exhausts admission capacity by submitting many addresses, or floods the verification/resend path once past the per-submission caps | The per-address, per-domain and global caps (T4.18 above) now count VERIFYING and PROVISIONED requests, so a workspace that exists counts against its domain's cap however it was created. Capacity itself is counted twice: advisory when a request is submitted, and authoritative (a fresh count(*) FROM signup_requests WHERE state = 'PROVISIONED' under the operator tenant's own fence) the instant a link is completed, so two links racing the last unit of capacity cannot both provision (T4.32's lock already serializes it) and a submission that looked admitted can still be waitlisted at completion with no partial workspace left behind. Resend is bounded independently of submission: signup_verify_queue refuses a resend at five sends or with a message already in flight or delivered in the last two minutes (RESEND_LIMIT), and a request bounced back to QUEUED by a capacity miss starts a fresh five-send cycle rather than inheriting an exhausted one, so a flood cannot be laundered through repeated waitlist/re-admission cycles either. | VS4, VS4b, VS5, VS10; tests/test_community_signup.py | Verified |

| T4.35 | S | A teammate invitation link at /join is leaked, forwarded, sits in a shared mailbox, or is opened by a mail scanner or link-prefetcher before the person does | The secret lives only in the URL fragment; GET /join is static and side-effect free, so any number of opens changes nothing, and invite.js moves the fragment into the form and out of the address bar and history before anything else can act on it, the same recipe T4.31 gives /signup/verify. The exchange is POST-only and only reads what the link is for through inspect_invitation/workspace_invitation_check; a wrong digest counts toward a five-miss lockout (failures) rather than answering a guess and a hit differently, and the count commits even though the calling transaction that found the mismatch does not. The link expires seven days after it is minted regardless of resends, and every resend mints a fresh digest and link (workspace_invitation_rekey, generation incremented), so a previously leaked link dies the moment a fresh one is sent; a revoked invitation's link answers the same "not valid" page without ever saying which. Every invite page is Cache-Control: no-store, and the site's default Referrer-Policy: same-origin keeps the secret out of any Referer, exactly as T4.31 records for signup (invitations.py, surfaces/invite_pages.py, surfaces/assets/invite.js, migrations/versions/0059_workspace_invitations.py). | TI1c, TI3, TI11, TI16, TI20; tests/test_teammate_invitations.py, tests/test_browser_invitations.py | Verified | | T4.36 | S | A teammate invitation link is used to take over an existing account, or to sign somebody in as a person other than the address it names | The new-address path never creates a password for an address an account or an active legacy principal already holds: workspace_invitation_accept raises ACCOUNT_EXISTS under the same sign_in_email marker community_signup uses, checked once by accept_invitation before hash_password ever runs and rechecked by SQL itself inside the accepting transaction, so a holder who appears in between is still caught. An existing or legacy address is never accepted from a signed-out browser either: inspect_invitation's tri-state answer sends existing and legacy addresses to ordinary sign-in first. /join/confirm then re-authenticates the account cookie and the session inside the accepting transaction (_prove_account, the same recipe community_signup._prove_account uses), and the definer function checks the signed-in account's own email against the invitation's (v_account.email <> v_invitation.email is ACCOUNT_MISMATCH), so a session for a different address than the one the link names is refused rather than silently attached. A session still waiting for its MFA is treated as signed out by the exchange and sent to /mfa before /join/confirm is ever reached, so _prove_account's own check_session call, made with no allow_pending_mfa, is a second, independent refusal behind it (invitations.py, surfaces/invite_pages.py, migrations/versions/0059_workspace_invitations.py). | TI9, TI10, TI18; tests/test_teammate_invitations.py, tests/test_browser_invitations.py | Verified | | T4.37 | E | Acceptance proceeds after the inviter's authority to grant those roles has been narrowed or removed, or after the invitation itself was revoked | The inviter's authority is read at acceptance, not cached from when they clicked Invite: workspace_invitation_accept re-checks that the inviter is still an active principal holding access.manage in its own step 5, immediately before it calls access_change as that inviter, so a demotion or a deactivation between the two refuses the acceptance FORBIDDEN and creates nothing, on either the new-address or the existing-account path. access_change itself re-validates every Space named in the frozen grants at that same moment, so a Space archived after the invitation was sent is INVALID_SPACE rather than silently dropped or silently granted. Revocation takes the same invitation row lock a concurrent acceptance would take, so the row lock (not a flag checked once) decides which of the two commits, and revoking also fails the invitation's own pending message closed (REVOKED) so delivery cannot outrun the revoke (invitations.py, migrations/versions/0059_workspace_invitations.py, decision 4). | TI1d, TI1e, TI6, TI11, TI12, TI12b; tests/test_teammate_invitations.py | Verified | | T4.38 | I | The tenant-agnostic drain that reaches every workspace, including one no jobs token names, is used to read or send from a tenant the caller does not administer | workspace_invitation_due is the one place a cross-tenant read exists, and it exists only because migration 0059 grants a SELECT policy, owner_drain, TO CURRENT_USER — the exact role that owns every definer function in this file, never execbound_app. deliver_invitations calls nothing else across tenants: having listed a due pair, it leases, rekeys and sends inside that one message's own tenant fence, in its own transaction, so a message never crosses back out. The runtime role's ordinary reads stay fenced as every other table's do; workspace_invitation_due's own row carries a tenant id and a message id only, never an address, so even the cross-tenant list itself discloses nothing about who was invited. This is a new pattern for this codebase — every earlier tenant-agnostic pass (signups.py's sweep, the approval-notification and export drains) instead holds its own operator credential and walks a configured tenant list — and it is proven under a NOBYPASSRLS owner carrying the hosted database's own posture, not assumed from the local development owner's superuser bypass (migrations/versions/0059_workspace_invitations.py, invitations.py, decision 1). | TI1, TI1h, TI4; tests/test_teammate_invitations.py | Verified | | T4.39 | T | Two browsers accepting one link, one account accepting twice, an acceptance racing a revoke, or an acceptance racing a verified signup for the same new address, produce more than one committed outcome | workspace_invitation_accept takes a row lock (FOR UPDATE) on the invitation before it reads state, and workspace_invitation_revoke takes the same lock, so the two are totally ordered: whichever commits first decides and the loser reads back its outcome (ALREADY, or INVITATION_REVOKED against an already-REVOKED row) rather than racing to double-write it. A signed-in account's own acceptance locks the account row (lock_account) before the tenant fence, so two clicks by the same account serialize on it and the second finds ALREADY. A new address racing a verified Community signup for that same address both insert into user_accounts, whose email column is the one true arbiter: its unique index turns the loser's insert, or the loser's own pre-insert holder check inside the same transaction, into ACCOUNT_EXISTS rather than a second row, and neither request consumes the other's own intent — the signup stays VERIFYING or the invitation stays PENDING, whichever lost (migrations/versions/0059_workspace_invitations.py, invitations.py, decision 8). | TI13a, TI13b, TI13c, TI13d; tests/test_teammate_invitations.py | Verified |

B5: Owner tooling, ADMIN and policy​

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, someone who is not an administrator of it extends, revives or converts it, or an abusive sandbox cannot be ended before its own next boundary | The end is a property of the row, kept by a trigger at the claim plus fourteen days for every writer, and moved by extended_until, deactivated_at, converted_at and, since 0044_hosted_free, fell_back_at (T5.21 below). Day fourteen's default is now sandbox_fallback, which sets plan = 'free' and leaves the tenant active rather than ending it; sandbox_expire and sandbox_notice both refuse a slot that has already fallen back, so the two endings are mutually exclusive in both orders, and sandbox_notice refuses a second notice regardless. sandbox-extend runs over the owner connection, takes at most thirty days a call, revives a sandbox deactivated within the last ninety days, and, if the slot had fallen back, restores the full uncapped sandbox outright rather than merely moving its end date. sandbox-convert runs over the owner connection with no day bound, clears the expiry outright, and lands on team (uncapped, the default) or free (the fallback's own state) as named; it does not check how long a slot has been deactivated at any plan, which is the corrected reading of this row: that ninety-day reactivation window is sandbox-extend's own guard, over deactivated_at, and sandbox-convert's guard checks only claimed_at and converted_at, never deactivated_at, so converting a sandbox deactivated at any point in its past is reachable through this command and not through sandbox-extend. Both require an active ADMIN principal of that tenant as the actor and append tenant.extended, tenant.fell_back or tenant.converted to that tenant's own chain in the same transaction. sandbox-deactivate (T5.23 below) ends any claimed, unconverted sandbox at once regardless of fell_back_at and regardless of its end date, and refuses a converted tenant. Deactivation, by either path, is an inactive tenant, so sessions, credentials and agent requests fail at their next check as for any inactive tenant; a fallen-back tenant stays active and only its plan and cap change (migrations/versions/0039_signups.py, migrations/versions/0044_hosted_free.py, signups.py). | SU7, SU7b, SU7c, SU7d, HF10, HF17, HF18, HF20, HF21, HF24 to HF30, HF46; tests/test_signups.py, tests/test_hosted_free.py | Verified |

| T5.15 | E | A console administrator gives themselves or somebody else an administrator's way in | admin_set_email and admin_reset_password carry the 0021 actor check and refuse an ADMIN target, so an administrator's sign-in address and set-password code stay with owner tooling exactly as their credentials do; an address another person holds anywhere is refused by a global unique index; the runtime role holds no write on principals.email, principal_passwords, password_resets or principal_identities, and check_runtime asserts the whole function catalog and the absence of those grants. password_replace refuses a person who holds no password, so the change route can never become a way to set one, and it takes the principal from the session rather than from a body (migrations/versions/0040_sign_in.py, surfaces/people_pages.py, surfaces/console_api/settings.py). | tests/test_sign_in_store.py, tests/test_console_api_people.py::test_the_console_refuses_an_administrator_an_address_or_a_code, tests/test_console_api_settings.py::test_a_person_with_no_password_cannot_set_one_through_the_change_route | Verified | | T5.16 | E | A space membership raises a role, or the tenant is left with nobody who can administer spaces | The space role narrows and only narrows: a person's authority is min(principals.kind, space role), space_assign refuses the ADMIN role for a HUMAN_APPROVER principal, and principals.kind stays a necessary condition wherever it is checked today, so no area module changes its authority. A tenant administrator is an active ADMIN principal with no membership, and the assignment that would leave a tenant with none is refused LAST_TENANT_ADMIN under a lock on the tenant's ADMIN rows, the way revoke_executor_key refuses a tenant's last live key (migrations/versions/0041_spaces.py). | SP3; tests/test_spaces.py, tests/test_admin_cli.py | Verified | | T5.17 | R | A space and the history it owned are removed | There is no delete path: spaces archive, memberships are removed but the rows they governed keep their space, and the runtime holds no DELETE or TRUNCATE on either new table — SELECT and nothing else on spaces and space_members, and INSERT plus three update columns on space_policy_selection alone. An archived space keeps every row it owned in every view that could see it, and archiving the tenant's last unarchived space is refused (migrations/versions/0041_spaces.py, db.py). | SP9, SP10; tests/test_spaces_schema.py | Verified | | T5.18 | E, I | A tenant's administrator registers an issuer they control, or a client secret and refresh token at rest are read | A provider an administrator registers can mint access for nobody on its own: a subject needs a link, a link needs that administrator's invitation, their direct link or the person's own self-link, and directory_link refuses an ADMIN target exactly as admin_set_email does, so administrator creation and cross-administrator links stay with the owner connection. The worst a hostile issuer buys is approver access for accounts in a directory that administrator already controls, which issuing approver credentials already buys them. At rest, the client secret and the refresh token are AES-256-GCM ciphertext under a key ring in the private configuration, with the tenant, the provider and the purpose as additional authenticated data, so a row moved to another tenant, another provider or the other column does not open; a key that left the ring is named rather than skipped. The key is never in the database, the runtime role holds SELECT and no write on all four tables, and neither value is logged, rendered, returned by any function or present in an audit payload. What this does not defend is a process that can already read the gateway's configuration, which is where the connector tokens and the assistant key already are (envelope.py, directory_store.py, migration 0043_directory_sign_in). | #177, #153; tests/test_envelope.py::test_a_ciphertext_moved_to_another_row_or_column_does_not_open, tests/test_directory_store.py::test_an_administrator_cannot_be_linked_by_anybody_but_themselves, tests/test_directory_store.py::test_the_runtime_role_cannot_write_a_directory_table, tests/test_console_api_sign_in.py::test_a_registration_is_validated_against_the_directory_and_lands_as_a_draft | Verified | | T5.19 | E | Access here outlives an account being disabled at the customer's directory | Before a directory session is renewed, and only then, the refresh token is redeemed at the stored token endpoint outside every database transaction. invalid_grant, or an ID token whose subject or tid changed, revokes the credential and every session under it on that request, which is what both Entra ID and Okta answer for a disabled, deleted or session-revoked account. A session renews once half its fifteen minutes has passed, so a person using the console is rechecked about every seven and a half minutes and one who walked away is gone in fifteen. Every other error word, invalid_client included, is unavailability rather than a refusal, so a client secret rotated at the customer's end does not end every session in the tenant: the session is accepted while the last verification is within fifteen minutes and refused after it, and nothing is revoked, so an outage that resolves lets people carry on. Disabling the provider and unlinking a person both revoke every credential under them in the same function. The residual is the window: a person disabled at the directory keeps the console for up to the recheck interval, and one disabled during a directory outage keeps it for up to the grace; an administrator deactivating them is immediate (directory_store.recheck_session, surfaces/human.py). | See R26; tests/test_directory_store.py::test_a_refused_recheck_ends_the_session_on_this_request, tests/test_directory_store.py::test_an_unreachable_directory_is_tolerated_and_then_refuses, tests/test_directory_store.py::test_a_disabled_provider_refuses_a_sign_in_and_ends_every_session_under_it | Partial, see R26 | | T5.20 | E | A future change reads a tenant's plan or its agent-principal count anywhere on the request path, turning an administrative entitlement (Hosted Free's cap) into a security control that can fail open on a bug or block a legitimate incident response | The cap is evaluated only where an allowance can be taken, both of them administrative definer functions, and raises PLAN_LIMIT there alone: admin_create_principal's own capped branch, and admin_reactivate_principal when its target is an agent a person deactivated by hand, because that deactivation freed an allowance and reactivating takes it back (without this, a tenant at three could deactivate one, create another and reactivate the first, holding four, and repeat without limit). Both take FOR NO KEY UPDATE on the tenant row before counting live and sleeping agents against the same CASE t.plan limit, so a creation and a reactivation contending for the last allowance serialize on one lock and exactly one wins. Waking a sleeping agent is never refused, because a sleeping agent never stopped counting. PLAN_LIMIT is an administrative refusal that never appears in a policy decision's reasons, is never consulted by admission, evaluation, dispatch or approval, and changes nothing about an already-created principal's ability to act (an over-cap tenant keeps every agent it holds). The guarantee is checked structurally rather than by sampling routes: execbound.requests and every module under execbound.execution, execbound.policy and execbound.resolution are read as source text and asserted to contain neither the string PLANS nor agent_principals, so a plan lookup added to any request-path module fails the suite closed rather than needing a reviewer to notice it (plans.py, administration.py, migration 0044_hosted_free). | HF9, HF53, HF54 to HF58; tests/test_hosted_free.py | Verified | | T5.21 | E | A route reachable by a tenant, an agent or the public reaches sandbox_fallback, which (unlike sandbox_expire) leaves the tenant active rather than ending it, or the day-fourteen guard against an unclaimed, converted, already-deactivated or not-yet-due slot is missing from the function that replaced sandbox_expire as the cron's default | sandbox_fallback(p_now) is a fenced, no-actor SECURITY DEFINER function with exactly one caller in the codebase: _fall_back in signups.py, reached only from sweep, the execbound-signups cron's own pass, under its own fence (execbound.tenant_id set to the slot being processed and never to a caller-supplied value). No HTTP or MCP route, no agent-authenticated path and no console page calls it. sandbox-convert --plan free does not call this function: its early-fallback branch produces the identical state (plan = 'free', fell_back_at stamped, tenant left active) through its own direct UPDATEs on execbound.tenants and execbound.onboarding_slots, inside owner_transaction's fenced owner connection, a second, independent implementation of the same state change rather than a second caller of the function. sandbox_fallback's guard restates sandbox_expire's own: a slot that is unclaimed, converted, already deactivated, already fallen back or not yet due is INVALID_SLOT, and a clock more than five minutes from the database's is INVALID_CLOCK. sandbox_expire sets tenants.active = false; sandbox_fallback leaves it true and only lowers the tenant's cap from uncapped to three agent principals, a widening of what the tenant keeps relative to expiry's outcome and never a grant of authority the tenant did not already hold, and each refuses the state the other produces, so the two endings are mutually exclusive in both orders (migration 0044_hosted_free.py, signups.py). | HF10, HF17, HF18, HF46; tests/test_signups.py, tests/test_hosted_free.py, tests/test_route_authority.py | Verified | | T5.22 | I | Computing which free tenant's agents have gone ninety days silent reads audit event payloads, or the derived signal cannot be reproduced once the second Hosted Free spec's retention pass redacts payload content | agents_sleep's own SQL and the cron's _silent_ninety_days both read only audit_events.kind (filtered to execution.%) and occurred_at; neither reads payload, so no retention or custody boundary is crossed to compute dormancy. Silence is measured from the later of the slot's own claimed_at and max(occurred_at) over the tenant's execution.* events, which survives the payload redaction the retention spec (the second half of #439, not built here) will apply, because that spec keeps every event's kind and occurred_at and drops only content. A tenant with no execution.* event at all is judged by its own claim's age rather than treated as silent the instant it becomes free (migration 0044_hosted_free.py, signups.py). | HF33, HF36, HF40, HF41; tests/test_hosted_free.py | Verified | | T5.23 | E | sandbox-deactivate, the operator's own way to end an abusive sandbox tenant at once regardless of its end date, becomes reachable from the runtime role or a tenant-facing route, handing an undated any-tenant off switch to the request path | Unlike sandbox_expire, sandbox_fallback and every other lifecycle function in this family, deactivate_sandbox is deliberately not a SECURITY DEFINER function: check_runtime() requires every function in that catalog (db.py's FUNCTIONS) to be executable by the runtime role, and an off switch reachable by the runtime role is an off switch reachable from the request path however carefully its own guard is written. sandbox-deactivate instead runs as a direct owner write over the owner connection inside owner_transaction, the same shape convert_sandbox already has: it locks the slot row, checks claimed_at/converted_at/deactivated_at, updates tenants.active and onboarding_slots.deactivated_at and appends tenant.deactivated, reachable from nowhere but the CLI's run_sandbox_command (cli.py) under the operator's own credential. sandbox_deactivate is asserted absent from db.py's FUNCTIONS catalog by name, so a future change that turned it back into a definer function would fail this guard closed (signups.py, db.py, cli.py). | HF8, HF24, HF25, HF28; tests/test_hosted_free.py | Verified |

B6: Monitoring sources and notification receivers​

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
T9.14SA forged or replayed ES256 JWS settles a grantThe header is parsed strictly (alg: "ES256" and kid only) and the payload strictly (the domain, version 1 and the seven record fields, nothing else); the 64-byte concatenated r-and-s signature is verified over the exact signing input; the binding checks the same four fields and reported_at an Ed25519 record checks, against the same command. A second, differently signed JWS can only restate a settled outcome, which retains and appends nothing.GC6, TG7, TG8; tests/test_gate_evidence.pyVerified
T9.15EAlgorithm confusion: a token labelled for one scheme verifies under a key of anotherThe algorithm always comes from the registered key's own algorithm column, never from the header or from anything the message claims: an Ed25519 key never verifies a JWS and an ES256 key never verifies the Ed25519 envelope, so a header naming HS256 keyed with the public key, RS256, EdDSA or a mismatched kid is refused before a signature is checked.TG8; tests/test_gate_evidence.pyVerified
T9.16SA provider's journal for one command, execution or operation settles a grant it does not nameCheckpointVerifier compares journal.record.command against the frozen provider command for this execution byte for byte, as a command hash and then as canonical JSON (the operation, account, intent hash, plan hash, target, version and arguments together), and refuses a journal dated before the grant. A checkpoint_providers entry is keyed by tenant, account and key id, so a journal signed for another tenant's or another account's Gate mapping verifies under no key this one holds.TG3, TG14; tests/test_gate_evidence.py, tests/test_gate_vendor_walk.pyVerified
T9.17IA reader of the gateway's private configuration forges a provider journalThe journal key is HMAC and sits in the same file as connector evidence keys, so a reader of that configuration is in the same trust boundary a connector's own journal key is: the key is verification-only, the gateway never calls the provider with it, and the executor never holds it, but the file's own confidentiality is what stands behind it rather than a cryptographic separation ExecBound enforces.CHECKPOINT.md; SURFACES.mdAccepted, see R3
T9.18EThe Tines ES256 signing credential's reach is wider than the story that uses itTines refuses a credential in a cryptographic function while domain restriction is enabled, so the signing credential carries no host restriction and any story in the team with access to it can produce a valid attestation; the report secret is readable in the same team's event data for the life of the grant. Neither is a defect the protocol can close from ExecBound's side: the recipe states both and recommends a dedicated Tines team and short event retention.CHECKPOINT.mdAccepted, see R27

B10: Control point to hook surface​

The Arcade logic extension makes ExecBound the decision service behind a platform's hooks, so this boundary is about a caller that calls on every tool invocation and about the correlation table that stands behind it. Every row here is fixture-verified; nothing has contacted Arcade.

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 | | R26 | Deprovisioning at a customer's directory reaches ExecBound on a poll rather than a push, so access here outlives a disabled account by up to the recheck interval, and by up to the fifteen-minute grace while the directory is unreachable. Neither Entra ID nor Okta pushes an event this relying party can receive today: Entra transmits no Shared Signals event to an outside application at all, and redeeming a refresh token is the one mechanism both honour. A Shared Signals receiver and SCIM provisioning shorten this and are Enterprise (T5.19). | #177; tests/test_directory_store.py::test_an_unreachable_directory_is_tolerated_and_then_refuses | | R27 | A Tines ES256 signing credential cannot be host-restricted, because Tines refuses a credential in a cryptographic function while domain restriction is enabled; any story in the team holding it can produce a valid attestation, and the checkpoint's own report secret is readable in the same team's event data for the life of a grant. The recipe's answer is a dedicated Tines team and short event retention, an operator practice rather than a control the protocol enforces. | CHECKPOINT.md |

Assumptions​

  • The gateway process, its private configuration files and its database credential are trusted. Compromise of the host running them is outside this model.
  • Owner provisioning is correct: canonical identities, approval assignments, inventory and incident sources describe the real environment.
  • The PostgreSQL administrator and the owner role are trusted; migrations run only through the owner connection.
  • Approvers and administrators are the people their credentials were issued to. Credential issuance happens outside the gateway.
  • In a deployment, TLS termination and origin configuration are set up as the operations guide requires; the secure cookie and HSTS behavior depend on it.
  • Provider origins are HTTPS with a certificate the gateway verifies; cleartext is accepted only for a loopback IP address and there is no opt-in for private networks (#183).

Out of scope​

  • Denial of service against the host, network or PostgreSQL from outside the application.
  • Vulnerabilities in the customer's agent platform, identity provider or SOAR system.
  • Physical or hypervisor-level attacks.
  • Social engineering of approvers outside the approval page.

Maintaining this document​

Update this document in the same PR whenever a change adds an HTTP route, MCP tool, human page, CLI command or monitoring route; adds a connector or external integration; adds a migration that touches authority tables; or closes an item marked Open or Partial. Keep threat and risk identifiers stable so issues and reviews can cite them. When an acceptance scenario is added, add its identifier to the row it evidences. A change that weakens a Verified row needs the failing test first, as the development guide requires for security behavior.

tests/test_threat_model_references.py checks what this section asks for, so a stale citation fails a run instead of waiting for a reviewer to click it: every cited test file exists and every cited node is defined in it, every acceptance identifier is a row or a group in the acceptance inventory, every document link resolves, every "see 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.