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 below, 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), Arcade logic extension (AL1 to AL34), sample targets (SM1 to SM11), denial notice (DN1 to DN10), action listing (LA1 to LA8), self-hosted export (EX5 and EX8 to EX10) and security signals (SG1 to SG17) sections of the inventory. Residual risks are stated plainly, each with the guide, row or test that states it. The architecture guide describes the components; this document describes how they fail and why they should not.

Security invariants​

These hold on every path ExecBound enforces, and each threat below is a way of attacking one of them.

  • An agent's identity and tenant come from authentication, and every object and connector account is scoped to a tenant.
  • Unknown or mismatched action or target input is rejected.
  • A failure of required context, policy, state or audit intent prevents consequential dispatch.
  • A hard denial always wins over permits and approvals.
  • Approvals are scoped, expiring and held server-side, and each is consumed by one execution claim.
  • Target impact is shared across agents, runs and adapters within the configured principal or tenant scope.
  • The provider checks the frozen precondition atomically where the vendor offers a conditional write. Where it offers none, what is proved is ExecBound's own read and comparison immediately before the write, with the window between them measured and retained, not atomicity.
  • An uncertain operation cannot be retried or refunded without sufficient evidence.
  • Replay never executes a provider or changes live budgets, approvals or execution state.
  • Human pages treat agent and provider strings as untrusted data.
  • Raw provider credentials, runtime configuration, signing material and control stores stay outside the protected agent's read and write authority, and there are no generic passthrough tools or agent-controlled connector destinations.

Scope and method​

Covered: the gateway (HTTP and MCP), the human surface, PostgreSQL, the protected mock providers, monitoring intake and alert delivery, security signal intake and the operator's mapping of it, owner tooling and the CLI, offline replay and export, the build and CI pipeline, and the export of the self-hosted source to its public repository.

Not covered: real vendor systems beyond the one real binding, Microsoft Graph's user disable, which T3.14 to T3.16 cover (every other binding is a controlled mock provider), operating-system sandboxing of the gateway process, the platform a deployment runs on (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.
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), an administrator's personal access tokens and their backing credentials (stored only as hashes, the backing credential minted and revoked with the token), continuation signing keys, provider dispatch and control credentials, provider evidence keys, monitoring source tokens, security signal producer tokens (stored only as digests the runtime role cannot read) and the database roles.
  • Tenant isolation: every tenant table is tenant-scoped with forced row-level security, and the account tables are fenced by account the same way.
  • 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; list the operations its own space offers.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
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
Human approverSign in for a session that ends after 12 hours idle and 2 days at most; 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 tenants or the registry. Administrators are made and unmade only by role grants, bounded as T5.28 says.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
Security signal producerPost execbound.security-signal.v1 envelopes inside its owner-registered scope with an sg1. token bound to one tenant and one source.Read anything, see a decision, approve, dispatch, reserve impact, change policy or mappings, be called by ExecBound, or make its verdict mean anything the bundle's mapping does not say.SIGNALS.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.TINES_MONITORING.md
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.scripts/ci.py; ACCEPTANCE_TESTS.md
Public contributorOpen a pull request on the public repository, whose CI runs on GitHub-hosted runners with a token that only reads.Run code on the maintainers' own machines; reach the credential that writes the public repository; change the development repository or the hosted service except through a maintainer's review.The public repository's CONTRIBUTING.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.
B12Development repository to its public exportOnly what the export manifest names leaves, and nothing that leaves links back into the development repository or carries a secret, a private name or the hosted service's address without a recorded reason; a fix leaves only once the hosted service runs it; the public repository's CI never runs on the maintainers' own machines, and code contributed there reaches the development repository only through a maintainer's review.
B13Security signal producer to gateway, and the mapping behind itA producer's token opens /v1/signals and nothing else, and one fenced function authenticates it and checks its capability, subject, producer and times against the source's owner-registered scope; what a verdict means is the operator's mapping in the bundle, re-applied at evaluation to the records the plan froze; absence, staleness, revocation and a foreign space are all INVALID_CONTEXT, which denies, and clean needs every source it rests on to attest liveness.

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; an operation outside the agent's space is refused the same way (T1.17).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 when the external execution checkpoint 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 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.hosted: SO3, SO4, SO10, SO11, SO11bVerified
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.14TAn agent names or infers a space, and acts on another customer's objects through a mapping that is not its ownNo 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 before any provider contact: by admission's offered check, before arguments are validated or a target is read (execution/offered.py), or, for a request held for approval and re-sent unchanged after its account or agent left the space, by _account_in_space (execution/admission.py). The audit records INVALID_CONFIGURATION; the agent is answered 503 EXECUTION_UNAVAILABLE, exactly as for an unknown operation, and never INVALID_CONFIGURATION (T1.17). The active bundle is the space's selection, so a space with none fails closed exactly as a tenant with none did.SP1, SP5, LA4; tests/test_spaces_kernel.py, tests/test_action_listing.pyVerified
T1.15DAn 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.pyVerified
T1.16IAn agent learns the policy from its own denials: which rule matched, which fact put a target out of bounds, which budget is spent, or what a reviewer wroteA DENIED answer, over HTTP and MCP, carries the view's own members, denial and notice, one fixed sentence built from the request's reference and the category alone (runtime.denial_notice, added by Runtime.present for that state and no other); ExecutionView has no reasons field, and the recorded reason, the rules a denial matched, the facts they read and any reviewer comment reach people only, on the record a person finds by searching the console for the same reference. The sentence promises only what holds for every cause, that re-sending the same request returns the same answer, and never calls a refusal permanent: a new request can be decided differently once a busy target is released, a budget's window moves on, a policy or fact changes or a person is asked again. The rule and why: the protected agent is the governed party and may be mistaken, prompt-injected or compromised, so it is told a denial's state and reference and never its cause beyond the two categories below, and gets no map of the policy beyond the decision itself; a reviewer's words stay with people. The execute tool description, the agent guide, the agent recipes and the client's tool examples tell it to stop and relay the reference rather than guess. A busy target is answered the same way, as a DENIED with reason RESOURCE_BUSY and denial busy, and is no longer published as a retryable 409. Observe mode is a deliberate exception: an OBSERVED answer, from an observing tenant or from an observe-only mapping in an enforcing one, carries observed.reasons, rule ids included (HARD_LIMIT:<rule id>), because observe mode exists to show a tenant what enforcement would do and nothing runs under it. What stays visible: the decision itself, so an agent that tries many targets still learns which it may act on; and whether a person was involved, because the view's approval_state is DENIED after a reviewer refused, INVALIDATED when a request that held an approval was then denied, and null when the first decision denied it. The reviewer's comment never reaches the agent. Categories, the one extension: a DENIED answer also carries denial, which store.denial_category reads from the recorded DENIED transition, so the first answer, a re-send and a status read agree byte for byte. It is busy exactly when the recorded reason is RESOURCE_BUSY alone, limit whenever a HARD_LIMIT:<rule id> is among the recorded reasons, whatever sits beside it, NOT_AUTHORIZED included (decided on September 28, 2026), and withheld for every other denial; notice is the category's fixed sentence, and the limit's promises only that a new request after the limit resets is decided afresh, which stays true when a rule denies the request too. This closes a probe: under "every recorded reason is a limit", a spent limit made limit mean "the policy allows this target" and withheld mean "a rule denies it", a per-target verdict with nothing run and no reviewer asked. That no longer holds because policy/evaluate.py checks every limit rule against the request once the Cedar policy sets are evaluated, whatever their verdict, and records each applicable spent one beside NOT_AUTHORIZED or POLICY_ERROR; only a refusal that stops evaluation before the policy sets (the identity, the bundle, the arguments, the trusted entities or the usage snapshot) or an unexpected evaluation failure (DEPENDENCY_UNAVAILABLE) records no limit, and those are withheld and carry no policy verdict either. What each category discloses, and nothing more: busy tells the agent that another request holds the target and, because the busy check runs after the policy decision (execution/admission.py), that the policy otherwise allowed this request. limit tells it that a shared limit covering this request is spent; for a limit scoped to the principal, its space, the tenant or the agent's own groups that is all, and for a limit scoped by selector or by account it is also an inference about a fact, that the target matches the selector or belongs to the account. It carries no per-target policy verdict. withheld tells it nothing beyond the decision. No category names the rule, the limit, its window, a count, a fact or the other execution. busy and a tenant-wide limit are the cross-space channels residual risk R24 accepts, a target another space holds and a tenant limit another space spent, now stated to the agent rather than left for it to infer; they still say nothing about which space or what. Accepted because an honest agent needs to know when waiting is worth a new request, and both facts are learnable anyway by trying again later, when a busy target is free or a limit's window has moved on. Refusals before any plan tell the agent no more than before: an unknown target, a claim the inventory does not hold, a run that is not live and stale context are all answered the same 503 EXECUTION_UNAVAILABLE, and the cause the audit records is read from two columns of the one coherent snapshot statement, so every refusal after the mapping read costs the same statements whatever its cause (DN8). Only the audit's execution.rejected event names the cause (INVALID_OPERATION, INVALID_TARGET, INVALID_CONTEXT and the rest), from the closed list RESOLUTION_CODES, so no caller string becomes a reason. Two further oracles, bad arguments answered 400 or 503 by whether the operation exists in the tenant and the agent-facing INVALID_CONFIGURATION, are closed by T1.17.DN1 to DN10, OB3; tests/test_denial_notice.py, tests/test_denial_categories.py, tests/test_observe_mode.py::test_ob2_ob3_would_be_eligible_and_denied_requests_are_observed_with_their_reasonsVerified
T1.17IAn agent maps what exists beyond its own space: which operations the tenant maps, which another space offers, where an operation is placed, or which targets existDiscovery, of its own space alone. GET /v1/actions and the MCP tool list_actions answer what the agent's own space offers (execution/offered.py): each operation_ref, its catalog action and label, the argument names and the values or range each accepts, the target kinds it accepts and whether an incident is required. They never list a target, a rule, a limit, a placement, an account, a mapping or another space's operations; an observe-only or checkpoint operation is listed exactly as a connected one is, because the agent may send both and placement is never disclosed. A listed action is not permission: every request is still decided. Listed does not mean connected either: the listing reads no connector configuration, so a listed operation whose account has no connector still answers 503 CONNECTOR_UNAVAILABLE, which concerns only the agent's own space. An archived space answers 423 SPACE_ARCHIVED, a space with no active policy lists nothing, and an emergency stop does not stop a listing, as it does not stop a status read. A refused listing is audited as the surface's refusal (operation surface), as a refused status read is, and the runtime logs actions.listed with the count alone. A tools/call may leave arguments out, as MCP allows and the SDK's own client does for a tool called with none; when it is sent it must be an object, and list_actions refuses any argument. Refusal, alike for everything outside the space. Admission checks the requested operation_ref against the same one-statement read before it validates arguments or reads a target, for a new key and for a changed request under a known key. An operation the tenant does not map, one it maps but the space's bundle does not name, one whose mapping the catalog no longer binds, one the bundle names through another space's account and one the bundle names at a reference the tenant's mapping no longer matches (a stale policy, as a sample removal and reinstall leaves one) are all refused exactly as an unknown operation is: the same 503 EXECUTION_UNAVAILABLE, the same statements in the same order, and no execution record. That closes both oracles T1.16 recorded, bad arguments answered 400 or 503 by whether the operation exists anywhere in the tenant and the agent-facing INVALID_CONFIGURATION; bad arguments are refused 400 only for an operation the space offers. Only the audit tells the causes apart: INVALID_OPERATION for the first three, INVALID_CONFIGURATION for another space's account and INVALID_BUNDLE for a stale reference, which wins when the reference is both stale and on another space's account. The audit's INVALID_BUNDLE therefore now covers two agent answers, 409 from a checkpoint mapping on a connected account or a key ring that cannot attest a grant, and 503 for a stale reference. INVALID_CONFIGURATION is never answered to an agent wherever admission raises it (AUDIT_ONLY): the offered check, _account_in_space for a held request re-sent unchanged after its account or agent left the space, and an agent in more than 64 groups. What changed with it, decided on September 28, 2026: an operation outside the space no longer leaves a DENIED record with reason INVALID_BUNDLE, which was itself a sign that the operation existed, and observe mode no longer records an OBSERVED row for one; a new key for an intent still pending approval whose operation the space no longer offers is refused 503 rather than attached to the pending row; and the evaluator's own INVALID_BUNDLE for a bundle reference that does not match the plan's mapping is practically unreachable for a new or changed request, because the offered check compares the same reference first, and stays in policy/evaluate.py as the defence it was. Targets are never listed.LA1 to LA8; tests/test_action_listing.pyVerified
T1.18DRecording an agent key's last use slows, blocks or changes authentication, or is written for a key that did not authenticatecredential_used (0079) is a definer function under the tenant fence; the runtime holds EXECUTE on it and no UPDATE on credentials. identity.authenticate calls it for an agent's key alone (a person's or an owner tool's credential is never stamped, so a read-only owner command writes nothing), at most once a minute per key, after every check has passed, never on a refusal (a refusal raises inside the authentication transaction, which rolls back) and never in the decision transaction. It runs inside a savepoint whose errors are swallowed, so the identity is returned exactly as it would be without it. The function stamps nothing rather than failing when the application's clock that issued the key is ahead of the database's, and takes the row with FOR NO KEY UPDATE SKIP LOCKED, which does not conflict with the key-share locks execution, approval and session inserts take through their foreign keys and never waits on a revocation's lock. Nothing authorises, expires or revokes by the column; it is display and notice state only.AK2 to AK4; tests/test_key_last_used.py::test_ak2_a_stamp_that_raises_never_changes_authentication, ::test_ak3_a_share_lock_from_an_open_execution_does_not_delay_the_stamp, ::test_ak3_a_locked_key_authenticates_at_once_without_a_stamp, ::test_ak4_the_migration_adds_both_columns_and_three_functions_and_the_downgrade_removes_themVerified

B2: Gateway to PostgreSQL​

IDSTRIDEThreatMitigationEvidenceStatus
T2.1ETenant data reached across tenantsTransaction-local tenant context; every tenant table in db.TABLES forces row-level security, and the account tables are fenced by execbound.account_id instead; composite keys; the runtime role cannot bypass RLS and check_runtime refuses an unsafe role (db.py). A restored copy is read as the runtime role as well: scripts/verify_restore.py --check-fence fails a restore whose runtime role check_runtime refuses, or under which the fence of any tenant the copy holds, named or not, shows it another tenant's rows or hides its own history (RECOVERY.md).S, CC8; tests/test_execution_database.py::test_all_execution_tables_isolate_colliding_tenant_objects, tests/test_fenced_reads.py, tests/test_recovery.py::test_a_policy_that_lets_one_tenant_read_another_is_reported_as_crossing, ::test_the_fence_check_reads_every_tenant_the_copy_holds_not_only_the_named_ones, ::test_a_runtime_url_that_bypasses_row_security_is_unsafe_and_crossesVerified, 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. audit-verify, which the jobs service runs hourly, reads every workspace's chain on the live database again and places each retained checkpoint on it, and a chain that fails is logged as audit.chain_failed and fails the run (audit_verify.py, AV1 to AV7).tests/test_replay_verify.py, tests/test_audit.py, tests/test_audit_verify.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 databaseTokens, sessions, CSRF values, recovery codes and report secrets are stored as digests. Two things are stored as AES-GCM ciphertext under keys held only in the private configuration: authenticator seeds, and a directory's client secret and refresh tokens (T5.18). One usable secret is stored in plaintext: the Arcade correlation row's report secret while its grant is live (R17). Provider dispatch tokens, evidence keys and the Graph client secret never enter the database (CREDENTIALS.md).tests/test_mfa_sessions.py::test_enrollment_seed_is_encrypted_and_expires, tests/test_directory_store.py::test_a_registration_is_a_draft_whose_secret_is_only_ever_ciphertext, ::test_a_linked_account_signs_in_and_the_refresh_token_is_only_ciphertextPartial, see R17
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.8EThe runtime writes a signup request, a slot's lifecycle or an invitation directly, or forges a delivery the outbox never queuedEvery 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 and the table set, and since 0065_dormant_grants holds these grants, the seven columns included, to exactly what the hosted plugin claims, which is nothing on a database whose processes load no plugin (T2.11); both new tables are forced row-level security under the tenant policy every table has (migrations/versions/0039_signups.py, db.py, and the hosted plugin's own code).hosted: OS1, OS9b, OS9c, OS9d, OS9eVerified
T2.9IA store function reads without the space predicate and returns another space's rowsForced 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. A person's read computes the visible set with one function (spaces.visible_spaces), inside its own transaction, and its predicate on that set comes from one helper (spaces.space_clause), appended to the statement or substituted at the statement's /*space*/ marker; a row already read is held to the same set by Visibility.allows, and the Spaces list, whose rows are the spaces themselves, binds the set directly. Two narrower rules sit beside that predicate and never replace it. The review scope is written out by hand in each statement that applies it (board, dashboard, console_api.search, human.records and request_context.attach_context): the approval queue, and the reads of an approver access_control.review_scoped names, show only the executions an active assignment in the execution's own space names and, outside the queue, the rows of the spaces a role grant gives that approver broad reads in (:read_spaces). Each is joined to the helper's predicate by AND, so it can narrow what the visible set shows and never widen it. What an administrator narrowed to spaces may change, rather than read, is a second helper, spaces.administered_clause, the rule the definer functions apply since 0072_space_principal_scope (R25). An isolation test is the control. It reads every table 0041_spaces.py gave a space through that predicate, parametrized over the migration's own list, and it reads the catalog for every table the schema gives a space, by a space_id column or a foreign key into spaces: each must be in that list, name the negative tests that plant a row in another space and read it through that table's own readers, or say why its space is not a row a customer owns, so a table given a space later fails the test until it has a case. The readers of a role granted in another space and of a review rule in another space are among those cases. 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::test_every_space_carrying_table_has_a_case, ::test_a_role_granted_in_another_space_reaches_nothing_in_this_one, ::test_a_review_rule_in_another_space_routes_nothing_in_this_oneVerified, see R25
T2.10EForced row-level security is asked to do a space's work, and a policy comes to depend on a session settingEvery 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.pyVerified
T2.11EA runtime with no plugin reads a table or calls a definer function that only a plugin uses (for example community_provision, which creates a workspace with its first administrator)Every migration ships in the one chain, so these objects exist on every install. 0065_dormant_grants revoked every privilege the owner had granted PUBLIC, the runtime role and Supabase's anon, authenticated and service_role on the seven dormant tables and seventeen dormant functions of db.DORMANT, by the revoke convention of 0063_personal_access_tokens; a grant by another grantor survives, and check_dormant refuses it. An object of the kind added later must be created with no grant but the owner's, which for a function means revoking PUBLIC's default EXECUTE (the revoke convention); if PUBLIC or the runtime role keeps a grant, check_runtime refuses the database wherever no loaded plugin claims the object; a platform role's grant it cannot see, and on the plugin's own database the plugin's upgrade command revokes either. check_runtime requires every dormant object to hold exactly what the process's loaded plugins claim and nothing where none claims it, reading the grants in its existing functions query and comparing them after the revision check, so a grant made by hand refuses readiness (503) and every request that reaches the check; DELETE and TRUNCATE can never be claimed and stay refused on every table. check_dormant, the owner-side check the plugin's upgrade command runs before it commits, also reads the ACLs themselves, because what the runtime role can do folds PUBLIC's grants into its own and cannot see a platform role's: it refuses a grant to PUBLIC or to any role but the runtime's (the owner's own entry apart), one made by any grantor but the owner, one carrying a grant option, and one on another function of a dormant name. No source under src outside db.py and a plugin's own package names a dormant object as execbound.<name> (DC19), the form core's queries use, so hosted tests running with the plugin's grants cannot hide a core path that reads one. One residual: core's account_change (0052), which the runtime executes, still writes the dormant account_preferences through its preferences branch, so a runtime with no plugin can write its own consent row, which nothing reads; that is accepted, and a dormant definer would close it (db.py, migrations/versions/0065_dormant_grants.py, and the hosted plugin's own code).DC3, DC4, DC5, DC8, DC19, DC21; hosted: DC11 to DC14; tests/test_dormant_catalog.pyVerified
T2.12EA plugin's claims, compared on the execution path, weaken the runtime's check: a claim widens the runtime's reach into a core table or function, lets a request through, or runs plugin code where requests are decidedThe one exception to the rule that nothing on the execution path calls a plugin: check_runtime, which runs in authentication, resolution and the admission fence, compares the database with the claims the loader recorded. The claims are data recorded at each load, after every named plugin has loaded and passed its checks, and validated against core's own catalog: exact types, a dormant name, at least one privilege, each in the grammar (SELECT, INSERT or UPDATE on a table, whole or by column, EXECUTE on a function), and never a whole-table and a column claim for one privilege, each refused as INVALID_CLAIM. They name only dormant objects, which no core source outside db.py names as execbound.<name> (DC19; core's account_change still writes account_preferences, T2.11), so a claim cannot reach a core object. A claim can only refuse: one the database does not match refuses the request, and one it matches changes nothing a request can reach. A later load can add a claim but never change one (PLUGIN_CONFLICT), a refused load records none, and no plugin code runs in the check (db.py, plugins.py).DC2, DC5, DC6; tests/test_plugins.py, tests/test_dormant_catalog.pyVerified
T2.13E, IThe runtime forges, duplicates, re-addresses or re-dates a dormancy warning, or reads another workspace's warnings (hosted service only)A warning is a row of dormancy_warnings in the warned workspace's own tenant, inserted only by dormancy_warn, a fenced definer function with no actor that decides inside the fence whether the workspace is due (by agents_sleep's own measure of silence) and who is a live administrator, so the runtime never chooses a recipient, a date or whether to warn. The runtime holds SELECT and UPDATE on the seven outbox columns and nothing else: no INSERT, no DELETE, and no write to the recipient, the anchor or the sleep moment. One warning per administrator per approach is a unique key on (tenant_id, principal_id, silent_since) with ON CONFLICT DO NOTHING, so overlapping passes add nothing. The table is forced row-level security under the tenant fence; the two cross-tenant functions read through an owner_drain policy granted only to the role that owns them and return tenant and warning ids or one count, never an address, a name or a time. All four objects are dormant until the hosted plugin claims them (T2.11) (migrations/versions/0092_dormancy_warning.py, and the hosted plugin's own code).hosted: DW3, DW8, DW9, DW10Verified
T2.14E, IThe runtime forges, re-addresses or re-dates an approval email, chooses its recipient, or reads another workspace's subscriptions or emails (hosted service only)An email is a row of approval_emails in the approval's own workspace, inserted only by approval_email_queue, a fenced definer function with no actor that decides inside the fence which approvals are due and who may decide them, by the routing rule core's notifications route and decide by (notifications._ASSIGNED), so the runtime never chooses a recipient, an approval or a deadline. The runtime holds SELECT and UPDATE on the seven outbox columns and the unsubscribe digest and nothing else on emails: no INSERT, no DELETE, no write to the recipient, the approval, the deadline or the queued time; on subscriptions it holds SELECT alone, and only approval_email_change, which takes its person from the calling session, and approval_email_unsubscribe write them. One email per approval per person is a unique key with ON CONFLICT DO NOTHING. Both tables are forced row-level security under the tenant fence; the three cross-tenant functions read through an owner_drain policy granted only to the role that owns them and return tenant and email ids or one count. All eight objects are dormant until the hosted plugin claims them (T2.11) (migrations/versions/0096_approval_email.py, and the hosted plugin's own code).hosted: AM2, AM11Verified

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 mutationOn a binding declared ENFORCED_BY_PROVIDER, the provider checks the frozen expected version atomically at mutation; both mock providers do. The Microsoft Graph binding cannot, and T3.15 states what it does instead.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: 5 seconds by default and at most 30 for a mock provider's connection, and 10 seconds for Microsoft Graph, each applied to every phase of the exchange rather than as one deadline for the whole of it. 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, 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). The scheduled pass, recover --all-tenants, holds no workspace credential: it visits every active workspace in the directory of tenants, a workspace made by signup included, and acts in each as that workspace's scheduler principal, with the scheduled check where an administrator's run has the ADMIN recheck (T3.12). --cancel-unattempted needs an administrator's mode (--tenant, or --tenants-file, each as a current ADMIN): the CLI refuses it beside --all-tenants, and recover and reconcile_operation refuse it for a scheduled actor before any provider call (cli.py, provider/gateway.py).SJ2, SJ4, SJ7; tests/test_recovery_run.py (RC1 to RC13), tests/test_scheduled_recovery.pyVerified
T3.9SA leaked sample key lets its holder call the endpoint mock as any workspace's sample accountThe mock holds synthetic targets only and a sample connection reaches only the configured mock origin; no real connection, credential or configuration is derived from the key. The holder acts on nothing else: the mock refuses a sample token for any tenant it already holds a configured account for, whatever account id the presented command names, because resources is keyed by tenant and target and dispatch joins any representation in the tenant; and samples_install refuses a workspace that already holds an active connector account, so a sample account can exist only in a workspace with none. The gateway applies the mock's rule too: in a tenant its configuration holds a connector for, it derives no sample connection and offers no samples, so a sample request there is refused at admission rather than dispatched to a 401. The key must be independent of every connector evidence key, continuation key and checkpoint journal key, checked at start and on every rotation, and a changed key is a restart (provider/sample_accounts.py, provider/mock_app.py, runtime.py).SM3, SM4; tests/test_sample_accounts.py, tests/test_sample_mock.py::test_sm4_a_sample_token_for_any_account_in_a_configured_tenant_answers_401, tests/test_sample_runtime.py::test_sm_runtime_a_rotation_may_not_take_up_the_sample_key, tests/test_sample_runtime.py::test_sm_runtime_a_tenant_holding_a_configured_connector_gets_no_sample_connectionAccepted, see R28
T3.10SA forged sample account: an account marked sample by anything but the install function, or a derived connection for an account nobody installedOnly the samples_install definer function sets connector_accounts.sample, for a live administrator of the tenant under its fence; the runtime role writes no registry table and no request field, header or credential attribute reaches the flag; the gateway derives a connection only for an active account marked sample whose provider has a sample_providers entry, and a configured connector resolves exactly as before.SM1, SM2, SM8; tests/test_samples_schema.py::test_sm8_revision_catalog_and_privileges, tests/test_sample_runtime.py::test_sm_runtime_an_account_not_marked_sample_never_gets_a_derived_connectionVerified
T3.11SA sample token used across workspaces or accountsThe derivation binds the tenant and the account under a versioned label, so a value derived for one workspace or account fails for any other; the mock derives the expected token from the tenant and account of the command it parses, compares in constant time and answers 401 on any mismatch; admission resolves only accounts of the request's own tenant.SM3, SM4; tests/test_sample_accounts.py::test_sm3_a_token_for_one_tenant_or_account_fails_for_another, tests/test_sample_mock.py::test_sm4_a_mismatched_tenant_account_key_or_role_answers_401Verified
T3.12EScheduled recovery acts in a workspace no administrator authorizedThe pass may do only a closed list of things, each a transition the product already makes that leaves the record more conservative or applies verified provider evidence: mark an unreported grant INDETERMINATE, settle an operation through the trusted verifier bound to its frozen command or mark an inconclusive journal INDETERMINATE, release an asserted claim past the act window, expire a lapsed approval, retire thirty-day-old assistant text, and settle without sending a queued notification whose approval is closed or whose destination is gone. It never dispatches, resends, decides or extends an approval, or requests a tombstone: cancellation needs an administrator's Identity, which recover requires before its first selection and reconcile_operation before any provider call. Only the call chain tests/test_scheduled_actor.py::test_sj9_only_the_pinned_functions_accept_a_scheduled_actor pins may accept a ScheduledActor, and store.identity_check refuses one anywhere else. Every selection and every write the pass makes in a workspace runs scheduled_check in the same transaction, which requires the workspace to be active and to be the one the transaction is fenced to, and, once the actor names one, the workspace's own active scheduler principal; a deactivated workspace stops the workspace's run at its next check. The fence, lock order and trusted verifier are the ones an administrator's run uses (execution/scheduled.py, recovery_run.py, provider/gateway.py).SJ2, SJ6, SJ7, SJ9; tests/test_scheduled_recovery.py::test_sj7_a_scheduled_actor_never_asks_for_a_tombstone, ::test_sj7_the_scheduled_run_refuses_cancellation_before_reading_anything, tests/test_scheduled_actor.py::test_identity_check_refuses_a_scheduled_actor, ::test_the_scheduled_check_is_bound_to_the_transactions_own_fenceVerified
T3.13S, EA connector vouches for its own evidence: it returns a forged journal or record together with a verifier, or a reported configuration, that accepts it; or it reads more of the dispatch than it needsA connector receives the frozen ProviderCommand and nothing else of the dispatch, and carries no verifier. The runtime reads each connector's configuration once, validates that copy, rotates that copy, and builds the verifier the gateway settles with from it (provider/verifiers.py, Runtime.evidence_verifier), matching on the exact configuration type; the verifier it hands out reads the account's current ring each time it is used. Every server caller passes it, which a static test holds, and neither connector package can name TerminalEvidence, the evidence the kernel settles on. A connector's own check_journal or check_connector_record only catches its mistakes early; it decides nothing.PK5 to PK9; tests/test_protected_path.py::test_the_verifier_comes_from_the_server_not_the_connector, ::test_reconciliation_settles_with_the_server_verifier_too, ::test_the_runtime_verifies_with_the_config_it_was_given_not_what_a_connector_reports, tests/test_connector_packages.py::test_every_server_caller_settles_with_the_runtimes_verifierVerified
T3.14I, EThe Microsoft Graph client secret, which can disable real users in a customer's directory, leaks or is sent anywhere but MicrosoftThe secret is read from the private configuration's graph_connectors block alone, and never travels in a request an agent makes, a failure reason, an error or a representation. The Graph and sign-in origins are pinned in source (GRAPH_ORIGIN, LOGIN_ORIGIN), so neither configuration nor agent input can move where the secret goes, and the client follows no redirect and trusts no proxy or certificate setting from the environment. The connector performs one operation, identity.disable, and refuses a dispatch for another account before any vendor call. What the Entra app registration itself may do is the operator's to scope, so the execute registration should hold only User.EnableDisableAccount.All and User.Read.All and no directory role (PROVIDER.md); keeping the configuration file itself is the operator's too (CREDENTIALS.md).tests/test_graph_connector.py::test_no_failure_puts_the_secret_or_the_token_in_its_reason, ::test_the_credential_keeps_its_secret_out_of_its_repr, ::test_the_client_never_follows_a_redirect_or_trusts_the_environment, tests/test_graph_dispatch.py::test_a_dispatch_for_another_account_is_refused_before_any_vendor_callPartial, see R12
T3.15TThe target changes between the Graph connector's read and its writeGraph offers no conditional write, so the binding is declared CHECKED_BY_CONNECTOR and the deployment refuses any other class for it. The connector reads the user, compares the composite with the frozen version, refuses before any write on a mismatch, and writes at most once. It signs a record of the frozen and observed composites and the measured window between read and write, which settlement retains. A change inside that window is undetectable, and so is a stale read Graph serves, since its reads are not read-your-writes consistent (PROVIDER.md); the window is that limit, measured, and never described as atomicity.tests/test_graph_connector.py::test_a_disable_reads_compares_and_writes_exactly_once, ::test_a_change_after_the_read_is_undetectable_and_that_is_the_whole_window, ::test_a_mismatch_refuses_before_any_write_is_attempted, tests/test_graph_dispatch.py::test_an_allowed_disable_writes_once_and_retains_the_window_it_measured, ::test_a_subject_that_moved_is_refused_and_the_record_keeps_the_driftAccepted
T3.16RA lost Graph response or a refused write is settled from a re-read that cannot tell this connector's write from someone else'sGraph publishes no journal, so the connector offers no lookup and no cancellation, and such an execution stays uncertain rather than settling on a guess. A refused write is retained as uncertain rather than claimed as a failure, and a cancellation cannot refund a Graph operation. The record's signature is ExecBound's own and is never evidence that Microsoft acted, which replay states.tests/test_graph_dispatch.py::test_the_connector_offers_no_lookup_and_no_cancellation, ::test_a_refused_write_is_uncertain_rather_than_a_claimed_failure, ::test_a_cancellation_cannot_refund_a_graph_operation, ::test_replay_reads_the_connector_envelope_and_the_window_it_carriesVerified

Transport decision (September 16, 2026). 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 hosted service 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 hosted service 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 that end after 12 hours idle and 2 days at most (SESSION_LIFETIME, SESSION_ABSOLUTE); HttpOnly and SameSite=strict cookies with the __Host- prefix when served over HTTPS; expiry rechecked inside decision transactions (surfaces/sessions.py); a session that still owes its second factor stands 15 minutes (PENDING_LIFETIME), at most five sessions are live per credential, the oldest ended (reason SESSION_CAPACITY) to admit a sixth; a session a workspace switch opens starts at 15 minutes too, and once nothing is owed (the factor verified, or the switch carried the source's verification) the next load extends it. In a workspace that requires its directory the twelve idle hours hold as well: a session back after more than the fifteen-minute grace is rechecked at the directory before it loads, and is refused unless the directory confirms the account (T5.19).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, CF10, HV10, HV16; tests/test_browser_surfaces.py, tests/test_browser_console.py, tests/test_browser_console_shell.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.tests/test_human_surface.py::test_listing_renders_unknown_contract_label_instead_of_failing, ::test_listing_renders_unknown_action_label_instead_of_failingVerified
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.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; a rendered message also names its workspace and space, both reduced the same way, and the requesting agent by its tenant-local handle ("Agent 3", built from the principal's counting number), never by the label an administrator gave it or any string the agent supplied, while the webhook body stays byte for byte as documented; a reminder (approval.reminder) is the same message again with its own subject, first line and header, and a webhook body that differs from the announcement's only in kind; an outcome message (approval.closed) follows the same rules and says less, the outcome, its time, the workspace and the link, with no target fact, agent or person and a fixed sentence that it decides nothing and does not say whether the action ran, and its webhook body is its own documented kind; 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; Slack's blocks are plain_text with emoji off and the same reduced labels, its notification line, which Slack reads as mrkdwn, leaves out a workspace name Slack could format (_, :, . or /) or one that starts like a list item (-, 1)), and a Teams card whose workspace or space name, or whose environment (a fact value a provider reported), Teams could format carries its facts in text runs, which Adaptive Cards never read as Markdown, since neither vendor offers an escape for all of these; no message carries an approve or refuse control, the decision still happens on the authenticated page with the scope check, and delivery failure changes nothing (notifications.py, transports.py). A finding message follows the same rules: the rule's version and fixed catalog label, the finding's, source's, connection's, observation's or agent's identifiers, when it was raised, the workspace and Space names reduced the same way, the workspace's tenant id in the line saying which workspace to choose when its name cannot be shown as written, and the console's link to the finding; never its stored title, target_ref, story id, action or outcome, a person's or an agent's name, or any string a source or a provider supplied. Its webhook body is monitor.finding version 1 with tenant_id, created_at and finding_url added, pinned byte for byte, and its chat messages are plain_text with one link (the renderers are in transports.py; monitoring/worker.py builds the message).tests/test_approval_notifications.py (AN1 to AN12), AY5, AY6, AY7; tests/test_notification_email.py::test_ay6_hostile_workspace_and_space_names_never_reach_a_header, tests/test_approval_delivery.py::test_ay6_the_drain_names_the_agent_by_handle_and_counts_a_partial_quorum, tests/test_notification_chat.py::test_ay7_facts_lead_with_the_deadline, tests/test_notification_transports.py::test_ay5_new_fields_never_change_the_webhook_body, AY9: tests/test_notification_email.py::test_ay9_each_close_out_email_says_how_the_approval_ended_and_nothing_more, tests/test_notification_chat.py::test_ay9_a_close_out_card_matches_the_checked_in_fixture, tests/test_notification_transports.py::test_ay9_the_close_out_webhook_body_keeps_its_documented_bytes, AY10: tests/test_notification_email.py::test_ay10_a_reminder_email_says_the_request_is_still_waiting, tests/test_notification_chat.py::test_ay10_a_reminder_card_is_the_announcement_still_needing_a_decision, tests/test_notification_transports.py::test_ay10_a_reminder_webhook_body_is_the_announcement_with_its_kind, FD2: tests/test_findings_messages.py::test_fd2_the_webhook_body_is_pinned_byte_for_byte, FD5: tests/test_findings_messages.py::test_fd5_hostile_workspace_and_space_names_reach_no_body_as_markup, ::test_fd5_slack_reads_a_name_it_could_format_only_as_plain_text, ::test_fd5_teams_reads_a_name_it_could_format_only_as_text_runs, tests/test_findings_delivery.py::test_fd5_no_string_a_source_reported_reaches_any_body, AY7: tests/test_notification_chat.py::test_ay7_slack_reads_a_name_it_could_format_only_as_plain_text, ::test_ay7_teams_reads_a_name_it_could_format_only_as_text_runs, ::test_ay7_either_name_alone_puts_the_card_in_text_runs, ::test_ay7_an_environment_teams_could_format_puts_the_card_in_text_runsVerified
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. Ending them asks for no password: the session is at most two days 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, else by its workspace number (Agent 3), with that handle beside an administrator's label so two agents labelled alike stay apart, and only a row with no number by 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 API's board takes utc_offset, a bounded integer of minutes that moves only the start of the day the counters count from and is refused as INVALID_REQUEST outside fourteen hours; a held shot carries its approval's expires_at as an instant. 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.CF4, CF5; tests/test_board_read.py, tests/test_console_api_board.py, tests/test_browser_console_board.py, tests/test_console_foundation.py; the hosted service's own testsVerified
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, of rules written with is_not, at_least and at_most at every value they can name, and of the Defender scenario's rules, 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/agents_pages.py, and the hosted plugin's own pages).RS10, RS12; hosted: RS11; tests/test_pack_rulesets.py::test_the_rules_read_over_every_input_as_the_kernel_decides_it, tests/test_rule_reading_operators.py::test_every_condition_of_the_other_operators_reads_as_the_kernel_applies_it, tests/test_rule_reading_operators.py::test_the_defender_scenario_rules_read_as_the_kernel_decides_on_every_input, 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 core's 24 addresses, and console_routes(plugins) adds, in a process whose plugins bring them, the workspace-scoped approval address where a plugin registered approval links and each plugin's own console pages (registry.console_path); tests/test_console_serving.py holds core's list equal to core's generated route tree, the hosted service's own tests hold the list with its plugin loaded equal to its route tree, and an address outside the list answers 404 rather than a shell that would render "not found" with a 200. The policy is chosen by matching that same list (console_matcher reads console_routes(plugins)) 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.tests/test_console_serving.py, tests/test_route_authority.py; the hosted service's own testsVerified
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.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.tests/test_human_surface.py::test_logout_invalidates_retained_cookie, tests/test_browser_surfaces.py::test_browser_logout_rejects_retained_session_cookiesVerified
T4.18DA 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 sharesEvery 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: a per-address cooldown, a per-domain cap with free-mail domains held to their own lower daily cap, an hourly and a daily cap overall, refusal of a disposable domain, and a honeypot field recorded as a refusal. Since 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 operator-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 (the hosted plugin's own code and pages). Since 2 October 2026, a Google or GitHub attempt at /signup/{provider} passes through the identical counts, under the identical fence, as an email submission; once the provider has proven the address, every page that follows is one of the emailed link's own pages (the generic page, the waitlist page, the naming form), so a provider flow opens no surface a stranger could mine differently from the one this row already describes.hosted: OS5, OS5b, OS5c, OS5d, OS14c; tests/test_browser_accessibility.pyVerified
T4.19IA prospect's email address escapes the operator's own list into an audit event, a log line, a page or a reportThe address is written by a fenced function into signup_requests in the operator tenant and read back only over the owner connection, by the operator's own tooling. 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 operator's reports carry only 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 (logging_schema.py, and the hosted plugin's own code).hosted: OS8, OS10, OS13Verified
T4.20S, DPassword guessing, credential stuffing or account enumeration at /loginA 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).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_thingVerified
T4.21SA forged, replayed or misdirected provider assertion signs the wrong person inEach 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. Microsoft's ID token is verified the same way against Microsoft's own pinned key set, with the issuer taken from the token's own tenant, the personal-account tenant refused, xms_edov required and the subject the tenant and object identifiers together (T4.85). 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. A flow started at /login still refuses an account matching no person and creates nothing there. A flow started at /signup is different: once a loaded plugin's signup point has checked that the subject identifies nobody and the address holds no account, it may record a request and create a new account for it, exactly as the emailed link already does, and never by attaching to an existing one. The subject is bound once and identifies the person afterwards, and a subject already bound to somebody else is refused rather than moved, on either path (oidc.py, github.py, microsoft.py, signin.py, migrations 0040_sign_in and 0091_microsoft_sign_in).tests/test_oidc.py, tests/test_github_sign_in.py, tests/test_microsoft_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; hosted: MI7Verified
T4.22E, IA set-password code reaches the wrong person, survives its use, or leaves the previous holder signed inA 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).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_refusedVerified
T4.23IResolving an address before a tenant is known reads more than that one addressThe 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_listingVerified
T4.24TA form submission that starts a provider flow is forged, or the redirect it follows is blocked or pointed elsewhereStarting 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_inVerified
T4.25EA person sets the session's current space to one they do not belong to, or a stale column grants a read after they were removedsession_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.pyVerified
T4.26IA record in another space is opened by its identifierMembership 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.pyVerified
T4.27TA decision lands while its space is being archivedThe 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.pyVerified
T4.28S, TA tenant's registered directory returns a forged, replayed or misdirected assertion, or a browser is walked into somebody else's flowOne 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, PS256 or ES256; each verifies only with a key of its own family, RSA for the first two and P-256 for ES256, because a directory's keys are filed by kid and family and a header never chooses how a key of another kind is used, and an ES256 signature is accepted only in its 64-byte JWS form; 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).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.py::test_a_family_a_warm_cache_lacks_reads_the_set_once_more_and_is_refused, tests/test_directory.py::test_an_algorithm_this_server_cannot_verify_is_refused_before_any_key_is_fetched, tests/test_directory.py::test_an_es256_assertion_verifies, tests/test_directory.py::test_an_algorithm_finds_no_key_of_another_family_under_its_kid, tests/test_directory.py::test_an_es256_signature_in_any_other_form_is_refused, tests/test_directory.py::test_an_ec_entry_this_server_must_not_use_is_skipped, tests/test_directory.py::test_the_signature_check_never_verifies_es256_with_a_p384_key, 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_nothingVerified
T4.29E, IAn invitation code reaches the wrong person, survives its use, or binds one account to two peopleA 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).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_wayVerified
T4.30IThe 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_pageVerified
T4.31SA 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 doesThe secret lives only in the URL fragment, which GET /signup/verify never reads server-side and the page's own script 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 (migrations/versions/0055_community_signup.py, and the hosted plugin's own code, pages and script). Closing signups does not cancel a link already queued for delivery: the delivery pass still sends it, and its completion now refuses under closed mode (SIGNUPS_CLOSED, checked 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.hosted: VS3, VS9, VS12, VS18, VS20, VS20b, VS32Verified
T4.32TTwo browsers, or one browser retried after a lost response, complete one verification link and end up with two workspaces or two accountscommunity_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 the hosted plugin's sign-in step 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 create form gets the "Your workspace is ready. Sign in." page instead, and a replay of the signed-in form gets the "Your new workspace is ready." page its first completion showed only when that workspace is in the signed-in account's own workspace menu, the ready page otherwise, because community_provision answers ALREADY on the token before it compares the account.hosted: VS8, VS10, VS12h, VS12iVerified
T4.33SAn email link is used to attach a Community workspace to, or take over, an account other than the one the address namesA 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: the link's inspection gives a tri-state answer that 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 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 provisioning at all, and the account proof calls check_session without allow_pending_mfa, so provisioning called 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.hosted: VS11, VS12gVerified
T4.34DA stranger exhausts admission capacity by submitting many addresses, or floods the verification/resend path once past the per-submission capsThe 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.hosted: VS4, VS4b, VS5, VS10Verified
T4.35SA 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 doesThe 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.pyVerified
T4.36SA teammate invitation link is used to take over an existing account, or to sign somebody in as a person other than the address it namesThe 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 the hosted plugin's 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 the hosted plugin's signup 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; the hosted service's own testsVerified
T4.37EAcceptance proceeds after the inviter's authority to grant those roles has been narrowed or removed, or after the invitation itself was revokedThe 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 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).TI1d, TI1e, TI6, TI11, TI12, TI12b; tests/test_teammate_invitations.pyVerified
T4.38IThe 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 administerworkspace_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. Some other tenant-agnostic passes instead hold their own operator credential and walk a configured tenant list, as the hosted plugin's signup sweep and the export drain do. The recovery directory, job_tenants (T4.46), is the second owner-only cross-tenant read, and the weekly summary's three (weekly_summary_tenants, weekly_summary_due, weekly_summary_sent, T4.56) are the others; like this one, each lists identifiers or a count only. This drain 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).TI1, TI1h, TI4; tests/test_teammate_invitations.py; the hosted service's own testsVerified
T4.39TTwo 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 outcomeworkspace_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).TI13a, TI13b, TI13c, TI13d; tests/test_teammate_invitations.py; the hosted service's own testsVerified
T4.40E, ISample targets added, refreshed or removed by someone other than an administrator, or added or refreshed over what a person built; their demo configuration read by someone else, or sending the agent key it is run with anywhere but the deployment's own mockThe three mutation routes, POST /api/console/v1/samples, /samples/refresh and /samples/remove, take the session, CSRF and exact Origin checks every mutation takes, each refusing a request without the CSRF token, one from another origin and one naming a field it does not take while changing nothing, and are an administrator's, and so are the status read, GET /api/console/v1/samples, and the demo configuration built from it, GET /api/console/v1/samples/demo-configuration, an attachment under no-store that names the sample targets and their incidents, holds no credential and passes the secret check before it leaves, a document the check refuses being answered 503 UNAVAILABLE and logged with no detail, and a download from an account session naming that session's generation, so a tab left on a workspace the person has since switched away from is refused 409 WORKSPACE_CHANGED rather than handed the new one's; that configuration names the endpoint mock that serves the samples, to which the demo's boundary beat sends the agent's key, and never the console in its place, whatever the console's origin, nor the mock at a loopback address, which is the gateway's own view of the mock and which nothing the gateway holds says the person's machine reaches (the Community bundle publishes the gateway's port alone and keeps its mocks on loopback inside its own network), execbound-demo refuses a file naming a loopback mock beside a gateway that is not on loopback before it sends anything, and an origin a file names that the demo machine cannot reach fails the boundary beat, which names it; the definer functions refuse anyone but a live administrator of the tenant; install refuses a workspace with a selected policy, an active connection or someone else's endpoint.isolate mapping; removal deletes nothing and refuses while a request on the sample account is unresolved; a refresh is a removal and an install in one transaction, refused before anything is removed while a bundle a person made names the sample account or a space other than the default one selects the starter, so while samples are present it is refused wherever either would be and a refusal from its install rolls its removal back, while with no samples it writes nothing and answers their status, as a removal does, and it checks the caller with the definer functions' own test before that read, so an administrator seated in a space is refused without learning whether such a policy exists.SM2, SM6, SM9, SM11; tests/test_console_api_samples.py::test_sm2_api_an_approver_sees_the_samples_but_cannot_add_or_remove_them, ::test_sm_api_the_routes_take_the_guards_every_mutation_takes, ::test_the_status_route_is_an_administrators, ::test_sm11_api_the_demo_configuration_is_an_administrators, ::test_sm11_api_the_demo_configuration_is_the_samples_as_the_demo_reads_them, ::test_sm11_api_a_demo_configuration_the_secret_check_refuses_never_leaves, ::test_sm11_api_an_account_session_downloads_only_with_its_own_generation, ::test_sm11_api_the_demo_configuration_names_the_mock_that_serves_the_samples, ::test_sm9_api_refresh_is_refused_while_a_persons_copy_of_the_starter_names_the_samples, ::test_sm9_api_an_administrator_seated_in_a_space_may_not_refresh_the_samples, tests/test_samples_schema.py::test_sm2a_the_install_function_refuses_anyone_but_a_live_administrator_of_the_tenant, tests/test_samples.py::test_sm9c_a_refresh_is_refused_rather_than_break_a_persons_policy_or_connection, ::test_sm9i_a_refresh_is_refused_while_a_second_space_selects_the_starter, ::test_sm11_the_demo_configuration_never_names_a_loopback_mock, tests/test_demo_command.py::test_sm11_a_loopback_mock_is_named_only_beside_a_loopback_gateway, ::test_sm11_an_origin_this_machine_cannot_reach_fails_the_boundary_beat_and_says_soVerified
T4.41E, TAfter a removed member rejoins through an invitation, an administrator reactivates the principal the invitation replaced, bringing back its old grants and approval eligibility as a second live person for one address that no membership points at, counted by access_has_permission and by the LAST_ADMINISTRATOR check; or a reactivation racing the acceptance leaves both principals liveThe acceptance marks the old principal with replaced_by when it re-points the membership, and the check principals_replaced_retired forbids a replaced principal from ever being active, so no path, the owner's included, can bring one back. admin_reactivate_principal refuses one with PRINCIPAL_REPLACED right after its row lock and before any other effect, and the People function, the console route (409 with its own sentence) and the hosted service's own owner command that wakes agents all name that refusal rather than reporting unavailability. The new principal stays fresh, so an invitation never restores what the old one held. The race is closed by lock order: the acceptance takes the tenant fence and then the old principal's row before deciding, the same order reactivation takes them, so whichever commits first wins and the other reads its outcome (ALREADY_MEMBER or PRINCIPAL_REPLACED), never a check violation or a deadlock (migrations/versions/0061_replaced_principals.py, surfaces/people_pages.py, surfaces/console_api/people.py, administration.py).TI37, TI38, TI39, TI40, TI41, TI42, TI43, TI44; hosted: TI45; tests/test_teammate_invitations.pyVerified
T4.42SA stolen personal access token acts as its owner until it expires, is revoked, or its owner loses the roleThe token is opaque (ebp1.<tenant>.<token>.<secret>), looked up by the tenant and token id parsed from the string itself, its SHA-256 digest then checked against the stored one with hmac.compare_digest; the exceptions below (T4.43 to T4.45, T5.24 to T5.26) narrow what it can do while it is live, a rate limit of 300 requests a minute of its own bounds abuse, last_used_at is stamped at most once a minute so a theft leaves a trace, and the owner or any administrator of the workspace can revoke it from Workspace settings, taking effect on the token's own next request rather than needing to find the thief first (personal_tokens.py, token_rules.py). Every audit event a token causes names its backing credential as actor_credential_id, which maps one to one to the token. Acknowledging a monitoring finding is one of them: it appends monitor.finding_acknowledged naming the backing credential as actor_credential_id, like every other administration event, so an acknowledgement made through a token can be told from one its owner made in the browser (monitoring/store.py). An acknowledgement is final, so acknowledging many findings in one request is refused to a token (token_rules.TOKEN_REFUSED) and needs a browser session and an inline confirmation naming the count. The residual stands: a stolen token can still acknowledge findings one per request, permanently; each is attributable to the token in the chain, and the remedy is revoking the token and reading the trail, not undoing the acknowledgement.PT1, PT2, PT6, PT10, MQ2, MQ14; tests/test_personal_tokens.py::test_a_revoked_wrong_or_garbled_token_is_refused, tests/test_personal_tokens.py::test_personal_token_used_stamps_and_throttles_to_once_a_minute, tests/test_console_api_tokens.py::test_another_administrator_revokes_any_token_in_the_workspaceVerified
T4.43EA token, or an agent holding one, decides its own pending approval, or reaches one by proxy through a person the token creates and signs in asPOST /approvals/{approval_id}/decision and POST /executions/{execution_id}/reconcile are in token_rules.TOKEN_REFUSED, answered 403 TOKEN_NOT_ALLOWED and audited before a token session ever reaches the kernel. A second barrier holds even without that entry: a decision needs the signed continuation the approval page issues, bound to a browser session's id and CSRF token, which a token's authority never has. Approval by proxy through a created person is closed by T5.25 below, which refuses every write under /people, /access and /tenant/sign-in to a token (token_rules.py, surfaces/console_api/guards.py).PT4; tests/test_console_api_tokens.py::test_a_token_is_never_offered_an_approval_continuation, tests/test_console_api_tokens.py::test_a_token_is_never_offered_a_reconcile, tests/test_route_authority.py::test_token_allowed_writes_never_touch_people_access_or_sign_inVerified
T4.44TCross-site or forged form submission against a personal access token, or a request that carries a session cookie and a bearer token togetherA bearer request carries no ambient credential a browser attaches on its own, so CSRF, the exact Origin check and the workspace-generation header do not apply to it and are not needed to defend it: guards.py's session() and mutation() take the bearer branch before any of the three and skip them, while the Host pin, the query allowlist, the identity-header refusal and the body size and shape rules stay for every caller. A request presenting both a session cookie and Authorization: Bearer is refused rather than one being preferred over the other (surfaces/console_api/guards.py).PT8; tests/test_console_api_tokens.py::test_a_cookie_and_a_token_together_are_refused, tests/test_console_api_tokens.py::test_a_token_still_meets_the_boundary_and_body_rulesVerified
T4.45EA console API route added later is reachable by a personal access token before anyone decides whether it should beEvery route core serves under /api/console/v1 (the CONSOLE, AUTHORITY and ASSISTANT collections tests/test_route_authority.py holds) must appear in exactly one of token_rules.TOKEN_ALLOWED or TOKEN_REFUSED, and every route a plugin's console areas add (the sample plugin's PLUGINS, and the hosted plugin's own list in its tests) in exactly one of the two sets that plugin registers with token_routes. A plugin can never classify any method of a path core classifies, compared by shape so that renaming a parameter makes no new path, and core's answer is decided before a plugin's is read (token_rules.CORE_SHAPES), so a plugin can never loosen one of core's. The guard checks core's table and then the loaded plugins' before dispatch and answers TOKEN_NOT_ALLOWED for anything in neither, so the default fails closed, and the route inventory tests fail until a new route is classified one way or the other (token_rules.py, plugins.py, surfaces/console_api/guards.py).PT9; tests/test_route_authority.py::test_every_console_api_route_says_whether_a_token_may_call_it, tests/test_plugins.py::test_token_routes_are_the_plugins_own_console_routes_and_methods, tests/test_plugins.py::test_a_plugin_cannot_allow_any_method_of_a_core_path, tests/test_plugin_surfaces.py::test_a_token_calls_the_route_a_plugin_allows_and_no_other; the hosted service's own testsVerified
T4.46IThe directory of tenants is used to read across tenantsjob_tenants holds a tenant id and the time it was added, nothing else. Its one cross-tenant read is the owner_list policy, granted TO CURRENT_USER: the role that ran migration 0064_scheduled_jobs and owns the definer function job_tenants_page, never execbound_app. The runtime role holds no privilege on the table and reaches it only through that function's EXECUTE. Before a pass lists anything, jobs.check_directory_policy requires exactly two policies on the table, tenant_isolation, permissive for every command, and owner_list, permissive SELECT for the function's owner with USING (true), and anything else stops the pass with UNSAFE_JOB_POLICY before any workspace is visited: a widened policy is refused, and a narrowed or restrictive one cannot make the pass silently visit nothing. The pass reads nothing else across tenants; each workspace's work runs in that workspace's own fenced transactions. A directory was chosen over cross-tenant policies on the work tables because such a policy would let every definer function owned by the same role read every tenant's executions and assistant turns (migrations/versions/0064_scheduled_jobs.py, jobs.py).SJ1; tests/test_scheduled_jobs_schema.py::test_sj1_the_runtime_role_cannot_read_the_directory, ::test_sj1_the_backfill_and_the_list_work_for_an_owner_without_bypass, tests/test_scheduled_jobs_cli.py::test_a_changed_directory_policy_stops_the_passVerified
T4.47SThe scheduler principal is used to authenticate or is granted authorityA workspace's scheduler principal is kind SYSTEM, labelled "ExecBound scheduler", with an id derived from the workspace's own (the MD5 of execbound.scheduler: and the tenant id, as a UUID); the check principals_system_id ties the kind to that id in both directions and a partial unique index keeps one per workspace. The database refuses it rather than each function remembering to: the invoker trigger principal_guard is attached BEFORE INSERT OR UPDATE OF every column execbound.principal_columns.GUARDED names, every column through which a principal authenticates or gains authority (among them credentials, sessions, tokens, passwords, factors, passkeys, identity links, memberships, roles, groups, bindings, review scopes, approval decisions and replaced_by), and it compares the column with the derived id and reads no table, so it holds in a table fenced by account as in one fenced by tenant, and for an owner that does not bypass row security. principals_system_frozen refuses any change to a SYSTEM row and any change of kind to or from SYSTEM; access_effective_kind answers SYSTEM for it and access_has_permission false, even past a grant; the owner CLI's principal lookup and the API Keys list leave it out. A catalog test enumerates every foreign key to principals and every column named for one and fails on a column principal_columns classifies as neither guarded nor unguarded with a reason, so a new column fails closed until someone decides (migrations/versions/0064_scheduled_jobs.py, principal_columns.py, administration.py, surfaces/admin_pages.py).SJ8, SJ9; tests/test_scheduled_jobs_schema.py::test_sj8_the_guard_needs_no_fence_for_an_owner_without_bypass, ::test_sj8_no_change_to_the_scheduler_principal_is_accepted, ::test_sj8_an_administrator_grant_gives_the_scheduler_principal_nothing, ::test_sj9_every_principal_column_is_classified, tests/test_scheduled_actor.py::test_sj8_the_owner_cli_and_the_console_refuse_the_scheduler_principalVerified
T4.48S, EA stolen or unattended signed-in browser changes people, access, sign-in, credentials, approval rules, approver assignments, a space's membership, a password or tokens, activates a policy or switches the workspace to observe-only, or the step-up's window is stretched or skipped by the console's in-place verificationEvery console mutation under guards.STEP_UP_PREFIXES, under a guards.STEP_UP_NESTED pattern (an agent's credentials, a space's membership, activating a policy at /policy/{bundle_id}/activate, and a monitoring connection's key and its revocation, beside the /monitor/connections/tines and /monitor/connections/entra/ prefixes that connect Tines and start a Microsoft Entra consent, and the /monitor/sources prefix that mints, rotates and revokes a reporting source), or at a guards.STEP_UP_EXACT path (the samples routes that install and activate the starter, /samples and /samples/refresh, exact because /samples/remove only tightens) calls mfa.require_recent_factor; since a decision of 8 October 2026 that includes activating a policy and changing the enforcement mode (/tenant/mode), in both directions because the sets are path-based and checked before the body is read, while the emergency stop (/tenant/stop) stays outside them as the immediate tightening control, and so does its release (/tenant/resume); SU15 holds both outside, with the mode refused in the same stale session. Every prefix, pattern and exact path is held to a route core serves, since one that matches nothing guards nothing and fails no request (the directory's /tenant/sign-in sat unguarded under a /sign-in/ prefix until 2 October 2026); which, for a person with a factor enrolled, refuses MFA_REQUIRED unless that factor was verified on the session within mfa.STEP_UP_WINDOW, thirty minutes since 28 September 2026 (five before); every other recent-factor check reads the same constant, and account_security_change and user_security_change recheck the same thirty minutes in fresh (0067_step_up_window), so the security page's changes and an account's password are refused beneath the Python check too. The console asks for the code in place through POST /api/console/v1/session/verify, which takes the session cookie, the exact Origin, the CSRF header, JSON and the workspace generation and refuses a personal access token; it verifies with mfa.verify_factor, the security page's own call (single-use time steps, recovery digests, the five-attempt lock across sessions, account_security.* events), and rotates the session and CSRF token with mfa.rotate_verified_session, so a refused change resent with the old token is refused INVALID_CSRF. A session still at the second step of signing in is refused there and keeps /mfa. The client resends a refused change once and never asks twice for it. First-time setup and provider linking keep the five-minute sign-in age. A saved passkey satisfies the same check through POST /api/console/v1/session/verify/passkey/options and POST /api/console/v1/session/verify/passkey, under the same guards, functions and window as the code (T4.67). The question's first line names the refused change from a fixed table of route templates with numeric handles only, never a label, name, email or agent string; the line is informational, since the verification it asks for is session-wide and covers every protected change for thirty minutes, as the code's always has. The longer window is a deliberate trade: a person at an unlocked, signed-in browser within thirty minutes of a code can make these changes without another; the twelve-hour idle limit and the two-day cap on a session still bound it. A person with no factor enrolled is never asked, so in a workspace whose policy is optional the step-up protects nobody who has not enrolled; requiring MFA for administrators or for everyone is what closes that. An administrator without a factor is told so: My account states their own factor status, Users and the person page show every person's (T5.37), and one dismissible Home card states the effect where the deployment has account security. Assigning yourself from an approval page moves an existing review of the same action and target from another space, and the dialog says so before the writetests/test_mfa_sessions.py::test_a_verified_factor_steps_a_session_up_for_thirty_minutes, ::test_the_database_holds_the_same_thirty_minute_window, ::test_first_time_setup_still_needs_a_sign_in_within_five_minutes, tests/test_step_up_window_migration.py::test_only_the_recent_factor_interval_moves_and_the_downgrade_restores_it, tests/test_console_api_step_up.py::test_the_token_the_origin_and_the_body_are_checked_first, ::test_a_wrong_code_says_so_and_changes_nothing, ::test_five_wrong_codes_lock_verification_as_the_page_does, ::test_a_current_code_rotates_the_session_and_the_change_goes_through_resent_once, ::test_a_session_still_signing_in_is_left_to_the_mfa_page, ::test_an_account_changes_its_password_twenty_minutes_on_and_after_stepping_up, tests/test_browser_console_step_up.py::test_an_invitation_asks_for_a_code_over_the_users_page_and_is_sent_without_leaving, tests/test_console_api_step_up.py::test_every_step_up_prefix_covers_a_change_core_serves, tests/test_console_api_sign_in.py::test_registering_or_switching_a_provider_asks_for_a_recent_factor, tests/test_console_api_step_up.py::test_who_approves_and_who_acts_in_a_space_ask_for_a_recent_factor, ::test_activating_a_policy_and_changing_the_mode_ask_for_a_recent_factor, ::test_su15_the_emergency_stop_is_set_and_released_without_a_recent_factor, tests/test_browser_console_approvals.py::test_assigning_yourself_asks_for_the_factor_and_then_the_decision_lands, tests/test_console_api_approvals.py::test_assigning_yourself_moves_a_review_from_another_space, tests/test_console_api_step_up.py::test_a_passkey_rotates_the_session_and_the_change_goes_through_resent_once, tests/test_console_step_up_purposes.py::test_su_every_core_step_up_route_has_a_purpose, tests/test_console_api_monitoring_connect.py::test_mc13_connecting_replacing_a_key_and_revoking_ask_for_a_recent_factor, tests/test_console_api_monitoring_sources.py::test_mc13_minting_rotating_and_revoking_ask_for_a_recent_factor, tests/test_browser_console_agents_account.py::test_su14_a_passkey_steps_up_a_key_revocation_over_the_agent_page, ::test_ak11_people_settings_and_home_show_second_factor_status, tests/test_console_api_settings.py::test_ak10_my_account_states_the_factor_its_passkeys_and_recovery_codes; SU1 to SU15, AK9 to AK11, AQ6, AQ8, MC13Verified
T4.49S, E, IA Google, GitHub or Microsoft flow started at /signup records a request or creates an account outside the controls, the concurrency guarantees or the privacy the emailed link already holds itself to; or a signup intent completes an ordinary sign-in or a provider link, or the reverseCore knows one new thing, an intent cookie: start_provider_flow sets provider_signup to the SHA-256 of the flow's own state only when a page calls it with signup=True, and every other start (/login, /account/link) deletes it. The callback reads it once, and dispatches to a loaded plugin's ProviderSignup.finish only on an exact digest match; a mismatch, or no point registered, is refused SIGNUP_REFUSED before the plugin ever runs, so a flow that began as a sign-in or a link can never complete as a signup and the reverse is equally refused — provider_link is checked first and unconditionally. finish's own rule order checks whether the subject already identifies someone (subject_known, live or revoked) before anything else, even closed mode or the held-address check, so an already-bound person always reaches the ordinary sign-in rather than a second account. From there the hosted plugin reuses the email path's own admission and completion exactly: the same signup_request caps (T4.18 above) admit a fresh request, the same signup_provider_attach capacity count and the same community_provision/community_provision_provider lock order (operator fence, account, tenant) create it, so a provider attempt can create a new account and never attach to an existing one, the address's current holder meets the sign-in-first page, and completion_subject IS NULL keeps a plain link from ever completing through the provider path or the reverse. On real PostgreSQL, a provider completion racing the link's own completion, two provider completions, a provider admission racing an email submission, a provider completion racing invitation acceptance and a subject racing a concurrent link each resolve to exactly one account, one allocation and one binding. No page, log line, event or export carries the address, the subject, the authorization code or the state; the completion's own one-time token lives only in the signup_completion cookie, and the request row holds no subject once provisioned, waitlisted, replaced or lapsed. Core without the hosted plugin loaded registers no intent cookie and behaves exactly as before, and every provider's redirect URI is its existing /login/{provider}/callback (plugins.py, surfaces/sign_in_pages.py, the hosted plugin's own code, migration 0069_provider_signup.py).hosted: PS1 to PS14, PV1 to PV35, PW1 to PW14, VS19b, MI7, MI8, MI9; tests/test_plugins.py, tests/test_sign_in_surface.py, tests/test_sign_in_store.pyVerified
T4.50S, ESomebody holding a stolen or unattended signed-in browser adds a first password to an account that signs in only with Google or GitHub, and keeps a way in after the provider account is recovered; or the same door replaces a password the account already hasset_first_password asks provider linking's bar, and user_security_change's password_set (migration 0070_first_password) asks it again beneath the Python check, so a caller that skips the check meets the same refusal: an account session signed in within five minutes, a factor verified within mfa.STEP_UP_WINDOW when one is enabled, and a verified address. A browser left signed in for longer than five minutes cannot add one. The function inserts only where no user_passwords row exists (ON CONFLICT DO NOTHING, then PASSWORD_EXISTS) and has no update path, so it can never replace a password; replacing one stays change_password, which demands the current password. A workspace-only session reaches no account and is refused. The password is hashed exactly as every other one, compared as bytes on the page, and never written to a page, a log line or an event: account_security.password_set carries the session id alone. Like every other change to how the account signs in, it moves the account's version and ends its other sessions, so a session taken earlier does not outlive it. What this does not defend is a session stolen within five minutes of a fresh sign-in on an account with no factor, which linking a provider already permits too (passwords.py, surfaces/mfa_pages.py)FP1 to FP7; tests/test_first_password.py, tests/test_first_password_page.py, tests/test_first_password_migration.pyVerified
T4.51S, IA person mistakes one principal for another on a console page (two share a label, a label imitates another's handle, or a number is read as meaning the same principal in another workspace), or a principal's name or number reaches an agentNumbers are unique per workspace and class (the 0058 and 0062 indexes) and are display handles only: every reference, authorization and audit record keeps the UUID. Wherever a label is shown its handle sits beside it. References are read by surfaces/names.py's principal_facts inside the tenant fence, with the tenant named in the statement, for ids taken from rows the caller already read in scope, never from a request. /session names the administrator who activated a stop only to an administrator's session; an approver's reads null. The palette finds a person only for an administrator holding access.manage, inside the workspace and never by email, and an agent by number only inside the administrator's space view. Links are built by the router from ids and offered only to a reader who can open the page. No agent-facing answer carries a number, a kind or a principal's name, and the agent OpenAPI document is unchanged. Handle-shaped labels (an agent labelled "Agent 1" while another is Agent 1) are not refused; the handle beside every label keeps them apart on screenCF6 to CF12; tests/test_principal_names.py, tests/test_console_foundation.py, tests/test_browser_console_foundation.pyVerified
T4.52IThe value report, its CSV or the weekly summary counts executions, approvals or refusals beyond the reader's authorityOne read, value.read, with the approver scope clause written out in each statement and the space helper's predicate at its marker; the session path rechecks identity and session before and after inside one transaction, and the CSV takes the session's generation, so a stale tab gets 409 WORKSPACE_CHANGED rather than another workspace's counts. The summary is computed at send time under the recipient's current authority: an active ADMIN or HUMAN_APPROVER of an active tenant, review_scoped, review_read_spaces and access_visibility's whole visible set. Refusals before a decision carry no target or space, so they are counted only for a reader not narrowed to approval scopes and with no space filter, and are null for everybody else. Every count with an Activity link is the predicate that list filters on, outcomes.COUNT_COLUMNS (surfaces/value.py, surfaces/outcomes.py, surfaces/console_api/report.py, weekly_summary.py)HV6, HV7, HV8, HV13; tests/test_home_value.py::test_hv7_the_report_counts_only_what_its_reader_may_read, tests/test_console_api_report.py::test_hv8_the_csv_follows_the_workspace_the_tab_is_showing, tests/test_weekly_summary.py::test_hv13_a_subscriber_gets_one_summary_with_their_own_countsVerified
T4.53IThe weekly summary carries an agent, provider, target or person string, or a controlA fixed template of counts (transports.render_weekly_summary, the WeeklyCounts it takes holds integers only); the workspace name through name_label, and the tenant id in the line naming the workspace to choose only where that name cannot be shown as written; one link to the console's origin and the one-click unsubscribe link; no approval, execution or person identifier; no control. A refusal is described as a count of what was recorded, never as harm preventedHV13, HV17; tests/test_weekly_summary.py::test_hv13b_the_summary_carries_counts_and_nothing_else, ::test_hv13i_a_name_nothing_of_which_survives_is_left_out_and_chosen_by_tenant_idVerified
T4.54S, TThe weekly summary's unsubscribe link is guessed, replayed, prefetched or used to learn who subscribesA 256-bit secret per message, kept only as its SHA-256; GET reads and changes nothing; every POST gets the same status and body, a nonexistent workspace's included, and no fence or function refusal reaches a 500; a per-message rate limit beside the per-peer one, which behind the hosted proxy bounds the whole deployment rather than one caller; a link matches only a message queued since the subscription's current subscribed_at; Referrer-Policy: no-referrer and no-store, because RFC 8058 puts the secret in the path rather than a fragment, and no-referrer on every 400 and 429 too, whose pages load the stylesheet and link to sign in from that same address; a database fault while stopping answers the same page and is logged at ERROR with a fixed code and no ids, while a forged, unknown or malformed link logs nothing, so a link cannot fill the error log; the request log records no path, and ebw1 is one of the secret shapes the log and export guards refuse. Accepted: anyone holding a recipient's email can end that recipient's summary, which is RFC 8058's design and harms nothing but the summary (surfaces/summary_pages.py, weekly_summary.py, migrations/versions/0078_weekly_summary.py)HV11, HV14; tests/test_summary_pages.py::test_hv14_one_click_ends_only_its_own_subscription_and_answers_every_request_alike, ::test_hv14_an_old_link_cannot_end_a_renewed_subscription, ::test_hv14_both_limits_answer_too_many, ::test_hv14_an_error_page_sends_no_referrer, ::test_hv14_the_request_log_names_no_path_and_the_stop_only_its_ids, ::test_hv14_a_stop_the_database_refuses_is_logged_and_answered_alike, tests/test_weekly_summary_schema.py::test_hv11e_unsubscribe_ends_only_a_matching_active_subscriptionVerified
T4.55TA browser's clock decides that an approval is still waiting or has lapsedThe overview, the board and the execution views classify an approval past its deadline on the server's clock, bound as :now, with the two predicates outcomes.PREDICATES holds (lapsed, approved_unused), and write nothing: the recovery run alone moves an approval to EXPIRED. The console reads approval.lapsed and approval.unused and never compares a deadline itself outside the approvals queue's between-polls check (surfaces/dashboard.py, surfaces/board.py, surfaces/console_api/executions.py)HV1, HV3, HV4; tests/test_home_value.py::test_hv1_a_lapsed_approval_reads_as_lapsed_before_and_after_the_sweep, ::test_hv3_the_board_counts_a_lapsed_request_apart_from_a_held_one, ::test_hv4_the_record_says_its_approval_lapsed_by_the_servers_clockVerified
T4.56IThe weekly summary's owner-only cross-tenant reads, weekly_summary_tenants, weekly_summary_due and weekly_summary_sent, are used to read or send from a tenant the caller does not administerowner_drain is FOR SELECT TO CURRENT_USER, the owner of the definer functions, never execbound_app; the three return tenant ids, message ids or a count, never an address, a name or a count of a tenant's activity; every write, the enqueue included, happens inside one tenant's own fence and transaction, and the enqueue and the unsubscribe refuse TENANT_REQUIRED without one. Proven under an owner without bypass, as the hosted database's is (migrations/versions/0078_weekly_summary.py, weekly_summary.py)HV11, HV13; tests/test_weekly_summary_schema.py::test_hv11c_restricted_enqueue_works_per_tenant_as_an_owner_without_bypass, ::test_hv11d_due_sent_and_tenants_reach_every_tenant, tests/test_weekly_summary.py::test_hv13h_the_drain_delivers_under_an_owner_without_bypassVerified
T4.57S, EAn administrator who is not assigned to an approval decides it through Waiting on others, its detail or an administrator's Lapsed view, or obtains a decision token from themWaiting on others (view=others), an administrator's view=lapsed and the detail of an approval outside the reader's review scope are reads only, for an ADMIN session only, inside records()'s tenant fence, identity and session rechecks and space bound; a named record is read by membership, a list by the current view. records() itself refuses others with INVALID_IDENTITY to any reader but an administrator reading outside their own review scope, so the route's FORBIDDEN is not the only barrier. _detail mints a continuation only for a read through the reader's own review scope, never for a token session and never past the session's end. The decision route is unchanged: the continuation is bound to the session, its CSRF token, the approval and the plan; the scope read is records(..., reviewing=True); authority_guard and decide_approval run as before; a borrowed continuation is refused INVALID_CONTINUATION and audited. The reviewer counts an administrator sees are computed for the rows records() has just returned, never for a caller's list, and are null for every other reader (surfaces/human.py, surfaces/console_api/approvals.py)AQ1, AQ2, AQ3; tests/test_console_api_approvals.py::test_records_refuses_others_to_anyone_but_an_administrator_reading_outside_review, ::test_an_administrator_sees_approvals_waiting_on_others_and_cannot_decide_them, ::test_waiting_on_others_is_an_administrators_view, ::test_waiting_on_others_stays_inside_the_administrators_spaces, ::test_an_administrators_lapsed_view_covers_every_reviewerVerified
T4.58TA person decides an approval in the wrong workspace or space by their own mistake, for instance an analyst who works for several customers approving containment on a phone whose top bar hides the workspaceThe approval's eyebrow names the workspace, and the space where the tenant has more than one; the decision bar's "Deciding in" line names them on a phone always and on a wider screen wherever the person can hold more than one workspace or space; Approve's accessible name carries the workspace; the server binds every approval to its tenant and decides it in its own space, unchanged. Display reduces this mistake and cannot remove it (console/src/approvals/DecisionBar.tsx, DetailView.tsx)AQ5; tests/test_browser_console_approvals.py::test_the_phone_bar_names_the_effect_the_time_and_the_workspacePartial
T4.59IThe approval comment, or a new policy document, kept across signing in again is read by the next person at a shared browser, holds a secret pasted by mistake, or is sent with another approval's decisionThe draft lives in that tab's sessionStorage alone, never in localStorage, the query cache, an address or a log, bound to the principal who typed it and the approval's deadline, and restored only for that principal before that deadline. It is removed on a decision the server accepted, on a refusal that ends the approval, on the kernel refusing the comment as secret-shaped (after which nothing more is kept for that approval until the field is emptied, so refused text does not return to storage on the next keystroke) and on signing out; expired drafts are swept when the store loads. The comment field is rendered once per approval, so one approval's text is never kept under, or sent with, another's decision. The new policy document is kept the same way under one key, bound to the principal who typed it, expiring thirty minutes after its last write, not kept past 64 KB, removed when the bundle is created, on a refusal naming the document and on signing out. No show-once value can reach the store: only four named files and core's tests may import it, statically or lazily, and lint refuses it everywhere else, show-once holders and hosted files included (console/eslint.config.js, fixtures in console/scripts/bans.test.ts). CSP with Trusted Types and no third-party script keeps other scripts from reading it (console/src/lib/drafts.ts, console/src/approvals/DetailView.tsx, console/src/components/policy/BundleList.tsx, console/src/shell/SignOut.tsx)AQ7; SN17; tests/test_browser_console_approvals.py::test_a_typed_comment_survives_signing_in_again; the Vitest cases in console/src/approvals/drafts.test.tsx; R39Partial
T4.60I, SA copied approval link carries more than identifiers, points off this deployment, or points at another approvallink_path is built by core (/approvals/{approval_id}) or by a registered plugin's approval-link builder from the tenant and approval ids alone. The console accepts it only when it matches the approval grammar and ends with this approval's id, and otherwise builds the core address; it always prefixes the page's own origin and never reads the address bar (surfaces/console_api/approvals.py, console/src/approvals/link.ts)AQ8; hosted: AQ10; tests/test_console_api_approvals.py::test_the_detail_carries_the_cores_approval_link; the Vitest cases in console/src/approvals/pages.test.tsxVerified
T4.61TA source-reported or directory-supplied string (a story identifier, a display name, a label) shaped as a link, a command or markup reaches the Monitoring pages as oneMonitoring's search values are checked against fixed lists and patterns in the route's component and again in each view (parseMonitorSearch), and a value outside them is dropped; the route's validateSearch passes them through unchecked so the checks stay out of every page's first load, and nothing reads the route's search except through that parse; every link is built by the router from a constant path and identifiers or validated values; the self-hosted command that reports a gap story interpolates a story identifier only when it matches the pattern create_source enforces, does not begin with - and fits the label limit, in --option=value form with a fixed label and never the story's name; every label, name, title and reference renders as text (console/src/components/monitor/search.ts, Inventory.tsx)MQ6, MQ10; tests/test_console_api_monitoring.py::test_mq6_markup_in_labels_travels_as_data; Vitest console/src/components/monitor/search.test.ts, MonitorPage.test.tsx, Findings.test.tsxVerified
T4.62DThe console's re-checking of a new monitoring connection's evidence becomes a load source, or keeps checking behind a hidden tabThe setup's Verify step re-sends the existing check every 10 s only while its dialog is open, the tab is visible and the last answer for that connection was awaiting_sync, never while a check is out, and stops at a terminal answer, an error, Stop listening or ten minutes of wall time; it goes through the existing mutation with its CSRF and session checks, reads persisted evidence only and contacts no provider. The new findings read sets no timer: invalidation and window focus refresh it (console/src/components/monitor/listening.ts, MonitorSetup.tsx)MQ8; Vitest console/src/components/monitor/listening.test.ts, MonitorSetup.test.tsxVerified
T4.63IA Space's setup state (its policy, its agents' keys, its reviewers, its first records) is read by someone who cannot see or administer that Space, or by another tenantThe setup read is an administrator's: the guard's ADMIN kind, then one fenced transaction that checks the identity as an administrator's, the session, the Space and the identity again. The Space is the session's write Space held to the reader's visibility and to the ADMIN role in it, so a person seated there as a reviewer is refused; a personal access token's named Space is held to its owner's visibility; every query binds the tenant and the Space; the answer carries identifiers and references, never a hostname, a target or a label (getting_started.setup_state, surfaces/console_api/setup.py)UX9; tests/test_console_api_setup.py::test_the_setup_route_is_an_administrators, ::test_the_setup_route_refuses_a_bearer_credential_and_a_missing_session, ::test_all_spaces_in_a_workspace_with_two_reads_choose_space, ::test_an_administrator_seated_as_a_reviewer_may_not_read_a_spaces_setup, ::test_a_token_reads_its_named_space_and_is_refused_one_it_cannot_see; tests/test_setup_progress.py::test_records_in_another_space_tick_nothing_hereVerified
T4.64T, RThe console shows a setup step as done, or a Space as ready, without the record that makes it true: a click, a value in the address or browser storage ticks a stepThere is no write route: each step is a read of its record at request time and carries that record's identifier; the policy, the agent's live key and the reviewer are current state and un-tick when their record stops holding; a policy that can hold a request for review keeps the reviewer and the first decision owed, through an approval policy or a REQUIRE_APPROVAL limit; while the workspace observes, the first decision is owed and never next; the hidden card is a display preference that ticks nothing (getting_started.setup_state, console/src/components/welcome/setup.ts, console/src/shell/StartedCard.tsx)UX9, UX10, UX11; tests/test_setup_progress.py::test_a_live_agent_key_ticks_and_a_revoked_or_expired_one_does_not, ::test_a_limit_that_requires_approval_keeps_review_applicable, ::test_samples_tick_nothing_outside_the_default_space, ::test_observing_with_no_decision_leaves_the_first_decision_needing_enforcing, ::test_the_read_writes_nothing; Vitest console/src/components/welcome/setup.test.ts, WelcomePage.test.tsx, console/src/shell/StartedCard.test.tsxVerified
T4.65T, IA Tines story the console serves carries a credential, a workspace's origin or tenant id, or differs from the reviewed committed fileThe console build copies the two committed story files and integrations/LICENSE unchanged and fails without them; no story is rendered, templated or given an origin, a token or a tenant id; the committed files are held to no secret, no origin and no tenant by the story tests; the license travels beside them in the wheel and is served to no browser (console/scripts/stories.ts, scripts/check_wheel_licences.py)UX14; tests/test_tines_story.py::test_the_story_carries_no_secret_no_origin_and_no_tenant, tests/test_tines_gate_story.py::test_the_gate_story_carries_no_secret_no_origin_and_no_tenant, tests/test_console_serving.py::test_the_packaged_build_carries_the_committed_tines_stories_unchanged, ::test_the_story_licence_is_packaged_but_not_served, tests/test_wheel_licences.py::test_a_server_wheel_without_or_with_another_tines_stories_licence_is_refused; Vitest console/scripts/stories.test.tsVerified
T4.66TA crafted console link opens a setup step or dialog with content an attacker chose?step= and ?setup= are checked against fixed lists in the route's component (parseStep, parseMonitorSearch), because the routes' validateSearch pass the search through to keep the checks out of every page's first load; anything else reaches the page as no value; the steps and the setup dialog render fixed copy, and the dialog takes only the validated provider name (console/src/routes/welcome.tsx, console/src/components/welcome/steps.ts, console/src/components/monitor/search.ts)UX10, UX18; Vitest console/src/routes/-welcome.test.tsx, console/src/components/welcome/setup.test.ts, console/src/components/monitor/search.test.ts, MonitorPage.test.tsx; tests/test_browser_console_setup.py::test_setup_pages_are_accessible_in_both_schemes_and_at_390Verified
T4.67S, EThe in-place step-up is satisfied by a passkey assertion made for another session, origin or account, replayed, or without user verificationThe two routes are session mutations under the cookie, the exact Origin, the CSRF header, JSON and the workspace generation, and refuse a personal access token. They call passkeys.begin and passkeys.complete, the account security page's own functions. The 32-byte challenge is held per session for five minutes and consumed by the first finish attempt that finds it, whatever its outcome, so a replay finds no usable challenge. The RP ID and origin are pinned to the deployment origin, user verification is required, the credential must be saved to this account with a matching user handle, and the sign count is checked. An assertion that fails verification counts toward the five-attempt lock and is audited (account_security.verification_failed, method passkey); a missing, spent or expired challenge and a locked factor are refused without either. Success rotates the session and CSRF token with mfa.rotate_verified_session, as a code does. The purpose line comes from a fixed table and handles only and is informational.SU11 to SU14; tests/test_console_api_step_up.py::test_an_assertion_that_fails_verification_counts_and_is_audited, ::test_a_spent_or_missing_challenge_is_refused_without_counting, ::test_the_lock_refuses_a_valid_passkey_silently, ::test_the_passkey_routes_check_the_request_first, tests/test_browser_console_agents_account.py::test_su14_a_passkey_steps_up_a_key_revocation_over_the_agent_page (Chromium with a virtual authenticator over https://localhost)Verified
T4.68IUntrusted strings reach the document title, browser history or a shared screenThe title is composed only of the console's own nouns, the workspace and space names, the waiting count and title words from constructors (titleRef, titleNumber, titleShort, titleNoun in console/src/shell/page-name.ts) that accept a request reference, a principal's positive number, the first eight characters of a hexadecimal id or a fixed noun, so a hostname, a target, an agent's label or a provider's display name in the title is a type error; document.title is text, not a sinkNV1; console/src/shell/title.test.tsx, console/src/shell/page-name.test.tsx, tests/test_browser_console_shell.py::test_nv1_titles_name_the_page_and_never_a_server_stringVerified
T4.69IA client error report carries page data, identifiers or message text into the operator's logsThe console sends the route's pattern, a hexadecimal release, a reduced error name and a SHA-256 of the component stack, never a message, a path with identifiers or a query string; the server's model refuses any other field and any query string; at most three reports a page load and none for a typed refusal; without crypto.subtle nothing is sentNV16; console/src/lib/client-errors.test.ts, console/src/components/RouteError.test.tsx, tests/test_console_api_base.py, tests/test_browser_console_shell.py::test_nv16_a_failed_page_reports_once_by_its_route_and_shows_a_referenceVerified
T4.70IRecent pages and records kept in the browser show one person's or one workspace's records to another, or outlive revoked accessOnly a kind and a page path or a UUID are kept, under the tenant's and the principal's ids, read back strictly and discarded whole when anything else is found; a record is shown only once the scoped search finds it again, so revoked access or another space leaves it out; a storage failure means noneNV11; console/src/lib/recents.test.tsx, console/src/components/ui/palette-commands.test.tsx, tests/test_browser_console_shell.py::test_nv11_recents_are_per_person_and_drop_revoked_recordsVerified
T4.71EA keyboard shortcut, a palette command, the bell or the navigation sheet decides, stops or issues something without the dialog, the step-up and the server's answerNo key or command performs an action: commands open the existing dialogs, through an in-memory, closed-name, five-second command bus (console/src/lib/commands.ts), whose own step-up and checks are untouched; g sequences reach only entries the person can see; the bell and the sheet only linkNV4, NV5, NV12; console/src/lib/keys.test.tsx, console/src/lib/commands.test.tsx, console/src/components/ui/palette-commands.test.tsx, console/src/shell/notifications.test.tsxVerified
T4.72TA release reload interrupts a decision or another change in flightThe document navigation to a newer release and the Live board's idle reload both wait while any change is being sent, and the banner's Reload is disabled meanwhile; decisions stay server-authoritative and never optimisticNV18; console/src/shell/release-navigation.test.tsxVerified
T4.73IAn account's view of its own workspaces carries another workspace's data: a record, an identifier, a label, or a count the current sign-in could not see by enteringGET /session/workspaces answers, for each membership memberships() lists, only the workspace's name, the kind a session there would carry, its workspace-wide roles and what entering asks. For a workspace other than the current one that asks nothing more, it adds the number of pending approvals assigned to this account's principal there, capped at 100, and their earliest deadline floored to the minute. Each is read under that tenant's own row security, entered only for a listed tenant and set back to the caller's when its block ends, with the pending queue's own predicate (a parity test holds them together). A count is withheld, and no count statement runs, where entering asks for a factor, a factor's set-up or a directory sign-in. The read needs the account cookie and the current session, refuses a personal access token, writes no event, and a workspace's administrator cannot reach it. Residual: the account's holder learns the volume of their own assignments in workspaces they belong to, which is the approved principle; a count may trail a concurrent revocation by up to a minute, and authorizes nothingXW1 to XW5; tests/test_workspace_waiting.pyVerified
T4.74TA workspace switch carries the old workspace's place into the new one, sends the new session to a page it cannot open, or its return address becomes a redirect; or a message between tabs changes what a tab may doThe switch's section is a server-side closed set of console page roots with no identifier or query; the answer is /mfa or a member of that set, and a section the destination session cannot open falls back to where that session starts, itself checked against the same rules. Through the second factor the section rides the existing signed, five-minute return cookie, and the switch route, behind its CSRF check, writes nothing but a section into it; what a sign-in link may name there is T4.75's closed grammar of console pages. A stale tab's link is a section root and a full reload, carrying a record only when the workspace is unchanged. A message between tabs is shape-checked, rendered as text and grants nothing; a tab acts on an announced sign-in only after a confirmation read whose answer header names that session, and the server's generation check, which refuses a stale tab's request with 409 WORKSPACE_CHANGED, stays the authorityXW6 to XW8, XW12; hosted: RW1; tests/test_workspace_sections.py, console/src/lib/workspace-context.test.tsVerified
T4.75T, EA return hint sends a signed-in person somewhere other than a console page, carries a secret or an arbitrary query through sign-in, makes a page act on load, or is planted to send them to a page of the attacker's choosinglogin_return.canonical is the one check every writer and reader of the hint uses: it admits only core's console route shapes with lowercase-UUID parameters (the workspace-scoped approval shape only where a loaded plugin's approval links mount it; a plugin's own console pages are never returnable), refuses a second origin, a backslash, a fragment, an encoded slash or backslash, whitespace and control characters before any shape is matched, refuses a duplicated key and more than sixteen fields, keeps on five pages only allowlisted keys whose values are UUIDs or closed enumerations (none of which acts on load: each only chooses what its page shows, and a key is admitted only after its page is checked to act on a click alone), drops every other key, and refuses anything over 512 characters in or 256 out. The cookie keeps its HMAC under a fixed domain, its five-minute deadline, its duplicate refusal and its length bound; an unusable ?review= deletes any earlier hint; destination() returns only a value equal to its own canonical form, so a cookie signed under an older grammar returns only what the current one admits; the hint grants nothing and every page re-authorises its reads (surfaces/login_return.py, surfaces/console.py).SN1 to SN6, SN16, SN19; tests/test_login_return.py, tests/test_sign_in_surface.py::test_sn4_password_and_provider_sign_in_return_to_any_console_page, ::test_sn5_an_unusable_return_is_ignored_not_refused, ::test_sn5_an_unusable_return_deletes_an_earlier_one, tests/test_mfa_surface.py::test_sn6_the_second_step_returns_to_any_console_page; residual: a person can be made to visit /login?review=<console page> and land on that page, showing what the attacker chose, after signing in, as they could already be made to land on an approvalVerified
T4.76S, EA sign-in is steered into a workspace the person did not mean to open, or a later sign-in is steered by the record of the last workspace enteredThe signed hint chooses only among the account's own memberships, under exactly the eligibility a sign-in already applies (an active account, an active membership whose account has a verified address or whose membership has legacy access, an active tenant, an active principal of a human kind, and not a workspace that requires sign-in through its own directory): user_accounts.first_membership(prefer=) returns the preferred workspace only when it is among those, else the one entered most recently, else the oldest. It chooses only where a sign-in completes, at the password POST (CSRF cookie, exact Origin, rate limits) or the provider callback (state, nonce, PKCE), never on a page's GET of an existing session, which still meets the explicit switch; the hint is the HMAC-signed, five-minute, single cookie login_return.destination already reads (login_return.hinted_workspace). The destination's own second-factor policy applies to the new session. workspace_memberships.last_entered_at is written only by the definer functions account_session_open and account_switch (migration 0082_workspace_entry), the runtime keeps SELECT alone on the table, and the stamp only orders a choice among memberships each usable on its own; the console names the workspace on every screen.SN9 to SN11; hosted: SN20; tests/test_workspace_entry_migration.py, tests/test_workspace_switching.py::test_sn10_sign_in_opens_the_last_workspace_used, tests/test_sign_in_surface.py::test_sn11_a_sign_in_opens_the_workspace_its_link_names; residual: someone who can make the person open a crafted /login?review= can choose which of the person's own workspaces that sign-in opens; and someone who knows the password but not the factor can sign in, abandon the challenge and so stamp a workspace of their choosing among the person's own, which the person's next sign-in without a hint then opensVerified
T4.77S, DA just-used authenticator code is replayed to probe the factor or to dodge the five-attempt lockmfa.check_factor reports a code as just used only when it matches, compared in constant time over every step of the current window, a step at or below the factor's last_step; such a code is never accepted, records account_security.code_reused and no failure. Every other wrong code still records a failure and counts toward the five-attempt lock across sessions, and a locked factor refuses every code without reading it, a just-used one included. A used step is already single-use under the tenant fence, so the answer reveals only that a code the person has already spent was spent.SN12; tests/test_mfa_sessions.py::test_sn12_a_code_just_used_is_not_a_failure, ::test_totp_is_single_use_across_concurrent_sessions, tests/test_mfa_surface.py::test_sn12_the_page_says_a_code_was_just_used; residual: none beyond the existing lock, since a used code says nothing of the nextVerified
T4.78E, SAn invitation's reviewer group grants more than the invitation shows, applies before the person accepts, outlives the inviter's authority, or names another workspace's groupThe group is named by workspace_invitation_set_group in the creating transaction, under the administrator check every invitation path runs (workspace-wide access.manage, so an administrator narrowed to Spaces cannot reach it): it must be this tenant's, carry no Administrator or Policy manager grant, and carry no other grant the invitation's own grants do not cover (the same role, and a workspace-wide invitation grant or the same Space), and the invitation must grant Approver; a refusal rolls the whole creation back. The membership is inserted only inside workspace_invitation_accept, as step 8b, after step 8's access_change has taken the tenant fence and re-checked the inviter's access.manage, so a concurrent change to the group, which also goes through access_change, is ordered against it; the same checks run again there, and a deleted, raised, widened or full group is skipped with its reason recorded on invitation.accepted, never applied and never a refused acceptance. Creation keeps the step-up and the personal access token refusal. An applied membership is audited as access.group_member_added with the inviter as actor (migrations/versions/0083_invitation_reviewer_group.py, invitations.create_invitation, invitations._record_acceptance)AR1 to AR8; tests/test_invitation_reviewer_group.py::test_ar2_a_group_granting_more_than_the_invitation_is_refused, ::test_ar2_a_narrowed_or_ordinary_actor_cannot_name_a_group, ::test_ar3_inviting_with_a_group_steps_up_and_refuses_a_token, ::test_ar4_acceptance_adds_the_member_after_the_grants, ::test_ar5_a_changed_group_is_skipped_and_recorded, ::test_ar6_a_group_promotion_racing_an_acceptance_is_ordered; tests/test_invitation_reviewer_group_migration.py::test_ar1_downgrade_restores_the_acceptance_byte_for_byte; tests/test_browser_invitations.py::test_ar8_an_invited_approver_joins_the_group_and_decidesVerified
T4.79IThe decision coverage and unrouted reads disclose which resources were requested and how thinly they are reviewed to someone who should not learn it, or reach an agent as a target listingBoth reads (GET /api/console/v1/approval-coverage and /approval-coverage/unrouted) require workspace-wide access.manage, the authority /approval-rules and /approvers already need and which already lists targets and assignments; an administrator narrowed to Spaces is refused 403, so neither read filters by Space and nothing depends on a Space filter being right. An agent credential is refused 403 and audited before any read, as on every console route. The answers carry counts and never a reviewer's id, name or address; target labels are provider identifiers rendered as text. ApprovalItem.reviewers_at_hold is filled only for a reader with workspace-wide access.manage and is null for every other reader, approvers included. A personal access token may read both, as it may read the approvers matrix (approval_routing.read_coverage, read_unrouted, surfaces/console_api/approval_coverage.py)AR18 to AR21; tests/test_console_api_approval_routing.py::test_ar18_a_seated_administrator_is_refused_both_reads, ::test_ar18_an_approver_is_refused_both_reads, ::test_ar18_only_a_workspace_administrator_sees_the_hold_record, ::test_ar18_a_token_may_read_coverage_and_unrouted; tests/test_approval_routing.py::test_ar21_coverage_names_no_reviewer, ::test_coverage_refuses_anyone_without_workspace_access_manage; tests/test_console_api_human_access.py::test_access_endpoints_refuse_bearer_missing_session_and_non_adminVerified
T4.80EA recorded or previewed reviewer count is taken as authority, or a fix to coverage is applied without the administratorapprovals.reviewers_at_hold is written once, by admission, under the tenant fence beside the approval it describes, and read only by console views: scope_check, verify_approval, resume and consume read the live effective_approval_scopes view, never the record, and no other module under execution/ names the column. The runtime role may insert it and not update it, which check_runtime enforces (migrations/versions/0084_reviewers_at_hold.py). The coverage and unrouted reads run in their own snapshot without the fence and change nothing. Every fix the card offers opens an existing dialog, prefilled, that needs the administrator's confirm and the server's answer (and a reason for a rule change); nothing is assigned or added to a rule automaticallyAR14 to AR17, AR22; tests/test_reviewers_at_hold_migration.py::test_ar14_the_runtime_inserts_the_hold_count_and_cannot_update_it, ::test_ar14_check_runtime_refuses_an_update_grant_on_the_count; tests/test_approval_routing.py::test_ar16_a_reviewer_added_after_the_hold_decides_and_it_executes_once, ::test_ar16_a_reviewer_removed_after_the_hold_is_refused, ::test_ar17_a_rule_save_racing_a_hold_records_one_side, ::test_the_count_is_never_read_by_a_decision; tests/test_browser_console_routing.py::test_ar22_an_unrouted_request_is_shown_and_fixed_from_coverageVerified
T4.81S, ESomeone who reads, intercepts or guesses a password reset link takes over an account, or uses the reset to skip its second factor (hosted service only)The hosted service's own code mints a link only for an active account that already holds a password and has a verified address, and mails it only to that address. Its secret is 256 random bits in the URL fragment, which no server receives, and only its SHA-256 is stored. One live link per account: a newer request or a resend replaces it, and each send attempt mints its own, so an uncertain attempt's link is dead before the next. A link lasts 30 minutes from its send, is bound to the address it was mailed to and to the account's version at the send, so an address change or any sign-in change ends it, and five wrong secrets end it; it is spent once under a row lock. Opening it and pressing Continue change nothing. Completion requires a current authenticator step above the last one used, or an unused recovery code that is then removed, whenever a factor is enabled; wrong codes count toward the five-attempt lock every session shares, and a completion that meets or sets that lock ends the link. The reset runs under the account lock and every workspace's fence, moves the account's version, ends every account session and every workspace session (each a human_session.revoked with PASSWORD_RESET) and revokes every personal access token in every workspace the account belongs to, records the reset in each workspace where it changed something or the account is active and for the account itself, emails a notice naming the time and the support address, and signs nobody in.hosted: RP1, RP3, RP4, RP5, RP6, RP7, RP9, RP11Verified
T4.82I, DA stranger uses the password reset form to learn whether an address has an account, to flood someone's inbox, or to spend the relay's daily allowance (hosted service only)One page and one status for every submission, never repeating the address; at most three reset emails an hour and ten a day reach any one account and sixty an hour reach anyone, and nothing is mailed to an address with no usable account. Refusals, bot-trap hits and unknown addresses never count toward those caps, and past 2,000 rows an hour only a request that will be mailed still records a row, so a script can neither close the caps for others nor grow the table without bound. The email is sent after the answer by a background task, so the answer's timing does not depend on the outcome; that task runs without an operator identity, so it may lease only the one message the server generated and its own transaction queued for the same request, never one named by the client or one the scheduled retry holds, while the retry keeps the operator identity check. The form is CSRF-cookied, Origin-checked and honeypotted like /signup, and the relay's daily budget counts signup and reset mail together, with dormancy warnings, which may spend only half of it (T5.41), and approval emails, which may spend only a quarter of it (T5.42). That budget defers rather than drops.hosted: RP1, RP2, RP8, RP9Verified
T4.83EA plugin's recovery link on the sign-in page sends a person somewhere other than this deploymentRegistry.recovery_link takes a literal absolute path on this site with no parameter, outside the reserved prefixes and /assets, at most once per deployment; a second plugin registering one is PLUGIN_CONFLICT. The label is one line of at most sixty printable characters, rendered as text, and dropped, with plugins.recovery_link_failed logged, when it raises or answers anything else.hosted: RP12; tests/test_plugins.py, tests/test_sign_in_surface.py, tests/test_plugin_surfaces.pyVerified
T4.84S, EA factor verified in one workspace lets a stolen or unattended browser into another of the same account's workspaces without a code, or the carried verification outlives the factor or the step-up window it came fromaccount_switch carries a verification only from the session it switches away from, which must be the account session's current one and pass every check the switch already makes, and only when, inside the switch's transaction, that session's session_assurance row is of the account's enabled factor at its current version and was verified within the last thirty minutes (mfa.STEP_UP_WINDOW, the function's literal held equal to it by test); the source row is read under the source's own tenant setting before the function moves it. The carried row keeps the source's own verified_at, so the window never restarts, and nothing else of the source workspace is carried: the destination's own second-factor policy still applies, a directory requirement is never satisfied by it, and an account with no factor is still sent to enrol. Every switch needs the account cookie, CSRF and the exact Origin, and the console API's switch also needs the displayed workspace generation. Factor changes take the same account lock before any tenant fence as the switch, so the two serialize, and a carried row of a superseded version satisfies nothing, since requires_mfa and the step-up both compare it with the current factor version. Every carry is recorded twice: assurance_carried for the account in user_security_events, naming the source workspace and session and the destination workspace and session, readable only under the account's own row-level setting and held to that kind by the check user_security_events_carry; and account_security.assurance_carried in the destination's audit chain, with the new session, the account session, the method and the original verified_at and no other workspace's identifier, joined to the account record by the new session's id (migrations/versions/0089_assurance_carry.py, workspaces.switch_workspace). The workspace list's entry answers open exactly where the switch would carry (workspaces.carries, read before the list enters any other workspace), and never on a carry for an account with no enabled factorSN14, SN15, SN22; hosted: SN21; tests/test_workspace_switching.py::test_sn14_assurance_carries_for_the_same_factor_within_the_window, ::test_sn14_the_window_never_restarts, ::test_sn14_an_older_or_changed_factor_still_asks, ::test_sn14_the_carry_window_is_the_step_up_window, ::test_sn14_entry_proof_matches_the_carry, ::test_sn14_carries_answers_for_the_factor_the_account_holds_now, ::test_sn15_a_switch_and_a_factor_change_serialize; tests/test_assurance_carry_migration.py; residual: within thirty minutes of a code, an unattended signed-in browser can open any workspace of that account without another code, the trade T4.48 accepted for step-up changes, now across the account's workspacesVerified
T4.85SAn account in another Microsoft organization asserts an address it does not own, or a personal Microsoft account signs inThe deployment's Microsoft button uses the multi-tenant organizations authority, a recorded deviation from the single-tenant rule a workspace's own directory keeps, which still stands for directories. What replaces the pin: the ID token's issuer must equal the issuer of the token's own tid, so a token is believed only about the tenant that signed it; the personal-account tenant is refused here as well as by the authority; the key set, the endpoints and RS256 are pinned in source, alg is settled before any key is fetched and the signature is checked before any claim is read; ver, aud, azp, exp, iat, nbf and nonce are checked; the subject is tid:oid, so an object identifier in another tenant is another person, and sub (pairwise per application) and preferred_username (unverified) are never read. The address is believed only when xms_edov is exactly the JSON value true, and then only where any provider's address is: to start a new account through a plugin's signup, or to link the provider from a signed-in account whose own verified address equals it. Sign-in is by subject alone, and a workspace that requires its own directory is never opened by it. Nothing turns the button on: a deployment offers it only when its operator adds a sign_in.microsoft block. Residual: the administrator of a tenant that verified a domain can assert any address in that domain, which is that domain's owner speaking. Gap: the claim's JSON type is checked against Microsoft's documentation and a stand-in token; a sign-in through a real registration has not yet confirmed it (microsoft.py, oidc.py, surfaces/configuration.py, migration 0091_microsoft_sign_in).tests/test_microsoft_sign_in.py, tests/test_sign_in_surface.py::test_mi3_microsoft_signs_in_and_links_by_subject_only, tests/test_microsoft_migration.py; hosted: MI7, MI8, MI9Partial
T4.86IAn approval email reaches someone who cannot decide the request, who opted out, or a third party (hosted service only)Only a person who turned it on for that workspace from their own session receives one, and only for a request held after they did, so nothing is sent for a backlog. The queue, and again the lease immediately before the send, read inside the workspace's fence whether the person may decide the request now (the routing rule scope_check decides by, an active ADMIN or HUMAN_APPROVER), whether they decided it already, whether their subscription is still the one the email was queued under, and whether the workspace is still an active Community one; any change skips the email with its code. The address is the person's verified account address alone, read at send time; an address an administrator typed on the principal is never used, because agents decide when these emails go and such an address could be a third party's. It is held only in memory for the send: no row, audit event or log line carries it (the hosted plugin's own code).hosted: AM2, AM3, AM7, AM12Verified
T4.87I, TAn approval email carries a target, agent, reason or argument string, or a control a mail client or a reader would act on (hosted service only)A fixed plain-text template whose only reader-supplied text is the workspace name through name_label and the action's catalog label through safe_label, and a request reference of EB- and a number. It names no resource class, environment, Space, requesting agent, target, reason or argument, carries no approve or refuse control, and links only to the workspace-scoped approval page, its basic page and the one-click stop; the renderer refuses any other link. The workspace name is its own administrators' text, so it can still read as a web address, as the weekly summary's can (T4.53) (the hosted plugin's own code).hosted: AM8Verified
T4.88S, TAn approval email's unsubscribe link is guessed, replayed, prefetched or used to learn who subscribes (hosted service only)A 256-bit secret per attempt, kept only as its SHA-256, so a retry's link replaces the one before it; GET reads and changes nothing; every POST answers the same status and body whatever the link; a link ends only the subscription its own email was queued under, so an old link cannot end a renewed one; both routes are limited per peer and per email; the page sends no-referrer and no-store, the request log records no path, and the link's shape (eba1) is in the secret screen (secrets.py, and the hosted plugin's own code).hosted: AM10Verified
T4.89SSomeone who signs up names the workspace, or themselves, with a web address and invites a stranger, so a teammate invitation sent from ExecBound's own address carries a link a mail client makes clickable, pointing wherever they choseThe invitation's workspace and inviter names go through name_label, and a label holding a colon, a slash or a dotted host-like word (example.com, an inviter named by an address such as robinexample.com) is replaced by "a workspace" or "An administrator", as is a name nothing of which survives, so the only link left is the /join link on the deployment's own origin, which the renderer refuses in any other shape; the join page then names the workspace in full, escaped as page text. A name can still carry misleading words without a link (transports.render_teammate_invitation)TI3; tests/test_teammate_invitations.py::test_ti3_an_invitation_leaves_out_a_name_a_mail_client_could_link, ::test_ti3_an_invitation_keeps_a_name_no_mail_client_would_link, ::test_ti3_an_invitation_leaves_out_a_name_nothing_of_which_survivesVerified
T4.90S, EA stolen trust cookie, or a shared or unattended desktop that holds one, skips the sign-in factor, and a trust outlives the security change, the factor or the workspace rule it should not surviveThe trust cookie never replaces the password, the provider sign-in or a directory requirement: five places read it and two of them apply it: GET /mfa and GET /account/security (one handler) apply it to a sign-in still owing its factor, and a workspace entry (workspaces.trusted_entry) applies it to the new session a switch opened; the workspace list (trusted_browsers.applies, behind GET /session/workspaces) reads it to say what entering would ask and writes nothing, and My account (trusted_browsers.browser_of) reads only the browser id in it to mark "This browser", comparing no secret. It grants nothing by itself. It is HttpOnly, __Host- prefixed over HTTPS, Path=/ and SameSite=Lax (a provider's callback is a cross-site navigation), and lasts thirty days; its value is tb1.<browser_id>.<secret> with a 256-bit secret of which the database keeps only the SHA-256, so the cookie is matched by comparing digests and the secret is never stored. A trust is granted only from the session's own verification of the enabled factor within the step-up window, at sign-in and never by the step-up, and is bound to the account's and the factor's versions, so a password change or reset, a factor enabled, replaced or disabled, a passkey registered or removed, a provider link and "Sign out other devices" end every trust, the last from a session with no account sign-in as well (account_sign_out_others moves the linked account's version as the account path does); at most ten are live per account, the eleventh revoking the oldest. A use writes the session's assurance with method trusted and the trust's original verified_at, never now, and rotates the session's token and CSRF in the same statement, and the same transaction appends account_security.trusted_sign_in to the workspace's audit chain with the session, the trust and the time and method of the verification behind it; trusted is never a fresh verification, so the thirty-minute step-up, the database's own fresh read and the switch carry still ask for a real code. Every trust is listed on My account with "This browser" marked and removable, one or all, and every addition and removal is a user_security_events notice. A workspace administrator may forbid trust in that workspace (account_security.trusted_browsers_changed), and an administrator with a factor must have verified it recently to change the setting; afterwards its sign-ins and entries ignore every cookie and the code prompt offers no box; the workspace list reports open only where trusted_browsers.applies would let the switch apply it (trusted_browsers.py, migrations/versions/0101_trusted_browsers.py, workspaces.trusted_entry)TB1, TB2, TB3, TB4, TB5, TB6, TB7, TB8, TB9, TB10; hosted: TB11; tests/test_trusted_browsers.py, tests/test_trusted_browser_sign_in.py, tests/test_console_api_trusted_browsers.py, tests/test_browser_trusted_browser.py; residual: someone holding both the password and that browser's cookie signs in without the factor for up to thirty days, the trade a person accepts by ticking the box, which a workspace administrator may refuse for the workspaceVerified

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.tests/test_cedar.py::test_pinned_native_entry_points_keep_their_shapePartial, 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 tenantA workspace administrator's emergency stop (T5.32) 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 workspace administrator (T5.32) 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.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. The console's rules write takes a single-action group, account or space budget only with a qualifier its read offered, all read inside the tenant fence (the tenant's groups, the active accounts the draft's own mappings name, and the spaces the draft may serve that the person may see), and refuses any other at the budget's field without echoing the identifier, so another tenant's group, account or space never reaches a draft through the form; the store's refusal above still stands behind it. Admission does not re-run those checks: it reads the active bundle through execution.store.active_bundle, which revalidates no component, so a group removed after activation is not refused there. What bounds that is the write path rather than a recheck — no command deletes a group, the runtime holds SELECT only on both tables, and the membership foreign key has no cascade, so removal takes the owner connection — and store.memberships reads the principal's groups inside the fence at every decision, so a rule's counting follows current provisioning rather than a stale bundle claim (administration.py, policy/store.py, execution/store.py, migration 0022_limit_scopes). See POLICY.md.SL7, SL10, SL11; tests/test_admin_cli.py::test_group_lifecycle, tests/test_limit_scopes_schema.py::test_runtime_cannot_write_group_tables, tests/test_policy_store.py::test_scoped_rules_fail_validation_with_locations, tests/test_console_api_rules.py::test_a_qualifier_of_another_tenant_is_refused_at_its_fieldPartial
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 deactivation never removes an administrator, and nothing is deleted; making or unmaking an administrator through a role grant is T5.28 (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.14EA 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 boundaryThe 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. Extending, reviving or converting a sandbox happens only over the owner connection, by the hosted service's own commands (documented with the hosted service), each with its own bounds; each requires an active ADMIN principal of that tenant as the actor and appends tenant.extended, tenant.fell_back or tenant.converted to that tenant's own chain in the same transaction. Deactivation (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, and the hosted plugin's own code).hosted: OS7, OS7b, OS7c, OS7d, HF10, HF17, HF18, HF20, HF21, HF24 to HF30, HF46Verified
T5.15EA console administrator gives themselves or somebody else an administrator's way inadmin_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_routeVerified
T5.16EA space membership raises a role, or the tenant is left with nobody who can administer spacesThe 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.pyVerified
T5.17RA space and the history it owned are removedThere 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.pyVerified
T5.18E, IA tenant's administrator registers an issuer they control, or a client secret and refresh token at rest are readA 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. A draft is verified only by a workspace administrator's sign-in through it: the self-link route refuses anybody else unless the provider is enabled, directory_store.link_self refuses the same in its transaction before directory_link_self runs, and since 0073_directory_workspace_admin the function refuses it too, where before it accepted an approver's (T5.31). 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).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, tests/test_console_api_directory_identity.py::test_an_approver_cannot_start_a_link_through_a_provider_that_is_not_enabled, tests/test_console_api_directory_identity.py::test_link_self_refuses_a_non_administrator_on_a_draft_and_writes_nothingVerified
T5.19EAccess here outlives an account being disabled at the customer's directoryWhenever a directory session's renewal is due, 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 renewal is due seven and a half minutes after the session was last extended (RENEW_AFTER), whether or not the row can still move, so a person using the console is rechecked about every seven and a half minutes (the directory is asked when the last verification is more than five minutes old) and one who walked away is rechecked at their first request back. In a workspace that requires its directory, the session check itself refuses a session whose verification is older than the fifteen-minute grace (DIRECTORY_REQUIRED); the guarded loads answer that refusal with the same recheck before refusing (directory_store.load_rechecked), so a person back after an hour keeps the session if the directory confirms the account, loses it if the directory refuses, and is refused, with nothing revoked, if it cannot answer. A session at its two-day end can no longer be extended and is rechecked all the same, on every request after that, so the last twelve hours of a session are no exception. 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, in continuous use, for up to RENEW_AFTER (seven and a half minutes) while the verification keeps step with renewal, and for up to RENEW_AFTER + RECHECK_INTERVAL (about twelve and a half minutes) when it falls out of step: a renewal that finds the verification under five minutes old asks nothing, and the next one is RENEW_AFTER later. A request that crashes between claiming the recheck and settling it leaves its claim standing for CLAIM_LEASE (sixty seconds) with the verification unmoved, so nothing is served on it and the next request after the lease rechecks. One who is away is rechecked at their first request back, and one disabled during a directory outage keeps the console for up to the grace; an administrator deactivating them is immediate. Of several requests that find the verification old at once, one claims the recheck (directory_recheck_claim) and calls the directory, so a refresh token is never presented twice at once. The claim is a column of its own (recheck_claimed_at) and never moves verified_at, which only the directory's yes does, so the others read the verification as it was: inside the grace they go on, and past it they wait up to fifteen seconds (SETTLE_WAIT) for that recheck to settle and are then served on the directory's answer or refused, never served on the claim, the person's first request back after a long absence included. At most eight requests in a process wait at once (MAX_WAITERS), each holding one of the server's thirty-two request slots, so a directory that answers slowly, which a workspace administrator can register, cannot take the server's slots from every other workspace; past the eight a stale request is refused at once. The directory's yes is recorded only under the claim it was asked under (directory_recheck's p_claimed), so a call that outlived its lease cannot clear the claim the next request took over or overwrite the refresh token stored under it. That is the residual of the claim: nothing bounds one directory call as a whole (httpx's five seconds bound each connect, write and read alone, and the name lookup takes none), so a call longer than the sixty-second lease lets the next request redeem the same refresh token, which a directory that rotates refresh tokens may answer invalid_grant, ending that person's sessions. A claim the database cannot write refuses a session past the grace PROVIDER_UNAVAILABLE rather than passing over it, and the claim and the recheck stamp the application's clock, as the sign-in does, so a clock that drifts from the database's cannot put a verification outside the grace; the console shell's page load rechecks as the API does (directory_store.recheck_session, surfaces/human.py, surfaces/console.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, tests/test_longer_sessions.py::test_ls_the_directory_recheck_keeps_its_cadence, tests/test_longer_sessions.py::test_ls_a_directory_session_at_its_two_day_end_is_still_rechecked, tests/test_longer_sessions.py::test_ls_simultaneous_rechecks_call_the_directory_once, tests/test_longer_sessions.py::test_ls_the_console_shell_rechecks_a_directory_session, tests/test_longer_sessions.py::test_ls_a_directory_required_session_back_after_an_hour_is_rechecked_not_ended, tests/test_longer_sessions.py::test_ls_a_directory_required_session_disabled_while_away_is_refused, tests/test_longer_sessions.py::test_ls_a_directory_required_session_is_refused_while_the_directory_is_unreachable, tests/test_longer_sessions.py::test_ls_two_requests_back_after_an_hour_call_the_directory_once, tests/test_longer_sessions.py::test_ls_a_page_load_rechecks_a_directory_required_session_back_after_an_hour, tests/test_directory_recheck_safety.py::test_rs1_a_request_beside_a_recheck_in_flight_waits_and_is_refused_with_it, ::test_rs2_a_request_beside_a_recheck_in_flight_loads_once_the_directory_says_yes, ::test_rs3_a_claim_still_unsettled_after_the_wait_leaves_the_session_refused, ::test_rs4_a_claim_left_past_its_lease_is_taken_over, ::test_rs5_a_claim_the_database_refuses_fails_a_stale_session_closed, ::test_rs6_the_claim_and_the_recheck_stamp_the_applications_clock, ::test_rs7_the_migration_and_its_downgrade, ::test_rs8_a_release_or_a_late_yes_never_touches_a_claim_not_its_own, ::test_rs9_a_yes_the_database_cannot_record_lets_the_claim_go_and_stays_refused, ::test_rs10_a_request_inside_the_grace_that_loses_the_claim_goes_on_at_once, ::test_rs11_with_every_waiting_place_taken_a_stale_request_is_refused_at_oncePartial, see R26, R36
T5.20EA future change reads a workspace's agent limit or its agent-principal count anywhere on the request path, turning an administrative entitlement (the limit Hosted Free sets from its plan) into a security control that can fail open on a bug or block a legitimate incident responseThe 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 workspace at its limit could deactivate one, create another and reactivate the first, holding one more, and repeat without limit). Both take FOR NO KEY UPDATE on the tenant row before counting live and sleeping agents against the same limit, the workspace's own t.max_agents (null is uncapped) since 0066_max_agents replaced 0044_hosted_free's CASE t.plan, 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-limit workspace 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 (HF53), and a wider walk that adds the agent surfaces, identity, the runtime, the Arcade hooks, the provider modules and the approval decision surfaces asserts that none names max_agents, agent_capacity or execbound.capacity (AC9), so a plan or limit lookup added to any walked module fails the suite closed rather than needing a reviewer to notice it (capacity.py, the hosted plugin's plan table, migrations 0044_hosted_free and 0066_max_agents). Modules outside the walk are not covered: db.py, whose SCHEMA_REVISION names the migration, and the shared helpers the walked modules import, such as audit.py, operations.py, contracts.py, spaces.py and access_control.py.AC4, AC9; hosted: HF9, HF53, HF54 to HF58; tests/test_agent_capacity.pyVerified
T5.21EA 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 scheduled pass's defaultsandbox_fallback(p_now) is a fenced, no-actor SECURITY DEFINER function with exactly one caller, in the hosted plugin's own code: its scheduled signup 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. The hosted service's own conversion command does not call this function: moving a sandbox to the free plan early 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 over the 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, and the hosted plugin's own code).hosted: HF10, HF17, HF18, HF46; tests/test_route_authority.pyVerified
T5.22IComputing which free tenant's agents have gone ninety days silent reads audit event payloads, or the derived signal cannot be reproduced once a retention pass redacts payload contentagents_sleep's own SQL and the hosted plugin's scheduled pass, which applies the same rule to choose the tenants it puts to sleep, 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, so a retention pass that redacts payload content and keeps every event's kind and occurred_at leaves it reproducible; no retention pass is built. 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, and the hosted plugin's own code).hosted: HF33, HF36, HF40, HF41Verified
T5.23EDeactivation, 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 pathUnlike sandbox_expire, sandbox_fallback and every other lifecycle function in this family, deactivation 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. Deactivation instead runs as a direct owner write in a fenced transaction on the owner connection, the same shape the hosted service's conversion 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 hosted plugin's own owner command, 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 (db.py, and the hosted plugin's own code).hosted: HF8, HF24, HF25, HF28Verified
T5.24EA personal access token activates a policy, switches the workspace to observe-only, lifts an emergency stop, reactivates an agent, unarchives a space or installs or refreshes the sample targetstoken_rules.TOKEN_REFUSED carries /policy/{bundle_id}/activate, /tenant/resume, /agents/{principal_id}/reactivate, /spaces/{space_id}/unarchive and POST /samples and POST /samples/refresh, which install and activate the starter bundle, not every method of /samples: GET /samples, which only reads the samples' status, is allowed to a token, as /connection is. /tenant/mode stays allowed, but settings.py's change_mode route itself refuses a token's request unless the body's mode is ENFORCE, so OBSERVE is refused there rather than in the table; TOKEN_BODY_LIMITS carries only the reference text the published API describes that limit with. /samples/remove, which only removes what /samples installed, stays allowed because it tightens rather than loosens (token_rules.py, surfaces/console_api/settings.py).PT3; tests/test_console_api_tokens.py::test_a_token_may_switch_to_enforcing_and_not_to_observe_only, tests/test_console_api_tokens.py::test_the_exceptions_answer_token_not_allowed_and_are_audited, tests/test_console_api_tokens.py::test_a_token_reads_the_samples_status_its_owner_reads, tests/test_console_api_samples.py::test_sm9_api_a_token_may_not_refresh_the_samplesVerified
T5.25EA personal access token creates or changes administrators, people, access, groups, approval rules, sign-in providers or further personal access tokensEvery write under /people, /access, /approvers, approval rules and /tenant/sign-in is in TOKEN_REFUSED, as is every token-management route (/settings/tokens*, /tenant/tokens*); the hosted plugin's own invitation writes sit in its refused set, which token_routes registers: personal_tokens.issue_token, revoke_token, delete_token and list_tokens each refuse a token session outright before the store is reached, so a token can never mint, list, revoke or delete a token, its own or another's. /credentials/issue's own body rule refuses a credential for any HUMAN_APPROVER or ADMIN principal, new or existing, and accepts only an agent, so a token cannot mint the one kind of credential a session can exchange for a sign-in; the ten-live-token ceiling (admin_issue_personal_token) bounds even an administrator's own issuing. This is what closes T4.43's approval-by-proxy case (token_rules.py, personal_tokens.py, migration 0063_personal_access_tokens).PT4, PT5; tests/test_personal_tokens.py::test_a_token_cannot_manage_tokens, tests/test_console_api_tokens.py::test_a_token_issues_a_key_only_for_an_agent, tests/test_route_authority.py::test_token_allowed_writes_never_touch_people_access_or_sign_in, tests/test_personal_tokens.py::test_an_owner_holds_at_most_ten_live_tokensVerified
T5.26EAn administrator disabled at the workspace's identity provider keeps acting through a personal access token after a browser session would have re-verified and failedA browser session can re-authenticate at the directory every fifteen minutes (validate_workspace_session's recheck), so it is refused only once that recheck fails; a token never mints a federated_sign_ins row and has no way to prove itself there, so it is held to a stricter rule. personal_tokens._directory_required refuses issuing and using every token in the workspace the instant any identity provider row carries require_sign_in, in any state, rather than waiting for a recheck window to lapse (DIRECTORY_REQUIRED).PT13; tests/test_personal_tokens.py::test_a_token_is_refused_while_any_identity_provider_requires_sign_in, tests/test_console_api_tokens.py::test_directory_sign_in_refuses_a_token_as_it_refuses_a_session, tests/test_console_api_tokens.py::test_issuing_a_token_is_refused_where_directory_sign_in_is_requiredVerified
T5.27EA rules bundle compiles its policy about another action than the one the mapping it names dispatchesRules compile about a mapping's action, so the store refuses, at the ref's location (mappings.N.action), a MappingRef.action, or a rules bundle's action_name for a ref, that the tenant's mapping row does not serve; the row, not the bundle, decides what a ref dispatches, and evaluation denies a plan whose bundle names another action for its ref (policy/store.py, policy/models.py, policy/evaluate.py).tests/test_policy_store.py::test_a_mapping_naming_an_action_its_row_does_not_serve_is_refused_at_its_action, ::test_a_rules_bundle_is_refused_where_its_ref_names_an_action_its_row_does_not_serve, tests/test_policy_evaluation.py::test_a_bundle_mapping_naming_another_action_than_the_plan_denies, tests/test_execution.py::test_rules_decide_a_request_on_a_ref_that_is_not_its_actions_nameVerified
T5.28EA console user makes someone an administrator, keeps a credential issued before the promotion, or removes the last administratorThe administrator role is granted and removed only by access_change, a definer function that requires the actor to hold access.manage and runs inside the tenant fence. The console reaches it under the step-up's /access/ prefix, so a session must have verified a factor within thirty minutes where one is enrolled (T4.48), and a personal access token is refused. The role is workspace-wide only: granting it at a space is INVALID_ROLE_SCOPE. Promotion revokes, in the same statement, every credential, session, pending password reset and invitation the person held, so nothing issued while they were less privileged becomes an administrator's; their password and directory binding survive, so they can sign in again. A change that would leave no active holder of access.manage is refused LAST_ADMINISTRATOR, including through group membership and deactivation.tests/test_access_control.py::test_promotion_revokes_previous_credentials_sessions_and_pending_resets, ::test_promoted_human_can_administer_and_stale_session_is_rejected, ::test_last_administrator_is_preserved, ::test_last_group_administrator_cannot_be_removed_or_deactivated, ::test_workspace_administrator_cannot_be_assigned_at_space_scope, tests/test_console_api_access.py::test_an_approver_is_refused_on_every_route, tests/test_console_api_step_up.py::test_every_step_up_prefix_covers_a_change_core_servesVerified
T5.29E, IAn administrator narrowed to one space gets a way in as another space's agent or as a person whose reach goes beyond their spaces: issues, revokes or deletes that agent's keys; issues or revokes that person's credential, mints their set-password code, changes their sign-in address, invites or links their directory account, or revokes or deletes their personal access token; or reads them on the credentials pageAn agent and its keys belong to the agent's space, and a person who holds any membership has no authority outside their spaces (T5.16). Since migration 0071_space_credential_scope, admin_issue_credential, admin_revoke_credential and admin_delete_credential ask what admin_create_principal and admin_assign_approver already asked: an actor without workspace-wide access.manage who holds a membership acts on an agent only where that membership is ADMIN, the space read from the agent's own principal row, never from an argument, so a space-narrowed administrator is still an effective administrator and yet reaches no other space's keys. A person belongs to no space, and a credential signs in as its person with their approver authority in every space they are seated in, so 0071 left people outside the check. Since 0072_space_principal_scope (decided 8 October 2026) the same actor acts on a person only when the person holds at least one membership, every space they are seated in is one where the actor's membership is ADMIN, and no role from the access model reaches them: a person with no membership has workspace-wide reach, a managed person's reach is their roles rather than their seats, and an access group's grants reach its members, and only a workspace administrator changes either. The memberships are read from space_members in the statement that acts. The three credential functions, admin_set_email, admin_reset_password, directory_invite, directory_link, directory_unlink and directory_invitation_revoke ask it, and admin_revoke_personal_token and admin_delete_personal_token ask it of the token's owner unless the owner is the actor. A refusal is the code each function already gives a principal, credential, invitation or token that does not exist (INVALID_PRINCIPAL, INVALID_CREDENTIAL, INVALID_INVITATION, INVALID_TOKEN). A workspace administrator is unchanged. credentials_overview, behind the console's credentials page and GET /api/console/v1/credentials, takes the agents and keys from space_clause over the reader's visible set, filtered by the space they are looking at as the Agents page is, and the people from spaces.administered_clause, the functions' rule as one predicate, so a narrowed administrator is shown only the people they may administer (migrations/versions/0071_space_credential_scope.py, migrations/versions/0072_space_principal_scope.py, surfaces/admin_pages.py, spaces.py).SP15, SP17, SP18; tests/test_spaces_credentials.py::test_sp15_a_space_administrator_is_refused_a_key_for_another_spaces_agent, ::test_sp15_a_space_administrator_is_refused_revoking_another_spaces_key, ::test_sp15_a_space_administrator_is_refused_deleting_another_spaces_key, ::test_sp15_an_approvers_seat_in_another_space_administers_no_key_there, ::test_sp15_a_persons_credential_is_administered_inside_the_administrators_spaces, ::test_sp15_the_credentials_page_lists_a_space_administrator_their_own_spaces_agents_alone, ::test_sp15_the_console_api_refuses_and_hides_another_spaces_agent, tests/test_spaces_principals.py::test_sp17_a_space_administrator_writes_on_their_spaces_people_alone, ::test_sp17_every_seat_in_one_of_their_admin_spaces_is_enough, ::test_sp17_a_personal_token_is_revoked_by_its_owner_or_their_administrator, ::test_sp18_people_and_credentials_list_a_space_administrator_their_people_alone, ::test_sp18_the_workspace_token_list_shows_their_own_and_their_peoples_tokensVerified
T5.30E, TAn administrator narrowed to one space changes another space's agent or a person outside their spaces: labels, deactivates or reactivates them, ends the person's sessions, binds or unbinds the agent, or withdraws or takes over another space's approver scopeSince 0072_space_principal_scope, admin_label_principal, admin_deactivate_principal and admin_reactivate_principal refuse a principal the actor may not administer under T5.29's rule with INVALID_PRINCIPAL, as for one that does not exist. admin_bind_agent refuses the principal so, and a discovered agent whose connection's space is not one where the actor's membership is ADMIN with INVALID_AGENT; admin_unbind_agent finds no binding unless both sides are the actor's (INVALID_STATE). admin_unassign_approver, which named no space, withdraws only a scope of such a space (INVALID_SCOPE), and admin_assign_approver refuses to move another space's live scope of the same approver, target and action into the actor's, which its ON CONFLICT would otherwise do (INVALID_SPACE, its answer for a space that is not theirs). End all sessions is the runtime's own update rather than a definer function, and its statement carries the same rule, so a session outside it is left alone. The console answers 404 before the write: require_agent and require_person apply the rule, and the People list and a person's page show a narrowed administrator only the people they may administer, as the workspace's token list shows them only their own tokens and their people's. Before 0072 a label or a deactivation under /agents/{id} landed and only the read after it answered 404. A workspace administrator is unchanged (migrations/versions/0072_space_principal_scope.py, surfaces/agents_pages.py, surfaces/people_pages.py, surfaces/console_api/people.py, surfaces/sessions.py, personal_tokens.py).SP16, SP17, SP18; tests/test_spaces_principals.py::test_sp16_a_space_administrator_is_refused_another_spaces_agent, ::test_sp16_an_approvers_seat_in_another_space_administers_none_of_its_agents, ::test_sp16_binding_and_unbinding_need_both_sides_in_the_administrators_spaces, ::test_sp16_another_spaces_approver_scope_is_neither_withdrawn_nor_taken, ::test_sp16_the_console_api_answers_404_for_another_spaces_agent, ::test_sp17_ending_sessions_reaches_their_spaces_people_alone, ::test_sp17_the_console_answers_404_for_a_person_outside_their_spaces, ::test_sp16_the_upgrade_adds_the_check_and_the_downgrade_restores_each_functionVerified
T5.31E, DAn administrator narrowed to one space configures the tenant's own identity provider: registers an issuer, verifies a draft by signing in through it, enables it, or disables it and so ends every directory session in the tenantA provider signs people in to the whole tenant rather than to a space, so it is configured by a workspace administrator alone (decided 8 October 2026). Since 0073_directory_workspace_admin, directory_register and directory_set_state ask what space_create asks, in the same words: an actor without workspace-wide access.manage who holds any membership is refused INVALID_ACTOR, the code an actor who is not an administrator gets, before the provider is read, so the refusal says nothing about it, whatever the state requested. A sign-in through a draft or a verified provider is the test that verifies a registration, so directory_link_self links through one for a workspace administrator alone and answers anybody else, a narrowed administrator or an approver, INVALID_PROVIDER, the code directory_store.link_self already gave an approver there and still gives first; through an enabled provider anybody links their own account as before. The console asks the same before the write with directory_store.configures, which reads the effective kind and spaces.narrowed, the functions' predicate in their own words: POST /api/console/v1/tenant/sign-in and POST /api/console/v1/tenant/sign-in/{provider_id}/state answer 403, as the access page does for a setting only a workspace administrator holds, before a discovery document is fetched or the named provider is read, and GET /login/directory/{provider_id}/link answers 404 for a provider that is not enabled, as it answers an approver. GET /api/console/v1/tenant/sign-in stays readable to every administrator, because a narrowed administrator's invitations and links for their own people stand on the provider it describes, and the card offers them no registration, test sign-in or state change. A workspace administrator is unchanged (migrations/versions/0073_directory_workspace_admin.py, directory_store.py, spaces.py, surfaces/console_api/sign_in.py, surfaces/directory_pages.py).SP20; tests/test_spaces_directory.py::test_sp20_a_space_administrator_registers_no_provider, ::test_sp20_a_space_administrator_neither_enables_nor_disables_the_provider, ::test_sp20_a_space_administrator_cannot_sign_in_through_a_provider_that_is_not_enabled, ::test_sp20_the_definer_function_links_through_a_draft_for_a_workspace_administrator_alone, ::test_sp20_the_console_refuses_a_space_administrator_before_the_write, ::test_sp20_the_test_sign_in_is_not_started_for_a_space_administrator, ::test_sp20_the_upgrade_adds_the_check_and_the_downgrade_restores_each_functionVerified
T5.32E, T, DAn administrator narrowed to one space changes a setting that is the whole workspace's: switches every space to observe-only, changes how long every new approval waits, activates an emergency stop that halts every space, lifts a stop a workspace administrator set, turns the assistant on or off for every person, or lowers the organization's MFA requirementEach is one value for the whole tenant, so a workspace administrator alone changes it (decided 8 October 2026). set_enforcement_mode, set_approval_deadline, activate_stop, release_stop and set_tenant_switch are Python store functions and ask store.workspace_admin_check at each of their identity checks, before and after the fence wait and again before commit: identity_check for an administrator, then spaces.narrowed, the definer functions' predicate in their own words (no workspace-wide access.manage, and a membership of any space). A narrowed administrator is refused INVALID_IDENTITY, the code an approver gets, the four kernel functions audit the refusal as execution.rejected as they audit an approver's, and nothing is written. The requirement is a definer function's: since 0074_workspace_settings_admin the policy operation of account_security_change and user_security_change asks the same predicate after its own check and refuses RECENT_AUTH_REQUIRED, the code it gives an approver. The console's POST /api/console/v1/tenant/mode, /deadline, /stop, /resume and /assistant/settings and the commands tenant-mode, tenant-approval-deadline, tenant-stop, tenant-resume and tenant-assistant reach those functions, so they answer 403 FORBIDDEN or exit 2 with the code; POST /account/security/policy answers anybody who is not a workspace administrator 403 before anything is written (mfa.sets_requirement). Reading each setting is unchanged, because a narrowed administrator's own work stands on knowing that a stop is in force or that the workspace only observes: GET /api/console/v1/tenant, GET /api/console/v1/assistant/settings, tenant-status and the session's stop stay theirs, and Workspace settings and Account security offer them no control. The top bar's stop button already asked workspace-wide workspace.manage. A workspace administrator is unchanged (execution/store.py, execution/modes.py, execution/stops.py, assistant/turns.py, mfa.py, surfaces/mfa_pages.py, migrations/versions/0074_workspace_settings_admin.py).SP21; tests/test_spaces_workspace_settings.py::test_sp21_a_space_administrator_changes_no_kernel_setting, ::test_sp21_a_space_administrator_cannot_resume_a_stop_a_workspace_administrator_set, ::test_sp21_a_space_administrator_cannot_switch_the_assistant, ::test_sp21_a_space_administrator_sets_no_mfa_requirement, ::test_sp21_the_account_security_page_offers_a_space_administrator_no_requirement, ::test_sp21_the_console_refuses_a_space_administrator_every_workspace_change, ::test_sp21_the_command_line_refuses_a_space_administrator, ::test_sp21_the_upgrade_adds_the_check_and_the_downgrade_restores_each_functionVerified
T5.33I, E, TAn administrator narrowed to one space reads the data and closure requests of every space, with the names and ids of people outside their spaces, records a response that closes anybody's request, a workspace closure included, or reads observe-report or rotation-readiness, which count every space's observed decisions and list every connector account's unsettled operationsThe queue and both reports are the whole workspace's, so a workspace administrator alone reads or answers them (decided 8 October 2026), by the definition T5.32 uses. accounts.request_queue and the resolve operation of accounts.change ask, inside their own transaction and before any request is read or the definer function runs, that the session be ADMIN and not spaces.narrowed, and refuse anybody else FORBIDDEN; since 0075_workspace_requests_admin the resolve operation of account_change asks the same predicate after its own check and refuses FORBIDDEN, the code it gives an approver, so a response is refused even with nothing in front of the function. GET /api/console/v1/account/requests, by a session or by the administrator's personal access token, and POST /api/console/v1/account/requests/resolve answer 403 FORBIDDEN, and Workspace settings shows a narrowed administrator a note in place of the queue and does not read it. observe_report and rotation_readiness ask store.workspace_admin_check at each of their identity checks and refuse INVALID_IDENTITY, the code an approver gets, so observe-report and rotation-readiness exit 2 with it. A person's own request, the request operation filed and read on My account, is unchanged, so a narrowed administrator still asks for their own data or the workspace's closure. A workspace administrator is unchanged (accounts.py, surfaces/console_api/account.py, execution/modes.py, rotation.py, migrations/versions/0075_workspace_requests_admin.py, console/src/components/settings/WorkspaceService.tsx).SP22; tests/test_spaces_workspace_requests.py::test_sp22_a_space_administrator_reads_no_request_queue, ::test_sp22_a_space_administrator_records_no_response, ::test_sp22_the_definer_function_records_no_response_for_a_space_administrator, ::test_sp22_a_space_administrator_files_and_reads_their_own_requests, ::test_sp22_a_space_administrator_reads_neither_workspace_report, ::test_sp22_the_console_refuses_a_space_administrator_the_queue, ::test_sp22_a_space_administrator_s_token_reads_no_request_queue, ::test_sp22_the_command_line_refuses_a_space_administrator_both_reports, ::test_sp22_the_upgrade_adds_the_check_and_the_downgrade_restores_the_functionVerified
T5.34EA longer default deadline widens the window in which a held request can be approvedHosted Community workspaces start at 600 seconds rather than core's 120, set in the transaction that makes the workspace. The value is within the deadline's existing 120 to 3600 second bounds, and context validity still caps every approval (store.approval_expiry), so no approval outlives the facts it was frozen on. tenant.provisioned records the value, and a workspace administrator changes it only on the audited path. Self-hosted workspaces and quickstart keep 120.hosted: AY20Verified
T5.35E, IA Policy manager narrowed to some Spaces, or an administrator narrowed to some Spaces by a membership or a Space-scoped grant, previews a candidate policy and learns another Space's recorded decisions, their counts or their actionspolicy/preview.py preview_bundle keeps only the decisions whose execution's Space is one where the reader holds policy.manage, in one statement over executions before anything is counted, so considered, both effect counts and the changed rows are the reader's Spaces' alone. Only preview_scope's check, workspace.manage with no Space and any_scope left false (the check the console's preview made before), skips the filter, so a narrowed administrator, who holds workspace.manage only through access_has_permission's p_any branch, is filtered too, and a workspace-wide administrator's answer is unchanged apart from scope. replay/export.py export_history still requires workspace.manage by default and is passed policy.manage only by the preview, so neither the replay export nor its command widens, and the CLI's bundle-preview keeps its own ADMIN kind check. The policy must be one the reader may read (_bundle_access), so another Space's policy that is not offered answers 404 before anything replaystests/test_policy_preview_spaces.py::test_a_policy_managers_preview_holds_only_their_spaces_decisions, ::test_a_narrowed_administrators_preview_holds_only_their_spaces_decisions, ::test_an_administrators_preview_is_unchanged, ::test_the_replay_export_still_refuses_a_policy_manager, ::test_another_spaces_bundle_is_not_found_for_a_manager, ::test_a_policy_manager_previews_through_the_console_and_others_are_refused; PL2, PL3Verified, see R37
T5.36T, E, IA rules file carries another workspace's group, account or Space ids into a draft, activates on import, or a download carries this workspace's record identifiers outA download (GET /api/console/v1/policy/{bundle_id}/ruleset) leaves out every budget counted for a group, an account or a Space and the ruleset's context, the only parts of a Ruleset that name a record, and is held to containing no UUID; rule notes, condition values and selector values are the rules and travel as written, which the download note says, so the claim is record identifiers and no more. An import (POST /api/console/v1/policy with source: ruleset) is parsed by rulesets.parse_ruleset_document, which refuses more than 512 KB in bytes and a repeated key; refuses a qualified budget and a context at their locations; resolves every action only against the target Space's own or offered mappings (_space_mappings), naming an unmapped action INVALID_BODY and one only a signal-contract mapping serves SIGNAL_MAPPING_ONLY; compiles over those mappings alone, with no context and no signal mappings; and lands only as a DRAFT through create_bundle's creation scope, the archived-Space refusal and the people's text screen, so it must still be validated, previewed and activated there. Refusals name locations, never valuestests/test_console_api_policy_rules_file.py::test_a_download_is_the_ruleset_file_the_cli_reads, ::test_a_download_carries_no_identifier, ::test_a_rules_file_becomes_a_draft_over_this_spaces_mappings, ::test_a_rules_file_is_refused_at_its_location_and_writes_nothing, ::test_an_action_served_only_by_a_signal_mapping_is_named_as_such, ::test_an_oversized_rules_file_is_refused, ::test_a_policy_manager_imports_only_into_a_space_they_manage; PL8, PL9Verified
T5.37IAn administrator learns which people of the workspace have no second factoradmin_member_factors (0079) answers one boolean per listed person of the current tenant, only to an active administrator of an active tenant, under the tenant fence, reading no secret or count, and refuses more than 500 ids. It reads the factor step-up enforcement reads (user_accounts.factor_account, then user_factors for a membership's account or account_factors without one), so it cannot report a stale legacy row, and it sets execbound.account_id per account and restores the caller's, so it works where the owner does not bypass row security. On hosted the boolean describes an account-level factor the account shares across its workspaces, so it says the same thing in each workspace the person belongs to. An administrator's personal access token can read it through GET /people, as it can already read the people list. The same administrator can already require a factor for everyone (account_security_policy). Accepted: the list helps whoever controls an administrator session target weak accounts, which that session can already reach.AK9; tests/test_key_last_used.py::test_ak9_member_factors_answers_an_administrator_one_boolean_per_person, tests/test_member_factors_without_bypass.py::test_ak9_member_factors_reads_account_factors_without_bypass, tests/test_console_api_people.py::test_ak9_people_carry_their_second_factor_as_enforcement_reads_itVerified
T5.38IThe operator's service-measures report re-identifies a workspace or a person, carries a workspace's records across the workspace boundary, or counts a workspace whose people refused analyticsThe report is a hosted command over the owner connection that refuses any role but the schema owner (OWNER_REQUIRED) and appends nothing. It walks the directory through job_tenants_page after check_directory_policy, and reads each workspace in its own read-only, repeatable-read transaction fenced to that workspace, every statement naming the tenant. The reader returns a value holding only times, state words and booleans, so no identifier, name, address, destination or request number can reach the aggregation, and the output's keys are a fixed set. A workspace where anyone has recorded a refusal or withdrawal of analytics consent contributes to no figure, and neither does the showcase; the walk prints no count of either. A figure drawn from fewer than five workspaces, a count under five and a count whose complement is under five print as null and are named as suppressed, and so does any figure that could be worked out from printed ones: partitions print whole or not at all, a daily series prints whole or not at all beside its total, and origins are disjoint per run. The threshold is a constant, not an argument. Nothing is stored or shown on a product page. The owner role can still read every row directly, and can difference two runs over different windows, which the assumption that the owner is trusted already covers (the hosted plugin's own code).hosted: MS3, MS4, MS5, MS6, MS7, MS8, MS9Verified
T5.39IThe dormancy warning reaches someone who is not a live administrator of the workspace, or an administrator's address reaches an audit event, a log line or the warning's own row (hosted service only)Recipients are chosen inside the workspace's fence when the warning is queued (an active person, not an agent or the scheduler principal, holding workspace-wide access.manage, the test agents_sleep picks its actor by) and checked again in the same fence immediately before the send, so a person deactivated or narrowed in between is skipped (NOT_ADMINISTRATOR). The address is resolved at send time, from the account's address through an active membership or else the principal's own (weekly_summary.resolve_address), and is held only in memory for the send: the row has no column for it, nothing is appended to the audit chain, and the log lines carry the tenant, the warning's id, a count and a code. A person with no address is skipped (NO_ADDRESS) rather than sent anywhere else (the hosted plugin's own code).hosted: DW2, DW6, DW7Verified
T5.40TThe dormancy warning tells an administrator something false (a sleep date after a governed request moved it, a sleep already reached, a workspace that is not approaching sleep) or carries something a mail client or a reader would act on (hosted service only)The warning is queued only when dormancy_warn finds the workspace active, free, with an awake agent and between day 76 and day 90 of silence, and names the approach by the moment silence ran from. Before each send the workspace is read again in its fence, and the warning is skipped (NO_LONGER_DUE) unless it is still due on the same anchor and its sleep moment has not passed; a governed request starts a new approach with its own warning, and scheduler events are not activity. Sleep itself is never tied to a warning's delivery: a workspace sleeps at day 90 whether its warning was delivered, deferred and never sent, held by a drain or failed, and the sleep pass leaves the warning as it found it. The email is a fixed plain-text template whose only reader-supplied text is the workspace name through name_label; it links only the console's origin, carries no token or unsubscribe, and names the tenant id only where the name cannot be shown as written, to say which workspace to choose. The name is the workspace's own administrators' text, so it can still read as a web address, as the weekly summary's can (T4.53) (the hosted plugin's own code).hosted: DW1, DW4, DW5, DW6, DW12Verified
T5.41DA free workspace's administrators spend the operator relay's daily allowance with dormancy warnings, so every workspace's password reset and signup verification mail waits past its link's life, or one workspace's warnings hold back another's (hosted service only)A workspace may make any number of administrators with addresses of its choosing, so the warnings are bounded by the relay rather than by the workspace. Warnings, signup mail, reset mail and approval emails spend one daily allowance and each counts all four, a warning that a drain holds under a live lease counting with the delivered ones. A warning is sent only while the day's relay mail is below half of daily_limit, rounded down, and is leased only while the day's warnings, delivered or being sent, are below that half, read under one transaction-scoped advisory lock held until the lease is committed, so two drains running at once cannot both take the last of it: however many warnings are queued and however many drains run, the day's warnings as the service counts them never pass that half, and signup and reset mail keep the rest of the day as it is counted, less the quarter approval emails may spend. That count is of mail recorded as delivered or held under a live lease, not of what the relay received. A warning whose delivery was uncertain because its drain died mid-send holds no room once its lease lapses, so it never blocks its own retry: the next pass takes it again, checks it afresh and sends it, counting it once whatever the relay received the first time, as a signup or reset email in that state is, and a fifth attempt left that way is failed, never sent a sixth time. So each send cut off after the relay took its message and before it was settled leaves the relay holding one message more than the day's count: on a day with such a send, warnings can reach the relay more often than the half allows and signup and reset mail can have less than the other half of the relay's own allowance, as a signup or reset email cut off the same way already runs the count short. A warning over that share waits for a later pass with its attempts untouched. Due warnings are handed out one workspace at a time, every workspace's first before any workspace's second, so a workspace with many administrators cannot spend the warnings' half before another workspace's turn, and within each turn the soonest sleep goes first. Warnings are best effort and sleep never waits for one (T5.40): a warning still waiting when its workspace's sleep moment arrives is skipped, never sent late, so a backlog larger than the share can carry before those moments leaves some administrators unwarned (migrations/versions/0092_dormancy_warning.py, and the hosted plugin's own code).hosted: DW6, DW11, AM9Verified
T5.42DApproval emails flood a person, or spend the operator relay's daily allowance so signup verification and password reset mail wait past their links' life (hosted service only)Agents decide how many requests are held, so each person receives at most per_person_hour (10) emails in a rolling hour and per_person_day (50) in a rolling day in each workspace, counted with any held under a live lease. Approval emails alone may spend at most a quarter of daily_limit, rounded down: across every workspace, delivered or held under a live lease, read with the person's counts under one transaction-scoped advisory lock held until the lease is committed, so two passes cannot both take the last of the quarter. All counted relay mail together (signup, reset, dormancy warnings and approval emails; the weekly summary and operator-destination email are not counted) may spend at most the whole of daily_limit, but the day is read before each lease and outside the lock, and signup and reset mail count only once delivered, so it is not a strict bound: each pass, signup send or reset send running at the same moment can take it one message past the limit. Over a limit an email fails RATE_LIMITED at once, never deferred, because a late approval email can only mislead; a count that cannot be read leaves it pending for the next pass. Other mail counts against the whole day and not against the quarter, so on a day when signup, reset and dormancy mail alone pass a quarter, approval emails still go until the whole day is spent; signup, reset and dormancy mail count approval emails, so with the dormancy warning's half, signup and reset mail keep at least a quarter of the day as it is counted. T5.41's caveat about a send cut off after the relay took it applies alike (the hosted plugin's own code).hosted: AM9Verified

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. An administrator can also mint a source's token from the console, where the deployment has configured console connect: it is the same mo1 token, bound the same way, and reports for its own story only.tests/test_monitoring_http.py, tests/test_monitoring_store.py, tests/test_console_api_monitoring_sources.py::test_mc14_a_minted_token_is_shown_once_and_reports_for_its_story_only; 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. A change to a monitoring connection (created, its sealed key replaced, a collection asked for, its schedule changed, revoked) appends an informational audit event, which is not an execution mutation: replay treats each as deciding nothing, and connecting takes no tenant control row.tests/test_monitoring_store.py, tests/test_monitoring_worker.py, tests/test_replay_informational_kinds.py::test_monitoring_connection_changes_leave_every_later_decision_replayable, tests/test_monitor_connect_store.py::test_mc12_a_tenant_that_never_executed_gains_its_audit_head_and_nothing_elseVerified
T6.3DIntake floodPer-source rate limit of 120 attempts per minute (heartbeats included), intake concurrency of 8, 100 lifetime sources per tenant and 10,000 retained observations per source. Rotation preserves the lifetime count. Heartbeats update freshness without consuming observation capacity or queuing work, even at capacity; older retained heartbeat rows stay (monitoring/http.py, monitoring/store.py).tests/test_monitoring_http.py, tests/test_monitoring_store.py::test_full_source_can_heartbeat_and_rotate_without_resetting_its_event_cap, ::test_heartbeats_share_the_source_rate_limit, ::test_heartbeats_kept_by_an_older_release_remain_countedVerified
T6.4SA notification destination chosen by untrusted content, or a configured one whose name is turned against the deployment (server-side request forgery)Every destination comes from the operator's private configuration, validated at start and fixed against a reload, and no finding, observation, source, agent or request field names one. A finding is routed at drain time to the destinations configured then, by class, rule and Space, and its message carries identifiers, a rule's fixed label and the console's link only. For the webhook, Slack and Teams kinds of both classes, every connection resolves its host once, inside the send's deadline, and is refused before any request unless every address the resolver answers is public: not private, loopback, link-local including 169.254.169.254, shared 100.64.0.0/10, multicast, reserved or unspecified, nor an IPv6 address carrying one of those IPv4 addresses (mapped, NAT64 64:ff9b::/96, IPv4-compatible, IPv4-translated, 6to4 or Teredo), nor an IPv6 address outside global unicast 2000::/3 that carries none. The connection is then made to a checked address, the first three in the resolver's order tried in turn, with the TLS name and certificate still the URL's host; keep-alive is off, so every send is resolved and checked again, and the whole send ends within ten seconds. A URL whose host is itself a loopback literal is the one exception, for the local test receiver and the test fakes, and any other address literal is checked like a name; there is no opt-in for private networks; no redirect is followed, no proxy is read from the environment, a body is at most 4096 bytes and a response body is never read. The SMTP relay, the deployment's own, is exempt (egress.py, monitoring/worker.py, notifications.py). Residuals: on a network whose DNS64 synthesizes addresses under a network-specific NAT64 prefix of the operator's own rather than the well-known 64:ff9b::/96, a name whose IPv4 address is private resolves to an IPv6 address the guard reads as public, so the guard does not see the address it carries (the local-use prefix 64:ff9b:1::/48 lies outside 2000::/3 and is refused outright); the 6to4 and Teredo checks are defence in depth, since Python already counts both ranges as not global. The security event export builds its own client (export.py), outside the guard, so a security-events destination's name is not checked against private addresses, and it still counts only 200 and 204 as delivered.FD1, FD5, FD12; tests/test_egress.py::test_fd12_a_name_that_resolves_to_an_address_that_is_not_public_is_refused, ::test_fd12_one_answer_that_is_not_public_refuses_the_name, ::test_fd12_a_public_address_is_public_in_each_form_it_takes, ::test_fd12_an_address_literal_other_than_loopback_is_checked_like_a_name, ::test_fd12_the_connection_is_made_to_the_address_that_was_checked, ::test_fd12_a_first_address_that_refuses_gives_way_to_the_second, ::test_fd12_at_most_three_addresses_are_tried_in_the_resolvers_order, ::test_fd12_every_send_resolves_and_connects_again, ::test_fd12_tls_is_verified_against_the_urls_own_name, ::test_fd12_a_send_that_trickles_is_cut_off_at_its_deadline, ::test_fd12_a_lookup_that_outlasts_the_deadline_is_given_up_as_unavailable, ::test_fd12_no_redirect_is_followed, ::test_fd12_an_oversize_body_is_refused_before_any_request, tests/test_findings_delivery.py::test_fd12_the_approval_drain_refuses_a_name_that_resolves_to_loopback, ::test_fd12_the_findings_drain_refuses_a_name_that_resolves_to_loopback, ::test_fd1_routing_writes_one_delivery_per_admitted_destination, tests/test_findings_messages.py::test_fd5_hostile_workspace_and_space_names_reach_no_body_as_markup, tests/test_event_export.py::test_fd12_the_security_event_export_still_counts_only_200_and_204Verified for approval and findings destinations; Partial for the security event export
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). A source is also minted, rotated and revoked from the console: the minting administrator is recorded on the source too (created_by, 0090_monitor_source_creator), no token the store mints or rotates lives beyond thirty days, an administrator narrowed to other spaces can neither rotate nor revoke a source outside them, and the lifecycle still stays off the execution audit chain.tests/test_monitoring_lifecycle.py (SL1 to SL10, MC14, MC15), tests/test_console_api_monitoring_sources.py::test_mc15_rotation_keeps_the_previous_token_for_the_grace_and_no_longer, ::test_mc15_revocation_refuses_every_token_at_once_and_cannot_be_undoneVerified
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.TINES_MONITORING.mdAccepted, 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 accessA key_file 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). A sealed connection, a key pasted in the console, keeps the fingerprint and a ciphertext that only the collector's private key opens (T6.27).TD1, TD2, TD12; tests/test_tines_collection.py, tests/test_monitor_sealing.py::test_mc3_a_sealed_key_opens_only_for_its_own_tenant_connection_and_kindVerified
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, AuditLog.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). A directory connected from the console by consent is read with the deployment's own platform app, which holds the same four read-only permissions, still nothing that can write, and is a separate registration from any that executes (T6.32).SR1, SR13; tests/test_scout_run.py, tests/test_monitor_platform_app.py::test_mc3_an_assertion_names_the_certificate_and_lives_five_minutes_with_a_fresh_idVerified
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. Each follow-up kind is opt-in per destination in the same deployment configuration (messages, default pending alone), validated at start and fixed against a reload, and an outcome message is informational: it carries no capability and reports a decision without saying that anything ran. Findings delivery is opt-in in the same configuration: a destination's classes default to approvals, so an existing file sends no finding; rules and spaces narrow a findings destination and are refused without findings, messages and approvers are refused without approvals, and a reload that changes any of them is refused. A findings-only destination never receives an approval and never counts as announcing one, and a finding message is informational and carries no control. Every administrator of the workspace sees each findings destination on Alert settings by id, kind, host, rules and Spaces, never its URL, address or secret, and a Space the reader cannot read is counted, never named. The residual is that an administrator narrowed to some Spaces still sees a destination's id, kind, host and rules where it is scoped only to Spaces they cannot read.AY11, FD7, FD8, MQ11; tests/test_approval_delivery_config.py::test_ay11_a_list_outside_the_rules_refuses_the_file_without_quoting_it, tests/test_approval_delivery_config.py::test_ay11_a_reload_that_changes_messages_is_refused_by_the_shape, tests/test_notification_chat.py::test_chat_urls_outside_the_rules_refuse_without_quoting_them, tests/test_notification_chat.py::test_the_tenant_overview_shows_kind_and_host_never_the_url, tests/test_notification_chat.py::test_a_chat_destination_carries_neither_a_secret_nor_a_recipient, tests/test_approval_notifications.py::test_an10_invalid_destinations_are_refused_at_startup_without_leaking_the_secret, tests/test_findings_delivery_config.py::test_fd7_a_file_outside_the_rules_is_refused_without_quoting_it, tests/test_findings_delivery_config.py::test_fd7_a_reload_that_changes_classes_rules_or_spaces_is_refused_by_the_shape, tests/test_findings_delivery.py::test_fd8_a_findings_only_destination_is_never_queued_an_approval, tests/test_console_api_monitoring.py::test_mq11_the_snapshot_names_each_findings_destination_and_never_its_address, tests/test_console_api_monitoring.py::test_mq11_a_reader_narrowed_to_one_space_never_sees_another_spaces_nameVerified
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.the hosted service's own testsVerified
T6.20DAn approver is not told of a live approval because the drain judged it lapsed, or because a retry or a cap deferral was droppedThe drain reads the deadline from the stored approval in the claim itself, never from a caller, and closes a row unsent as LAPSED_BEFORE_DELIVERY only when that approval is still pending and expires within fifteen seconds (notifications.LAPSE_MARGIN); with more time left the row is sent as before. A retry or a cap deferral that would land later than that point fails the row at once with the failed attempt's own code (RECEIVER_UNAVAILABLE, MAIL_REFUSED, RATE_LIMITED and so on), so a broken receiver or relay stays visible as a failure and is never hidden under a lapse. Every outcome is a row code the notifications command lists, and the approval itself is untouched, so the console still shows it to every approver. The jobs service drains every fifteen seconds and runs its jobs side by side, so a long recovery pass cannot hold a notification back past its deadline (notifications.py, schedule.py). The hosted service's personal approval email reads the same margin, backoff and attempts.AY1, AY2, AY3, AY4, AY14; hosted: AM5, AM6; tests/test_approval_delivery.py::test_ay1_a_row_inside_the_lapse_margin_is_closed_unsent, ::test_ay3_no_retry_lands_past_the_deadline, ::test_ay4_the_email_cap_never_defers_past_the_deadline, tests/test_jobs_schedule.py::test_ay14_a_long_job_does_not_hold_notify_backVerified
T6.21EAn administrator narrowed to some spaces, or a session or token looking at one space, acknowledges a finding outside it by naming its identifier, and silences it for the administrators who own itAcknowledgement locks the finding through the same space_clause(visible_spaces(...)) predicate the monitoring reads apply, so a finding in a space the caller does not see is refused exactly as one that does not exist (FINDING_UNAVAILABLE, answered 404 NOT_FOUND with the unknown identifier's body), and neither the row nor the chain changes (monitoring/store.py). The reads hold to the same predicate: the findings list and the one-finding read (a finding outside it answers the unknown identifier's 404), the Monitoring agent page's findings, the coverage rows, the inventory, which takes its connection's space, and the deliveries, which take their finding's. Bulk acknowledgement locks only the findings the same predicate admits and answers any other identifier unavailable, exactly as one that does not exist (acknowledge_many). Spaces are a view rule, not isolation: a workspace-wide administrator on All spaces still acknowledges every finding.MQ1, MQ5, MQ10, MQ14; tests/test_monitoring_acknowledgement.py::test_mq1_an_administrator_cannot_acknowledge_a_finding_outside_the_spaces_they_see, tests/test_console_api_monitoring.py::test_mq1_acknowledging_outside_the_space_answers_as_unknown, tests/test_monitoring_acknowledgement.py::test_mq5_one_finding_reads_inside_the_space_only, tests/test_console_api_monitoring.py::test_mq5_one_finding_answers_by_identifier_only_where_visible, tests/test_monitoring_acknowledgement.py::test_mq10_the_agent_page_lists_only_findings_in_the_space, tests/test_monitoring_acknowledgement.py::test_mq10_coverage_inventory_and_deliveries_follow_the_space, tests/test_monitoring_acknowledgement.py::test_mq14_bulk_acknowledgement_reports_each_finding, tests/test_console_api_monitoring.py::test_mq14_bulk_over_httpVerified
T6.22RA collector stops, or keeps running without completing, and its connection keeps reading healthy, so missing evidence looks like a quiet periodOne read-time rule, collection_overdue_since and collection_reporting, decides freshness from server time and writes nothing: a connection is overdue a day after its last run of any outcome or its registration (a connection the collector opens itself, such as a sealed one, at twice its own interval if that is sooner), or two days after its last inventory sync. The setup verifier and the Connections view call it, and the sidebar, Home and the report count through its SQL twin OVERDUE_CONNECTIONS at the same boundary, under the reader's space predicate; an overdue or non-reporting connection never takes the allow tone, and quiet means stale sources plus overdue connections everywhere (monitoring/store.py, surfaces/console_api/monitoring_setup.py)MQ7; tests/test_monitoring_freshness.py::test_mq7_every_surface_answers_the_same_for_every_case, tests/test_monitoring_freshness.py::test_mq7_no_surface_keeps_a_rule_of_its_own, tests/test_monitoring_freshness.py::test_mq7_overdue_and_source_counts_follow_the_spaceVerified
T6.23S, EA follow-up message is read as authority: an approved outcome taken by a receiving automation as permission to act, a reminder read as extending the approval, or a follow-up spends the email cap an announcement neededNeither follow-up carries a control: a reminder is the announcement's fixed copy again, sent only while the approval is pending and more than fifteen seconds from its deadline, and it never writes expires_at or anything else on the approval, so its window is the one the approval already had; it is queued only once half the window has passed with at least three minutes left, at most once per approval and destination by the same key, and routed by the stored request as the announcement is. An outcome message carries no control, and the decision stays on the authenticated approval page; its copy and the webhook documentation say it decides nothing, does not say whether the action ran and grants nothing, and only the kernel authorises a dispatch. Reminders and outcome messages are opt-in per destination, so a receiver that never reads kind never gets one, and a row whose message was switched off is settled MESSAGE_OFF unsent. At most one outcome per approval and destination is a schema fact (approval_notifications_one_per_kind), its outcome and time are fixed when it is queued, and two racing drains queue and send it once. A follow-up email never spends the last tenth of the daily cap, which stays for a new request's announcement, and is refused at the cap rather than deferred (notifications.py, transports.py, migration 0080).AY8, AY9, AY10, AY18; tests/test_approval_delivery.py::test_ay8_each_outcome_is_followed_up_once_where_asked, ::test_ay8_a_close_out_whose_message_was_switched_off_is_not_sent, ::test_ay9_two_drains_send_one_close_out, ::test_ay18_follow_ups_never_take_the_last_of_the_email_cap, ::test_ay10_one_reminder_where_it_can_arrive, ::test_ay10_a_reminder_follows_the_closed_and_lapse_rules, ::test_ay10_a_reminder_whose_message_was_switched_off_is_not_sent, ::test_ay10_reminders_respect_the_follow_up_capVerified
T6.24T, RAn acknowledgement is final, so a stolen personal access token (T4.42) or one mistaken "Select all open on this page" silences many findings for good, and they leave the default Open queueBulk acknowledgement is refused to personal access tokens (token_rules.TOKEN_REFUSED), so it needs a browser session, and the console sends nothing until an inline confirmation naming the count is pressed; a request takes at most 50 distinct canonical identifiers; each finding acknowledged appends its own monitor.finding_acknowledged naming the backing credential; and a database trigger refuses any change to an acknowledgement once made, for every role, so a note and its event can never disagree with the row (monitoring/store.py acknowledge_many, console_api/monitoring.py, migrations/versions/0081_finding_acknowledgement.py). Residual: a token can still acknowledge findings one per request, permanently, as T4.42 records; the remedy is revoking it and reading the trail.MQ2, MQ14, MQ15; tests/test_console_api_monitoring.py::test_mq14_a_token_cannot_acknowledge_in_bulk, tests/test_monitoring_acknowledgement.py::test_mq14_concurrent_bulk_acknowledgements_acknowledge_each_finding_once, tests/test_monitoring_acknowledgement.py::test_mq15_the_database_refuses_to_change_a_finished_acknowledgement; Vitest console/src/components/monitor/Findings.test.tsxPartial
T6.25TTwo collection runs collect one connection at once, or one keeps writing after the connection is revoked, and corrupt its cursor or its outcome, or collect after the customer stopped itEvery run, scheduled or an operator's collect-tines or collect-entra, first takes the connection's lease through collector_claim, for at most fifteen minutes measured by the database's own clock, and a second claim while it runs is refused; every write the run makes to its connection row lands only while the row carries that lease and is active, and otherwise raises LEASE_LOST or CONNECTION_REVOKED and changes nothing; the steps that write no connection row check the lease on the locked row; revoking a connection clears its lease in the same statement; a lease that expired unfinished is recorded INTERRUPTED (RUN_INTERRUPTED) by the next claim. The runtime may write none of the lease columns itself (monitoring/due.py, monitoring/store.py, migration 0086). Residual: a run's outcome and its release commit separately, so a run stopped between the two leaves its lease to expire, and the next claim records INTERRUPTED over the outcome it had recorded; a release that fails for any reason but a lost lease fails that run too.MC6; tests/test_monitor_lease.py::test_mc6_two_claims_from_two_connections_admit_exactly_one, tests/test_monitor_lease.py::test_mc6_a_write_with_a_lease_the_connection_no_longer_carries_changes_nothing, tests/test_monitor_lease.py::test_mc6_revoking_a_connection_mid_run_clears_the_lease_and_stops_the_run, tests/test_monitor_lease.py::test_mc6_an_expired_lease_is_recorded_interrupted_then_claimed, tests/test_monitor_collect_due.py::test_mc7_an_operator_collection_waits_for_no_one_and_overlaps_no_oneVerified
T6.26EThe scheduled collector or evaluator acts with authority it should not have: in another workspace, in a deactivated one, outside a lease, or at a call site never meant to accept an actor with no credentialcollect-due --all-tenants and process --all-tenants hold no workspace credential. Each visits a workspace as a ScheduledActor that names no principal, admitted by scheduled_check only while the workspace is active and only under that workspace's own fence, and only at the call sites tests/test_scheduled_actor.py pins by name, with no alias to hide one; a collection step refuses it without a lease; neither pass appends an audit event or writes the actor into a row, so the scheduler principal is never made, and --all-tenants delivers only with --deliver, routing and sending each workspace's findings under that workspace's own fence to the destinations the private configuration names for it. One workspace's refusal is its own line, never the run's. The jobs service passes --deliver to monitor-process wherever its runtime configuration reads as --deliver reads it, decided once when the service starts and recorded on its jobs.started line; a runtime configuration that breaks after that stops no evaluation: the pass still evaluates, then routes and sends nothing, logs monitor.delivery_refused and fails. The residual is that a file broken when the service starts and fixed later gets no --deliver until the service restarts (monitoring/due.py, monitoring/store.py, monitoring/worker.py, schedule.py).MC7, MC21, SJ9, FD13, FD14; tests/test_monitor_process_all.py::test_mc21_each_workspace_is_evaluated_under_its_own_fence, tests/test_monitor_process_all.py::test_mc21_one_pass_evaluates_every_active_workspace_and_delivers_nothing, tests/test_monitor_lease.py::test_mc6_the_scheduled_actor_is_refused_without_a_lease_another_fence_or_an_active_workspace, tests/test_monitor_collect_due.py::test_mc7_one_pass_collects_each_workspace_on_its_default_intervals, tests/test_scheduled_actor.py::test_sj9_only_the_pinned_functions_accept_a_scheduled_actor, tests/test_findings_delivery_cli.py::test_fd13_each_workspace_is_routed_and_drained_under_its_own_fence, tests/test_findings_delivery_cli.py::test_fd13_all_tenants_deliver_sends_each_workspaces_findings, tests/test_findings_delivery_cli.py::test_fd13_a_runtime_configuration_broken_since_start_stops_no_evaluation, tests/test_jobs_schedule.py::test_fd14_monitor_process_delivers_wherever_a_runtime_configuration_reads, tests/test_jobs_schedule.py::test_fd14_serve_runs_monitor_process_with_deliver_only_when_toldVerified
T6.27IA gateway, database or backup compromise yields a Tines API key an administrator pasted in the consoleThe gateway holds the collector's public key alone (the runtime configuration's scout.collector); the pasted key lives in one request's memory and is sealed, libsodium's sealed box through PyNaCl, in an envelope binding it to its tenant, connection and kind before anything is written; the database keeps that ciphertext, the id of the collector key it was sealed to and the key's SHA-256, and the runtime inserts and updates neither sealed column (three definer functions write them, migration 0087). Only the collector's private key opens it, and that key sits in the collector's environment alone: on the hosted service, a secret no other service receives; on a Community installation, the jobs container's own read-only mount; never the gateway's. The collector refuses an envelope naming another row (SEALED_KEY_MISMATCH) and a key that is not the registered one (KEY_MISMATCH) before any request; a run reads the ciphertext again once it holds the connection's lease, so a replaced key is the one it sends. collector-reseal moves every sealed row in every active workspace to a new collector key with its fingerprint unchanged, only from the ciphertext it read, so a key replaced meanwhile is never overwritten, while the collector holds both keys (--collector-key given twice) so nothing is sealed to a key it lacks; a deactivated workspace's rows stay under the old key until it is reactivated (monitoring/sealing.py, monitoring/connect.py, monitoring/due.py). Residual: a compromised gateway sees the keys pasted while it is compromised, and a re-seal does not protect a key already sealed to an exposed collector key, which has to be replaced in Tines.MC2, MC3, MC4; tests/test_monitor_sealing.py::test_mc2_the_gateway_holds_the_public_key_only_and_cannot_open, tests/test_monitor_sealing.py::test_mc2_nothing_the_gateway_loads_imports_the_collectors_half, tests/test_monitor_sealing.py::test_mc3_a_ciphertext_copied_to_another_row_is_refused_and_never_sent, tests/test_monitor_sealing.py::test_mc4_reseal_moves_every_sealed_row_to_the_new_key_and_leaves_the_rest, tests/test_monitor_sealing.py::test_mc4_a_key_replaced_while_a_reseal_runs_is_never_overwritten, tests/test_monitor_sealing.py::test_mc4_during_a_rotation_the_collector_opens_a_key_sealed_to_either, tests/test_monitor_sealing.py::test_mc3_a_key_replaced_after_the_pass_read_its_connections_is_the_one_collected, tests/test_community_bundle.py::test_the_collectors_key_is_mounted_into_the_jobs_container_alone; hosted: MC24Verified
T6.28IA pasted key, or a reporting source's token minted in the console, leaks through the browser, an answer, an event or a log lineThe console holds the key in one form's state, in a password field, and clears it on success, refusal and close; the change is a mutation kept for no time and reset, so no query or mutation record, browser storage or URL holds it; a malformed body is refused by field location only and never echoed; no answer, snapshot, table but sealed_ct, audit payload or log line carries the key, and the answers pass the export's secret check (console/src/components/monitor/ConnectTines.tsx, surfaces/console_api/monitoring_connect.py). A source token is in the two answers that mint one and nowhere else: they set Cache-Control: no-store on the route's own response, no read carries the token, and the database keeps its SHA-256 only; the console shows it once through the show-once path in a dialog that closes on its own button only (never on Escape or a press outside it), and then no query or mutation record, storage or document holds it (surfaces/console_api/monitoring_sources.py, console/src/components/monitor/SourceTokenDialog.tsx).MC2, MC14, MC20; tests/test_console_api_monitoring_connect.py::test_mc2_a_pasted_key_is_sealed_and_appears_nowhere_else, tests/test_console_api_monitoring_connect.py::test_mc2_a_malformed_body_never_echoes_what_it_carried, tests/test_monitor_connect_store.py::test_mc2_the_key_is_in_no_table_log_or_event_but_its_ciphertext, tests/test_console_api_monitoring_sources.py::test_mc14_a_minted_token_is_shown_once_and_reports_for_its_story_only, ::test_mc14_minting_and_rotation_answer_no_store_on_their_own, ::test_mc14_a_malformed_body_never_echoes_what_it_carried, ::test_mc20_no_source_token_is_in_a_table_a_log_line_or_an_audit_payloadVerified
T6.29EA stolen console session or personal access token creates a credentialed monitoring connection, replaces its key, mints or rotates a reporting source's intake token, or silences monitoring by revoking a connection or a sourceEvery connection and source route is an administrator's behind the session, the exact Origin and CSRF; connecting Tines, starting a Microsoft Entra consent, replacing a key, minting, rotating and both revocations ask for a factor verified within thirty minutes (T4.48) and refuse a personal access token, which has no factor to give, while asking for a collection, changing a schedule within its bounds and reading a source widen nothing and are allowed; the definer functions check the administrator and the space they administer again, and the store does the same for a source's space; each connection change appends an informational event naming the credential that acted, and a source records who minted, rotated and revoked it on its own row. Minting and rotation answer CONNECT_UNAVAILABLE wherever console connect is not configured, so console minting is never live where console connect is not; a source token reaches only the intake for its own story (T6.1).MC12, MC13, MC14, MC22; tests/test_console_api_monitoring_connect.py::test_mc13_connecting_replacing_a_key_and_revoking_ask_for_a_recent_factor, tests/test_console_api_monitoring_connect.py::test_mc13_a_token_may_ask_and_schedule_but_never_connect_replace_or_revoke, tests/test_console_api_monitoring_connect.py::test_mc13_every_route_is_an_administrators, tests/test_monitor_connect_store.py::test_mc12_each_connection_change_appends_its_own_event, tests/test_console_api_monitoring_sources.py::test_mc13_a_token_may_read_a_source_but_never_mint_rotate_or_revoke, ::test_mc22_without_console_connect_sources_are_read_and_revoked_but_never_mintedVerified
T6.30SConsent phishing: an attacker starts a Microsoft Entra connect attempt in their own workspace and lures a directory administrator into completing it, so the victim's directory reports into the attacker's workspaceEach attempt is bound to the browser that started it: the start sets a SameSite=None, HttpOnly, Secure binding cookie whose SHA-256 the attempt stores, and both of Microsoft's callbacks require that cookie, the attempt's single-use state (also kept only as a digest) and an attempt under ten minutes old, so a lured administrator's browser, which holds no such cookie, is refused before any consent is asked for and after one is given. The sign-in callback takes Microsoft's own origin and no other; the nonce binds the id_token to the attempt; every refusal ends the attempt as refused with its code, creates nothing and lands on one generic page (monitoring/consent.py, surfaces/connect_pages.py, admin_record_signin, admin_complete_consent, migration 0088). Residual: a browser the attacker controls cannot be told from the administrator's own; the cookie binds the attempt to the browser, not to the person.MC8; tests/test_monitor_consent.py::test_mc8_each_refusal_at_the_sign_in_callback_asks_microsoft_for_nothing, tests/test_monitor_consent.py::test_mc8_each_refusal_at_the_consent_callback_creates_nothing, tests/test_monitor_consent.py::test_mc8_a_used_state_completes_nothing_and_keeps_how_the_attempt_ended, tests/test_monitor_consent.py::test_mc8_a_state_naming_another_workspace_finds_no_attempt, tests/test_monitor_connect_schema.py::test_mc1_a_sign_in_and_a_consent_follow_their_attemptVerified
T6.31SA workspace collects a Microsoft Entra directory it does not administer, or learns that another workspace holds oneThe directory is the one a verified id_token names: RS256 only against Microsoft's published keys, the issuer exactly that directory's, the audience the deployment's own app, current, and wids holding the Global Administrator or Privileged Role Administrator role; email, preferred_username, upn and oid are never read and no ExecBound principal is looked up. The consent address is built for that directory, and Microsoft's callback must name the same one. One active Entra connection per directory across the deployment: the sign-in step reads monitor_directory_claims, a table of directory digests with no tenant column and no runtime privilege, and refuses a claimed directory before any consent is requested; completion checks again, and a unique index across tenants is the backstop, so two workspaces completing consent for one directory at the same moment connect it once and the second is refused DIRECTORY_UNAVAILABLE. The refusal is the one generic sentence every refusal shows, so it names no workspace and does not say that one exists (monitoring/consent.py, migration 0088).MC8, MC9; tests/test_monitor_consent.py::test_mc9_a_directory_connected_in_another_workspace_is_refused_before_consent, tests/test_monitor_consent.py::test_mc9_a_directory_connected_elsewhere_after_the_sign_in_is_refused_at_consent, tests/test_monitor_consent.py::test_mc8_names_and_addresses_in_the_token_change_nothing, tests/test_monitor_connect_schema.py::test_mc1_one_active_entra_connection_per_directory_across_every_tenant, tests/test_monitor_connect_schema.py::test_mc1_two_workspaces_consenting_for_one_directory_at_once_connect_it_once, tests/test_monitor_connect_schema.py::test_mc1_directory_claims_grant_the_runtime_nothing_and_check_runtime_holds_itVerified
T6.32IThe platform app's certificate, which reads every directory that consented to the deployment's app, is compromisedThe app holds read-only Microsoft Graph application permissions and nothing that can write; its certificate and private key live in the collector's environment alone (on the hosted service, a secret no other service receives; on a Community installation, the jobs container's own mount; never the gateway's), and the gateway holds only the app's public client id, authority and name; the collector refuses a consented connection that names another app's client id before any request (CLIENT_MISMATCH); the certificate is rotated every 90 days until a federated credential replaces it; each directory can remove the app, and revoking a connection here stops collection (monitoring/graph.py, monitoring/due.py, SCOUT.md). Accepted: one credential reads every consenting directory, which is the blast radius of an app many directories consent to.MC3; tests/test_monitor_collect_due.py::test_mc3_a_consented_directory_for_another_app_records_client_mismatch_and_asks_nothing, tests/test_monitor_collect_due.py::test_mc3_a_run_itself_reads_a_consented_directory_only_with_the_app_it_names, tests/test_monitor_platform_app.py::test_mc3_the_platform_app_file_is_checked_whole_and_never_printed, tests/test_jobs_schedule.py::test_mc3_the_platform_apps_credential_is_read_from_the_collectors_directory; hosted: MC25Accepted
T6.33TA servicePrincipals/delta link Graph returns, which a connection stores and reads back on a later run, redirects the collector to another destination or another read, or a stored link someone altered in the database doesA delta link, and every next link of a delta round, is accepted only on the pinned Graph origin and exactly /v1.0/servicePrincipals/delta, and a delta link only with a delta token. The connection keeps the link rebuilt from the pinned origin, that path and the validated query, never as Graph sent it, and the database refuses any other prefix, a link over 4,096 characters and a link on a connection with no baseline. A stored link is validated again before every use, and only its query is sent, to the pinned origin and path. A refused link, a 400 or 410 from Graph and a round past 20 pages or 1,000 changes drop the link, write nothing from the round and leave the connection healthy for the next full collection to take a new link; a pass reads the round and what its principals hold before it writes anything, so any other refusal from Graph writes nothing either, and keeps the link for the next pass, a round page that does not parse or carries both continuation links or neither among them, since such a page says nothing about the link it was read from (monitoring/graph.py, monitoring/store.py, migration 0093).SR18; tests/test_scout_delta.py::test_sr18_a_delta_link_off_the_pinned_read_is_never_taken, tests/test_scout_delta.py::test_sr18_a_next_link_off_the_pinned_read_drops_the_link, tests/test_scout_delta.py::test_sr18_a_delta_pass_refuses_a_response_link_off_the_pinned_read, tests/test_scout_delta.py::test_sr18_the_database_refuses_a_stored_link_off_the_pinned_read, tests/test_scout_delta.py::test_sr18_the_database_refuses_a_link_too_long_or_without_a_baseline, tests/test_scout_delta_migration.py::test_sr15_the_upgrade_baselines_what_already_synchronized, tests/test_scout_delta.py::test_sr18_a_stored_link_is_validated_again_before_it_is_used, tests/test_scout_delta.py::test_sr18_a_stored_link_is_sent_only_to_the_pinned_origin_and_path, tests/test_scout_delta.py::test_sr18_a_round_past_its_bounds_drops_the_link, tests/test_scout_delta.py::test_sr18_a_round_is_bounded_at_twenty_pages_and_a_thousand_changes, tests/test_scout_delta.py::test_sr18_a_failure_reading_the_listed_principals_writes_nothing, tests/test_scout_delta.py::test_sr18_a_round_page_that_cannot_be_read_keeps_the_linkVerified
T6.34D, RConnecting a directory floods the alert destinations with a finding for every principal in it, or holding its findings for the summary silences a connection that never reads both principal collections, or the delta pass between full collections hides one: it swallows an administrator's collection request, moves the schedule, or reports the connection healthy when only the delta ran, or unhealthy when only a delta pass stopped; or a partial delta entry stores an agent identity as a classic principal for good, so its accountability is judged by its owners and every later full collection fails; or first-consequential-action.v1 reads only the first page of a workspace's actors, so an actor past it is never raised and the open episodes past it never close; or it stays silent about an actor linked to a governed route that acts with its own directory credential, outside ExecBound, and closes an open episode for good when the link appearsA connection's first complete collection that read both its agent identities and its service principals is its baseline, and one that could not read both takes none, so a principal a later read stores for the first time is never called new: the baseline opens one initial-inventory.v1 summary with counts only and none of the new-actor rules, and the ungoverned-reach.v1 and sponsorless-agent.v1 episodes it opens are recorded without a delivery each because the summary counts them, so connecting a directory queues one delivery; what a connection's first run opens without reading both waits for the summary through that run alone, a later collection that cannot send it delivering what waited and what it opens one by one, and what waited and ended before the summary is delivered with it, so a finding that waited is counted or delivered, never neither; each new-actor rule then raises at most one finding per actor, ever, a newcomer's reach episode is recorded beside its new-actor.v1 without a second delivery, and a connection that had synchronized before the upgrade is baselined at the upgrade whatever its latest collection's outcome, while one that never read the service principals, so holds none, waits for its first collection that reads both. A collection that could not read both asks for no delta link and records its outcome like any collection, and one that fails or gets no link it can trust leaves the stored link as it was. A directory with more principals than a run reads is refused whole (AGENT_CAPACITY), by a full collection, including one that Graph serves in pages too short for its bounded read to reach the end, and by a delta pass whose changes would carry it past the bound, and the rules refuse a connection past it rather than evaluate part of it, so no principal past a silent cut is later reported as new or left unevaluated; first-consequential-action.v1, which reads a whole workspace, reads every actor with an open episode or a new qualifying observation a page at a time, never the first page alone, and reports a consequential action observed for the actor's own directory principal whether or not the actor is linked and so holds a governed route for it, closing the episode only when no qualifying observation is current or the actor is gone. A delta pass claims the lease through collector_claim_delta, which the database refuses while a collection request waits and which never answers one, in the transaction that locks the connection, so a request survives for a full collection to answer; it never writes the run's outcome, last_run_at or last_sync_at, not even when the collector refuses its key file or platform app before any request, which the next full collection records instead, and its lease is marked as a delta pass's, so a pass that stops holding it is not recorded as an interrupted run, while a full collection that stops is recorded by either claim. A principal's class is written once, from a record that states it: a delta entry for a principal the store does not hold that carries neither the agent identity @odata.type nor a servicePrincipalType is left for the full collection's agent identity collection, and a full collection keeps the stored class of a principal the store holds, so a directory that contradicted itself would not stop collection. When a connection is next due and how its health reads stay the full collection's (monitoring/store.py, monitoring/due.py, monitoring/graph.py, migration 0093).SR15, SR16, SR17, SR18, SR19, SR20; tests/test_scout_delta.py::test_sr15_the_first_collection_is_the_baseline_and_opens_one_inventory_summary, tests/test_scout_delta.py::test_sr15_a_directory_past_the_principal_bound_is_refused_whole, tests/test_scout_delta.py::test_sr15_a_directory_in_pages_too_short_to_read_whole_is_refused_rather_than_cut, tests/test_scout_delta.py::test_sr15_a_collection_that_could_not_read_the_service_principals_takes_no_baseline, tests/test_scout_delta.py::test_sr15_a_registration_refused_the_service_principals_takes_no_baseline_and_stays_heard, tests/test_scout_delta.py::test_sr15_what_waits_for_the_summary_waits_through_the_first_collection_alone, tests/test_scout_delta.py::test_sr15_what_waited_and_closed_before_the_summary_is_delivered_with_it, tests/test_scout_delta.py::test_sr15_a_collection_that_could_not_read_the_agent_identities_takes_no_link, tests/test_scout_delta.py::test_sr15_a_later_collection_keeps_the_link_it_had_unless_it_takes_one_whole, tests/test_scout_delta.py::test_sr18_a_delta_pass_never_carries_a_directory_past_the_principal_bound, tests/test_scout_delta.py::test_sr18_the_rules_refuse_a_connection_past_the_bound_rather_than_cut_it, tests/test_scout_delta.py::test_sr20_every_actor_is_evaluated_however_many_precede_it, tests/test_scout_delta.py::test_sr20_a_linked_actor_acting_with_its_own_credential_raises_the_first_action, tests/test_scout_delta.py::test_sr20_a_governed_path_leaves_the_first_action_episode_open, tests/test_scout_delta.py::test_sr16_a_new_principal_with_reach_raises_new_actor_on_the_next_delta_pass, tests/test_scout_delta.py::test_sr17_a_principal_whose_entry_states_no_class_waits_for_the_full_collection, tests/test_scout_delta.py::test_sr17_a_principal_whose_entry_states_its_class_is_stored_with_it, tests/test_scout_delta.py::test_sr17_a_directory_that_contradicts_a_stored_class_never_stops_the_collection, tests/test_scout_delta.py::test_sr19_collect_due_runs_a_delta_pass_between_full_collections, tests/test_scout_delta.py::test_sr19_a_waiting_request_is_answered_by_a_full_collection_never_a_delta_pass, tests/test_scout_delta.py::test_sr19_the_delta_claim_itself_refuses_a_failed_or_due_connection, tests/test_scout_delta.py::test_sr19_the_database_never_lets_a_delta_claim_answer_or_skip_a_request, tests/test_scout_delta.py::test_sr19_an_interrupted_delta_pass_is_not_an_interrupted_run, tests/test_scout_delta.py::test_sr19_a_delta_claim_records_an_interrupted_full_run_and_takes_nothing, tests/test_scout_delta.py::test_sr19_a_failed_delta_pass_is_counted_and_changes_no_health, tests/test_scout_delta.py::test_sr19_a_delta_pass_refused_its_credential_records_nothing, tests/test_scout_delta_migration.py::test_sr15_the_upgrade_baselines_what_already_synchronizedVerified
T6.35IReading a directory's audit log collects the people it names: a target's name and principal name, an initiating person's address, a failure reason or an additional detail written in free text, or the application's own name and idThe parser takes an entry by allowlist: its id, moment, activity, service, result and correlation id, the initiating application's service principal id, and from its one User target the object id and the one modified property the activity map inspects, whose values it reads only as booleans. A person's entry, or an application's acting for a signed-in person, is ignored rather than stored, and an application activity the map does not cover is counted, never stored. What is kept is one observation naming the actor and the target by object id, the action, the outcome, the entry's id and its moment (monitoring/graph.py, monitoring/store.py, migration 0094).SR21; tests/test_scout_audit.py::test_sr21_nothing_that_identifies_a_person_or_an_application_is_kept, tests/test_scout_audit.py::test_sr21_an_entry_a_user_initiated_is_ignored_and_one_naming_no_principal_is_counted, tests/test_scout_audit.py::test_sr21_unmapped_application_activity_is_counted_and_never_stored, tests/test_scout_audit_migration.py::test_sr21_an_audit_observation_is_admitted_with_its_target_and_nothing_lessVerified
T6.36R, EAn automation's consequential action outside ExecBound is never reported because it is taken as one of ExecBound's own executions: it acts on the same target near an ExecBound execution, or the directory logs one change twice and an execution can no longer account for it, or a caller that reuses its correlation id has a later action folded into an earlier one that an execution accounted for, or ExecBound's own executions found in the first read's look-back make their executor part of the baseline so its credential used outside ExecBound is never reported, or a refused attempt or a back-filled habit is reported as news; or ExecBound's own execution is reported because a revoked connection and the one that connects its directory again hold copies of its entryfirst-consequential-action.v1 leaves out an audit-log observation only when exactly one execution in the same workspace matches it, settled EXECUTED through the Graph provider for the same action on the same target object id with the observation within ten minutes of its dispatch and settlement, and that execution matches no other observation; two executions for one observation, or two observations for one execution, leave out none, so an automation acting beside an execution is reported, and the one left out still derives observed reach. An execution's observations are counted within the connection that holds them, so copies of one entry in a revoked connection and its successor are one change, and an observation the match leaves out makes no actor part of the baseline, whatever its age. One actor's action on one target with one outcome under one correlation id, logged within ten seconds, is one observation, so a change logged twice is still one, a refused attempt never stands in for the success retried under its correlation id, nor one actor's entry for another's, nor an entry Graph serves again under its id for what it asserts now, and the same action under a reused correlation id logged later is an observation of its own that raises the rule and keeps observed reach current. Only a successful entry qualifies or derives reach, and what a connection's first audit read that catches up finds, with the backlog reads before it, is its activity baseline; without a sensitive-action list the actors counted as insufficient evidence are read under the same conditions. Home's board reads the match's one definition, so its band of what passed under the checkpoint draws neither the observation an execution accounts for nor a refused attempt (monitoring/store.py, surfaces/board.py, migration 0094). Residual: the database cannot recognise ExecBound's own service principal, so the match rests on action, target and time alone.SR23; tests/test_scout_audit.py::test_sr23_the_one_entry_an_execution_execbound_settled_is_not_reported, tests/test_scout_audit.py::test_sr23_an_entry_that_does_not_match_one_settled_execution_is_reported, tests/test_scout_audit.py::test_sr23_an_entry_naming_its_target_in_upper_case_still_matches_its_execution, tests/test_scout_audit.py::test_sr23_executions_execbound_settled_before_the_activity_baseline_are_not_its_habit, tests/test_scout_audit.py::test_sr23_a_directory_connected_again_holds_copies_of_one_entry_not_two_actions, tests/test_scout_audit.py::test_sr21_one_change_logged_twice_under_one_correlation_is_one_observation, tests/test_scout_audit.py::test_sr21_entries_under_one_correlation_are_one_change_only_when_logged_together, tests/test_scout_audit.py::test_sr23_an_action_under_a_correlation_id_an_execution_accounted_for_is_still_reported, tests/test_scout_audit.py::test_sr21_a_refused_attempt_and_its_retry_under_one_correlation_are_kept_apart, tests/test_scout_audit.py::test_sr21_an_entry_served_again_asserting_something_else_is_an_observation_of_its_own, tests/test_scout_audit.py::test_sr23_a_failed_attempt_derives_nothing_and_raises_nothing, tests/test_scout_audit.py::test_sr23_what_the_first_complete_read_finds_is_the_activity_baseline, tests/test_scout_audit.py::test_sr23_until_a_read_catches_up_no_audit_observation_raises_it, tests/test_scout_audit.py::test_sr23_without_a_sensitive_action_list_the_actors_the_rule_would_read_are_counted, tests/test_scout_audit.py::test_sr23_home_board_draws_only_what_passed_under_the_checkpointVerified
T6.37T, DThe audit read is redirected by a next link Graph returns, or it stalls, skips or silences: a page out of order moves the cursor past entries it never read, a busy log is never caught up with so the activity baseline is never set, an entry Graph publishes only after the read that sets the activity baseline is taken as the habit so its actor is never reported, a bulk change inside one second holds the read in place, a read falls behind with nothing to say so, a refused read dates an episode no read ends, a refused read fails the inventory or moves the schedule, an entry or a next link the collector cannot read stops the run and the scheduled pass over every workspace, or an entry stamped near the calendar's end stops the evaluation that follows, or the observed reach the observations derive stops at one page, or a back-filled log, or a gap that lasts, floods the alert destinations or holds back what a source reportsA next link is followed only on the pinned Graph origin and the audit path, compared without case, and the request is always that origin and path with the link's query; a link that is not, or whose port or host cannot be read, stops the read after the page it came with. Whatever a field the parser reads holds, the entry is classified, unmapped or dropped, and counted, never raised, and every identifier is read whole, as the database's checks read it, so none the store would refuse, such as one ending in a line break, is mapped. An entry stamped more than a day after the read's own moment is counted as dropped and kept only by the first later read it is no more than a day ahead of, which every later read meets because the cursor never passes the read's moment less the overlap; evidence is current for thirty days from its moment and never from later than the pass deriving reach from it, so no moment the calendar holds runs that pass off its end, and observed and verified reach are derived for every actor and action, a page at a time. A page out of time order is refused whole. A read takes at most four pages of 100 and the cursor never passes an entry it did not read, never moves back, which the store itself refuses, and after a refused read stops no later than the read's moment less the overlap, so a backlog is read on by the next run and an entry published late is still read. A read has caught up when it reaches the log's end or reads past its moment less the thirty-minute overlap, where the next read starts anyway, so a log busy past every read's end still sets the activity baseline. That baseline is what that read and the backlog reads before it stored, dated by when ExecBound received it and never earlier than anything the connection holds, so an entry a later read stores is news after the directory's baseline however early Graph stamped it, and a receipt after the baseline is later than it whatever the host's clock says. A read that never left the second it started in leaves the cursor at its last entry to the microsecond, so the next reads on. The connection dates the episode its reads have not caught up, refused ones included, and a run that skips the read its probe found refused ends it; a day of it, or a read starting from a cursor further back than Graph's longest retention, raises collection-health.v1 once an episode, however many reads the episode lasts. A refusal changes no run's outcome, health state, last refusal or schedule and is counted audit_failed, a backlog audit_backlog; an audit observation raises no finding of its own and no clock skew for an entry behind its receipt, and is evaluated on a budget of its own, so a back-fill never holds a reported action back (monitoring/graph.py, monitoring/store.py, monitoring/due.py, migration 0094). Residual: whether Graph honours a moment to the microsecond in the filter is unverified, and a read inside one second where Graph does not stays behind, which the health finding then reports.SR22, SR23; tests/test_scout_audit.py::test_sr22_a_next_link_off_the_pinned_read_is_never_followed, tests/test_scout_audit.py::test_sr21_an_entry_the_parser_cannot_read_is_classified_and_never_raises, tests/test_scout_audit.py::test_sr21_a_read_meeting_entries_it_cannot_read_counts_them_and_the_run_completes, tests/test_scout_audit.py::test_sr21_an_identifier_the_database_would_refuse_is_never_mapped_and_the_run_completes, tests/test_scout_audit.py::test_sr22_an_entry_stamped_over_a_day_ahead_of_the_read_waits_for_one_it_is_not, tests/test_scout_audit.py::test_sr23_an_entry_stamped_near_the_calendar_end_never_stops_the_scheduled_pass, tests/test_scout_audit.py::test_sr23_an_observation_stamped_near_the_calendar_end_never_stops_the_evaluation, tests/test_scout_audit.py::test_sr23_observed_reach_is_derived_for_every_actor_and_action_however_many_sort_before, tests/test_scout_derived.py::test_every_verified_and_observed_reach_is_derived_however_many_sort_before_it, tests/test_scout_audit.py::test_sr22_entries_out_of_order_are_refused, tests/test_scout_audit.py::test_sr22_a_read_is_bounded_and_the_next_run_continues_where_it_stopped, tests/test_scout_audit.py::test_sr22_a_log_busy_past_every_read_still_catches_up_and_sets_the_baseline, tests/test_scout_audit.py::test_sr23_an_entry_published_after_the_baseline_read_is_news, tests/test_scout_audit.py::test_sr23_without_a_sensitive_action_list_an_entry_published_late_is_counted, tests/test_scout_audit.py::test_sr23_an_entry_stamped_before_the_directory_baseline_is_the_habit_however_late_read, tests/test_scout_audit.py::test_sr23_an_entry_read_after_the_baseline_is_news_when_the_host_clock_is_set_back, tests/test_scout_audit.py::test_sr23_what_reads_before_a_clock_set_back_found_stays_the_baseline, tests/test_scout_audit.py::test_sr22_a_read_moves_on_past_more_entries_than_it_reads_in_one_second, tests/test_scout_audit.py::test_sr22_a_read_behind_for_a_day_raises_one_health_finding_per_episode, tests/test_scout_audit.py::test_sr22_a_read_refused_for_a_day_raises_the_same_health_finding, tests/test_scout_audit.py::test_sr22_a_read_skipped_as_refused_ends_the_episode_a_refused_read_began, tests/test_scout_audit.py::test_sr22_a_cursor_retention_has_passed_raises_one_retention_gap_finding, tests/test_scout_audit.py::test_sr22_a_refused_read_keeps_the_cursor_and_the_run_as_it_was, tests/test_scout_audit.py::test_sr22_a_read_refused_part_way_leaves_the_cursor_behind_the_overlap, tests/test_scout_audit.py::test_sr22_the_cursor_never_moves_back_when_the_clock_does, tests/test_scout_audit.py::test_sr22_the_store_never_moves_the_cursor_back, tests/test_scout_audit.py::test_sr23_a_back_filled_audit_log_never_holds_back_what_a_source_reports, tests/test_scout_audit.py::test_sr22_collect_due_counts_a_failed_and_a_backlogged_audit_read, tests/test_monitor_collect_due.py::test_mc7_one_pass_collects_each_workspace_on_its_default_intervals, tests/test_scout_audit.py::test_sr23_an_audit_observation_raises_no_finding_of_its_ownVerified
T6.38T, R, DOne actor's flood fills a directory connection's observation bound so another actor's first consequential action is never stored, or the executor's own executions stand in for its first action outside ExecBound, or an action timed beside one of ExecBound's executions, read with the execution's own entry or by a later read, is dropped or left the only observation that execution accounts for and so taken for ExecBound's own, or the bound drops every repeat, so a principal's observed reach lapses while it keeps acting and the rules that read reach close and open again each month, or drops every entry by an actor the inventory does not hold yet, so a principal created since the last full collection loses its first consequential action for good, or keeps such entries past the inventory's limit so the bound no longer bounds; or the bound's own health finding, raised again each time a purge brings a busy directory just under the bound, floods the alert destinations; or the purge that keeps the bound to a month of evidence hides evidence: it removes an observation a finding cites, a current or unevaluated one or another workspace's, a baseline actor's last baseline observation so its habit is reported as news, or an old observation whose removal turns a current one, one a later read stores, or one an execution settling later accounts for into ExecBound's own; or the purge never reaches a connection that sorts after a workspace's others, so it fills the bound, or waits on a collection and fails the pass before its findings are delivered, or a pass that fails leaves what its earlier batches removed recorded nowhere; or the runtime gains a general way to delete evidenceAt the bound a read moves on and keeps of each new entry what the rules would miss without it. An entry an execution could account for, settled or not yet settled (whose window is read as not yet ended), is kept, whoever acted, while that execution accounts for fewer than two observations, so its own entry is kept and whatever comes beside it, in the same read or a later one, leaves out neither; an execution keeps no more than two this way. Otherwise a live principal's first current observation of each action that the settled match leaves in is kept, so the executor's own executions never stand in for its action outside ExecBound; an entry an execution keeps, and an observation the connection already holds, whichever read stored it and whether under the bound or at it, count as that first only when the match, which counts settled executions alone, can never leave them out, however what is not yet settled settles. A principal's repeat is kept when it is more than fifteen days after the principal's newest current observation of that action, ExecBound's own included, so observed reach, which lasts thirty days from the newest, stays current for a principal acting at least that often, and at most two are current at once. An actor the inventory does not hold yet keeps only its first of each action, and only while the live principals and the actors not yet inventoried with a current entry stored in the episode are at most the inventory's limit of 1,000; past it the entry is dropped and counted. A failed or unknown entry and one older than thirty days are dropped. The bound's episode (audit_bound_since) raises collection-health.v1 once, at once, and ends only with a read that leaves the connection a tenth of the bound under it, so a directory the purge keeps just under the bound is one episode and one finding. The runtime holds no DELETE; its one removal is monitor_audit_purge, a definer function on a fixed search path, fenced to the caller's active workspace and a Microsoft Entra connection of it, that deletes at most 1,000 listed observations a call and only evaluated entra_audit observations a month old by the database's own clock, 720 hours whatever the session's time zone, and cited by no finding. The process pass that calls it keeps each principal's earliest baseline observation of each action that no unsettled execution could account for, exactly as the rule reads one; every observation an execution could account for beside one that stays, the next kind included; and every one an execution not yet settled, or one whose window reaches into the thirty days, could account for, so neither a read after the purge nor a late settlement leaves one observation that execution accounts for. It chooses and deletes under the connection's row lock, which a read's write takes too, so no read stores an observation between the choice and the delete, and passes over a connection a collection holds rather than wait on it; every connection gets a batch each pass, however many the workspace holds, and an operator's process, like the scheduled pass with --deliver, purges only after it has delivered. Each batch commits on its own, and the pass logs one monitor.audit_purged line with what it removed whenever it removed anything, a pass a later batch fails included (monitoring/store.py, monitoring/worker.py, migration 0095). Residual: the function bounds kind, state, age and citation, and the keepers and the execution guards are the store's choice, which the tests pin; an execution that stays unsettled keeps two entries at the bound, and what it could account for from the purge, for as long as it does.SR24; tests/test_scout_audit_retention.py::test_sr24_one_actors_flood_at_the_bound_never_hides_anothers_first_action, tests/test_scout_audit_retention.py::test_sr24_the_executors_action_outside_execbound_is_kept_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_an_action_timed_beside_an_execution_is_never_hidden_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_an_executions_own_entry_is_kept_so_a_later_read_cannot_hide_one_beside_it, tests/test_scout_audit_retention.py::test_sr24_an_unsettled_executions_entries_are_kept_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_an_entry_one_settled_execution_may_leave_out_is_no_first_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_a_held_entry_an_unsettled_execution_may_leave_out_is_no_first_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_a_held_entry_the_match_never_leaves_out_makes_the_next_a_repeat_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_an_execution_keeps_at_most_two_entries_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_at_the_bound_failures_and_repeats_are_dropped, tests/test_scout_audit_retention.py::test_sr24_a_principals_repeat_keeps_its_reach_current_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_of_a_daily_habit_the_bound_keeps_two_current_observations, tests/test_scout_audit_retention.py::test_sr24_an_actor_not_yet_inventoried_keeps_its_first_actions_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_actors_not_yet_inventoried_are_kept_only_within_the_inventory_limit, tests/test_scout_audit_retention.py::test_sr24_the_bound_moves_the_cursor_and_raises_one_finding_an_episode, tests/test_scout_audit_retention.py::test_sr24_a_connection_hovering_at_the_bound_is_one_episode_and_one_finding, tests/test_scout_audit_retention.py::test_sr24_collect_due_counts_a_read_at_the_bound, tests/test_scout_audit_retention.py::test_sr24_the_purge_removes_old_uncited_audit_observations_and_nothing_else, tests/test_scout_audit_retention.py::test_sr24_a_baseline_actor_stays_the_habit_once_its_observations_are_a_month_old, tests/test_scout_audit_retention.py::test_sr24_an_old_observation_sharing_an_execution_with_a_current_one_is_kept, tests/test_scout_audit_retention.py::test_sr24_an_old_observation_sharing_an_old_execution_with_a_keeper_is_kept, tests/test_scout_audit_retention.py::test_sr24_an_old_observation_sharing_an_old_execution_with_one_an_open_one_keeps_is_kept, tests/test_scout_audit_retention.py::test_sr24_an_old_observation_an_unsettled_execution_could_account_for_is_kept, tests/test_scout_audit_retention.py::test_sr24_the_baseline_kept_is_one_no_unsettled_execution_could_account_for, tests/test_scout_audit_retention.py::test_sr24_a_read_waits_for_a_purge_batch_between_its_choice_and_its_delete, tests/test_scout_audit_retention.py::test_sr24_a_pass_that_fails_after_a_batch_still_logs_what_it_removed, tests/test_scout_audit_retention.py::test_sr24_a_full_batch_is_one_call_the_function_accepts, tests/test_scout_audit_retention.py::test_sr24_every_connection_is_purged_each_pass_however_many_the_workspace_holds, tests/test_scout_audit_retention.py::test_sr24_the_purge_passes_over_a_connection_a_collection_holds, tests/test_scout_audit_retention.py::test_sr24_process_delivers_before_it_purges, tests/test_findings_delivery_cli.py::test_fd13_all_tenants_deliver_sends_before_a_purge_that_fails, tests/test_scout_audit_retention.py::test_sr24_the_function_deletes_only_what_its_own_conditions_admit, tests/test_scout_audit_retention.py::test_sr24_the_thirty_days_are_hours_whatever_the_session_time_zone, tests/test_scout_audit_retention.py::test_sr24_the_runtime_deletes_an_observation_only_through_the_function, tests/test_scout_audit_retention_migration.py::test_sr24_the_function_runs_as_its_owner_on_a_fixed_path_for_the_runtime_alone, tests/test_scout_audit_retention_migration.py::test_sr24_the_function_admits_and_refuses_alike_under_an_owner_without_bypassVerified
T6.39I, SReading a directory's sign-ins to say who drives an actor collects where its people and automations connect from, or names a platform on evidence a client chooses: a sign-in's address, name, principal name or full user agent is kept, a user agent that names a platform counts as evidence, an address is taken as a platform's when the platform publishes no stable egress or a range has changed, a feed fetched at run time redirects the catalog, a next link sends the read elsewhere, or a busy directory's sign-in read throttles the audit readThe sign-in parser takes an entry's moment, service principal id, status, address and user agent and nothing else; the address is matched in memory against the reviewed egress catalog and the Space's recorded Tines egress and discarded, and only the match (egress:<key> or tines:<connection>) is kept with its first and last moment; only a successful sign-in counts. The user agent is reduced to its leading product token, dropped when shaped like a secret, an address or a mailbox, and kept as a hint that never makes a row. The catalog is reviewed code naming each entry's source and day, every network checked when it is built and no address claimed by two platforms; Zapier and n8n Cloud hold no networks, the feed-published platforms match nothing until a reviewed refresh fills them from downloaded files and names a new catalog version, the catalog, its feeds, the refresh and the driver evidence import no network client, and no module that sends a request writes out a page or range list the catalog cites, names the catalog's page or feed constants or its entries, or reads a source other than its own arguments': the collector's requests go only to the pinned Graph and Microsoft sign-in origins and the connection's own Tines tenant. Next links are held to the pinned origin and the sign-ins path. The audit log and sign-ins share one pacer of five reports requests in ten seconds, waiting at most twelve seconds a run and refusing past it, and losing the beta capability never changes a run's outcome, health or completeness; collect-due counts a refused or sampled read, so one that fails every run is seen (monitoring/graph.py, monitoring/egress.py, SCOUT.md)SR26; tests/test_scout_drivers.py::test_sr26_a_sign_in_from_each_published_range_names_its_platform_and_nothing_else_does, tests/test_scout_drivers.py::test_sr26_a_user_agent_alone_never_yields_evidence_and_is_kept_only_as_its_token, tests/test_scout_drivers.py::test_sr26_a_platform_with_no_published_stable_egress_stays_unattributed, tests/test_scout_drivers.py::test_sr26_a_next_link_off_the_pinned_read_is_refused, tests/test_scout_drivers.py::test_sr26_the_reports_pacer_waits_for_the_window_and_refuses_past_its_bound, tests/test_scout_drivers.py::test_sr26_the_audit_read_and_sign_ins_share_one_reports_budget, tests/test_scout_drivers.py::test_sr26_losing_the_beta_capability_is_classified_and_the_run_continues, tests/test_scout_drivers.py::test_sr26_collect_due_counts_a_refused_or_bounded_sign_in_read, tests/test_scout_egress_catalog.py::test_sr26_a_catalog_that_cannot_explain_itself_is_refused, tests/test_scout_egress_catalog.py::test_sr26_a_refresh_that_changes_the_feeds_names_a_new_catalog_version, tests/test_scout_egress_catalog.py::test_sr26_a_feed_that_overlaps_another_platform_refuses_the_refresh, tests/test_scout_egress_catalog.py::test_sr26_nothing_in_the_catalog_or_the_collector_fetches_a_range_listVerified
T6.40S, RA driver claim says more than its evidence: a join names one story as the one that acted, or calls a credential allowed to call Microsoft Graph one for this directory, joins a credential with no host restriction, keeps a host, credential metadata or an owner, reads Tines' capped reference list as complete, outlives a revoked Tines connection, a changed credential or a changed egress record, carries a replaced address's dates into what a new one established, crosses a Space when it is made or when it is read, names another team's stories for a connection scoped to a team, or a stale sign-in read restores matches against an address no longer recordedThe Tines parser keeps of a credential only its host scope (microsoft for exactly graph.microsoft.com or login.microsoftonline.com, a wildcard never counting) and its referencing story ids, at most 100, marked "at least" when Tines' cap of 100 actions is reached, and for a connection scoped to a team only the stories of references that name that team. A joined row, within one Space and for an active Tines connection, names every story that uses one of its microsoft credentials and never one alone, is worded as stories that use a credential allowed to call Microsoft Graph or Microsoft sign-in, and never takes an unrestricted credential, which is counted beside it. Recording egress marks the connection's network and joined rows gone and re-reads thirty days, a read taking its cursor beside the recorded addresses so one already under way re-reads too; a read whose recorded egress changed while it ran drops its Tines matches and leaves its cursor; a row brought back is dated from the evidence that brought it back. Revoking a Tines connection ends its network and joined rows in the same transaction, and each Tines inventory collection brings its joins in line with its credentials, whether or not the directory's sign-ins are still read. An actor's detail is the tenant's, but it reads a tines: row, with the connection's label, its stories and its count of unrestricted credentials, only when the session's visible Spaces hold that Tines connection, under the Connections view's own predicate, and so do the Agents page's Driver column, its Tines Gate pointer and its tenant line's unattributed figure, which therefore counts every row the column calls unattributed. The database holds a row's platform to its level and its story ids to identifiers on a join alone (monitoring/drivers.py, monitoring/collection.py, SCOUT.md)SR27; tests/test_scout_drivers.py::test_sr27_a_sign_in_from_the_recorded_egress_joins_every_story_using_a_microsoft_credential, tests/test_scout_drivers.py::test_sr27_the_tines_inventory_keeps_a_credential_s_scope_and_stories_and_nothing_else, tests/test_scout_drivers.py::test_sr27_a_credential_s_allowed_hosts_are_classified_and_dropped, tests/test_scout_drivers.py::test_sr27_tines_cap_on_references_is_stated_as_at_least, tests/test_scout_drivers.py::test_sr27_a_join_names_only_the_stories_of_the_connection_s_own_team, tests/test_scout_drivers.py::test_sr27_changing_the_egress_ends_its_rows_and_rereads_thirty_days_once, tests/test_scout_drivers.py::test_sr27_egress_recorded_while_a_read_runs_drops_that_read_s_tines_matches, tests/test_scout_drivers.py::test_sr27_egress_recorded_after_a_run_began_still_rereads_thirty_days, tests/test_scout_drivers.py::test_sr27_evidence_from_a_corrected_egress_is_dated_from_its_own_sign_ins, tests/test_scout_drivers.py::test_sr27_a_revoked_tines_connection_ends_the_join_and_the_match, tests/test_scout_drivers.py::test_sr27_a_join_follows_the_tines_tenant_while_the_directory_s_sign_ins_go_unread, tests/test_scout_drivers.py::test_sr27_a_join_never_crosses_a_space, tests/test_scout_drivers.py::test_sr27_the_agent_detail_names_a_tines_tenant_only_to_a_session_that_sees_its_space, tests/test_scout_agents_page.py::test_sr29_a_tines_row_attributes_and_points_only_where_its_space_is_seen, tests/test_scout_drivers.py::test_sr27_driver_rows_hold_their_shape; the Vitest case in console/src/components/monitor/Scout.test.tsx that words a joined driver as the Tines tenant's stories, which holds the Driver column to the wording aboveVerified
T6.41T, ESomeone frames an actor as Tines-driven, or hides one, by what is recorded as a Tines tenant's egress: a private, shared or reserved address, a whole network, another platform's catalogued range, more addresses than a tenant has, a directory connection's record, an administrator of another Space, a stolen personal access token or a session without a recent factor, or a change nobody can see afterwardsadmin_set_egress_addresses is the only writer, for an active administrator of the connection's Space and a Tines connection alone, refusing more than eight addresses, a repeat, a network and any address in a private, shared, loopback, link-local, documentation, benchmarking, multicast or reserved block, or outside IPv6 global unicast; the store refuses the same blocks from the same list (drivers.NOT_EGRESS, which the function's definition is checked against), an address with a zone, and any address the egress catalog gives another platform before calling it; the runtime may write the columns no other way. The console route asks for a recent factor and refuses a personal access token; each change appends monitor.egress_recorded with the count, and the connection names who recorded the addresses and when, read as recorded by an administrator rather than as Tines' own statement (monitoring/drivers.py, migration 0098_scout_drivers)SR27; tests/test_scout_drivers.py::test_sr27_what_cannot_be_recorded_as_egress, tests/test_scout_drivers.py::test_sr27_the_database_holds_the_addresses_to_its_own_copy_of_the_rule, tests/test_scout_drivers.py::test_sr27_the_store_and_the_database_refuse_every_block_alike, tests/test_scout_drivers.py::test_sr27_the_database_s_copy_of_the_egress_rule_is_the_store_s, tests/test_scout_drivers.py::test_sr27_an_entra_connection_has_no_egress_and_a_narrowed_administrator_records_none, tests/test_scout_drivers.py::test_sr27_the_egress_function_checks_the_actor_whatever_its_caller_checked, tests/test_scout_drivers.py::test_sr27_an_administrator_records_a_tines_tenant_s_egress_and_it_is_audited, tests/test_scout_drivers_migration.py::test_sr27_the_egress_function_admits_only_the_caller_s_own_tines_connection_without_bypass, tests/test_console_api_monitoring_connect.py::test_sr27_the_egress_route_records_a_tines_tenant_s_addresses_and_answers_the_connection, tests/test_console_api_monitoring_connect.py::test_mc13_connecting_replacing_a_key_and_revoking_ask_for_a_recent_factor, tests/test_console_api_monitoring_connect.py::test_mc13_a_token_may_ask_and_schedule_but_never_connect_replace_or_revokeVerified
T6.42DA burst of findings floods a destination or spends the relay: a misreporting source or a newly connected directory sends a channel hundreds of messages, a receiver's 429s are ignored, turning delivery on sends weeks of backlog, or findings email spends the day's cap so a held approval's announcement cannot be sentThe store already withholds what another finding speaks for (T6.34). A pass routes at most 50 intents and sends at most 50 deliveries per workspace, inside the scheduled pass's budget, after first closing up to 5,000 intents more than a day old, oldest first; a chat destination receives at most one message a second within a pass; a delivery makes at most five attempts, 5, 25, 125 and 625 seconds apart, a 429 waiting its Retry-After, and a fifth lease that expired unsettled ends DELIVERY_UNCERTAIN with no sixth send; an intent more than a day old when first routed is closed TOO_OLD_TO_SEND, so turning delivery on sends no backlog; rules and spaces narrow a destination. Findings email counts toward mail.daily_limit with the workspace's approval email, in both outboxes, and never spends the last tenth kept for approval announcements: over its share it waits an hour, spending no attempt, and fails RATE_LIMITED once its delivery is a day old. The count is read under one advisory lock per workspace and includes email held under a live lease as well as email delivered, and each drain writes its lease before the lock is released, so two drains never both take the last of the cap or of the findings' share (monitoring/worker.py, notifications.py). Residuals: the cap is one workspace's approval and findings email, counted over the email destinations the configuration names now. Other mail sent through the same relay keeps its own count (teammate invitations, the weekly summary, and on the hosted service its own mail), so across them the relay's own limit is the bound; where the approval drain and the monitoring pass read separate copies of the runtime configuration, the cap is shared only while both copies name the same email destinations. A destination removed or renamed stops counting, with the day's email it sent. A send whose outcome is uncertain is never counted: a delivery closed DELIVERY_UNCERTAIN, or a lease that lapsed, may have reached the relay. A crashed drain's lease counts for at most 60 seconds for a finding and 30 for an approval, which errs toward keeping the reserve. A slow SMTP exchange of a few 10-second steps can outlast the approval drain's 30-second lease, and even the findings drain's 60-second one, so each such send can overrun the cap by one and a drain may resend it; the Message-ID, built from the row's id, collapses the copy in the mailbox. The wait for the lock is bounded by the transaction's five-second lock_timeout: a drain that waits longer stops and leases nothing, and the row stays as it was, which may be IN_FLIGHT with a lapsed lease, for the next pass. Findings share the nine tenths with approval follow-ups, which are refused RATE_LIMITED at once rather than wait, so one day's findings email can fail every reminder and close-out (T6.23). A delivery closed unsent after an in-flight lease expired may have reached its receiver: attempts above 0 on a closed row means a send may have landed. The stale-backlog close takes at most 5,000 intents a pass, so a larger backlog closes over several passes and holds a new finding back until it has. A webhook or chat destination has no daily cap of its own: it can receive up to 50 messages a pass from one workspace, and a daily digest is later work.FD3, FD4, FD9, FD11; tests/test_findings_delivery.py::test_fd3_a_429_waits_its_retry_after_and_counts_as_an_attempt, ::test_fd3_a_chat_destination_gets_at_most_one_message_a_second_within_a_drain, ::test_fd4_a_fifth_expired_lease_is_uncertain_with_no_sixth_send, ::test_fd9_an_intent_more_than_a_day_old_when_first_routed_is_closed_unsent, ::test_the_router_routes_no_more_intents_than_its_limit, ::test_the_router_closes_a_stale_backlog_before_it_takes_its_batch, ::test_the_router_closes_at_most_its_stale_batch_a_pass_oldest_first, ::test_fd11_a_findings_email_never_spends_the_announcement_reserve, ::test_fd11_findings_email_counts_toward_the_approval_cap, ::test_fd11_a_deferral_spends_no_attempt_however_often_it_waits, ::test_fd11_a_capped_findings_email_fails_once_its_delivery_is_a_day_old, ::test_fd11_two_findings_drains_at_once_never_spend_the_reserve, ::test_fd11_a_findings_drain_and_an_approval_follow_up_at_once_never_spend_the_reserve, ::test_fd11_two_announcements_at_once_never_pass_the_cap, ::test_fd11_a_lapsed_lease_never_counts_against_its_own_retry, ::test_the_drain_logs_a_delivery_it_closes_uncertain_or_unsentPartial
T6.43T, DAn alert loop: the story that receives findings is itself an instrumented source, so a delivered finding comes back as an observation, raises another finding and is sent againA message is never an observation: nothing the drain sends reaches intake, and only a story's own report to /v1/monitor/events with its source token does. A finding message names no action, target or outcome, so a story that forwards it as a report has nothing but its own action to report; the per-source intake limit (T6.3), the per-pass bounds (T6.42) and the one-intent-per-finding key bound a loop an operator builds anyway, and the guide says not to send findings to a story that reports to the same workspace. Nothing detects a loop an operator configured.FD5; tests/test_findings_delivery.py::test_fd5_no_string_a_source_reported_reaches_any_body, tests/test_monitoring_http.py; TINES_MONITORING.mdPartial
T6.44S, R, IThe synthetic directory is taken for a real one or leaks into real work: its actors appear beside a real directory's, connecting a real directory is silently mixed with it, its findings reach a channel, a ticket, an inbox or the console assistant's model as if real or are counted as the workspace's own, one of its actors is governed or bound and so billed with a real key, Home counts it as watching, an administrator of one Space adds it and so blocks every other Space from connecting a real directory and learns whether one ever did, or its collection reaches a network, a token endpoint or a credentialOne connection of kind fixture and credential mode synthetic, held to each other and to one fixed shape by the database, with no key; admin_add_synthetic_directory is its one writer, for a workspace administrator alone (one no membership narrows to their Spaces, refused by the store first and by the function whoever calls it), and create-connection refuses the kind. monitor_connections_directory_coexist refuses it beside any Microsoft Entra connection, active or revoked, and a Microsoft Entra connection beside it, whichever path inserts, under the registry lock, and the store and the console's consent start refuse first. Monitoring answers whether it is present for the whole workspace and whether one can be added only to a reader of every Space, never from the Space a session looks at, so Connections offers neither it nor Microsoft Entra beside what another Space holds. graph.run reads it through synthetic.client, an httpx.MockTransport over a fixed in-process dataset, and never gives a real connection that client or it any other; it takes no delta, matches its sign-ins against the egress catalog alone and joins no Tines tenant. Routing closes its findings' intents SYNTHETIC_DIRECTORY and monitor_deliveries_not_synthetic refuses a destination's delivery; agent_bindings_not_synthetic refuses to bind its actor (SYNTHETIC_ACTOR), and a governed agent's bind picker never offers one; Home leaves it out, and Home, the workspace settings, the Spaces page and the weekly summary count neither its findings nor a late collection of it; every answer that names it carries synthetic, the console assistant's findings tool among them, and every page says "Synthetic directory", an actor's page on Agent connections among them (monitoring/synthetic.py, migration 0099_scout_agents_page)SR31; tests/test_scout_agents_page.py::test_sr31_the_synthetic_directory_is_created_only_by_its_function, tests/test_scout_agents_page.py::test_sr31_a_synthetic_directory_never_shares_a_workspace_with_a_real_one, tests/test_scout_agents_page.py::test_sr31_it_is_collected_in_process_and_shows_a_new_actor_after_its_baseline, tests/test_scout_agents_page.py::test_sr31_the_scheduled_collector_reads_it_without_a_key_file, tests/test_scout_agents_page.py::test_sr31_its_findings_are_never_sent_and_a_delivery_of_one_is_refused, tests/test_scout_agents_page.py::test_sr31_its_actors_are_never_governed_or_bound, tests/test_scout_agents_page.py::test_sr31_home_neither_counts_it_as_watching_nor_draws_its_observations, tests/test_scout_agents_page.py::test_sr31_nothing_but_the_dataset_s_names_and_hints_is_kept_or_shown, tests/test_console_api_scout_agents_page.py::test_sr31_the_synthetic_directory_is_added_labelled_and_never_governed, tests/test_console_api_scout_agents_page.py::test_sr31_a_directory_is_not_connected_beside_it_and_it_is_not_added_beside_one, tests/test_scout_agents_page_migration.py::test_sr31_the_functions_admit_only_the_caller_s_own_workspace_without_bypass, tests/test_scout_agents_page.py::test_sr31_only_a_workspace_administrator_adds_it_since_it_holds_for_every_space, tests/test_console_api_scout_agents_page.py::test_sr31_its_findings_and_its_collection_count_nowhere_outside_monitoring, tests/test_console_api_scout_agents_page.py::test_sr31_the_assistant_reads_its_findings_labelled, tests/test_console_api_scout_agents_page.py::test_sr31_adding_it_is_refused_beside_a_directory_and_to_a_narrowed_administrator, tests/test_console_api_scout_agents_page.py::test_sr31_monitoring_answers_for_the_workspace_whichever_space_it_shows, tests/test_browser_console_scout.py::test_sr31_the_synthetic_directory_is_labelled_on_every_page_it_touchesVerified
T6.45T, R, EAn acceptance that is not what it says: a renewal after expiry that silently changes nothing, an acceptance written or rewritten outside the record of who decided, a withdrawal nobody can see, an acceptance of a path the actor does not hold, of one not open (lapsed, to an action nobody calls consequential, or accepted already) or of another workspace's actor, an acceptance written by an approver, an agent, the scheduler, an inactive administrator or one narrowed to another Space, or of an actor gone from its directory, a free-text reason that carries a person's words or a secret, or a stolen personal access token or a session without a recent factor deciding what reads as needing attentionadmin_accept_reach and admin_withdraw_reach are the only writers of monitor_reach_exceptions, for an active human administrator of the actor's connection's Space, which each checks itself whoever called it, accept-reach included, and accepting refuses an actor that is gone, and the runtime holds neither INSERT nor UPDATE on it; a renewal sets the reason, the expiry, who and when, and clears a withdrawal. Every change is kept in monitor_reach_acceptances, append-only (runtime SELECT alone), and appends monitor.reach_accepted or monitor.reach_withdrawn. The console accepts only a path open now (live, to an action the workspace calls consequential, with no acceptance in force), for one of five reason codes and one of four periods, and takes no free text; its Withdraw takes every kind an acceptance is recorded for; both routes ask for a recent factor and refuse a personal access token, and each Agents row and actor's page says whether the reader may accept or withdraw there (administered, the store's and the functions' own rules), so the console offers neither where the store would refuse it. An acceptance leaves the reach row as it is, and an expiry or a withdrawal reopens the episode at the next collection (monitoring/store.py, migration 0099_scout_agents_page)SR30; tests/test_scout_agents_page.py::test_sr30_a_renewal_after_expiry_renews_and_each_change_is_kept_and_audited, tests/test_scout_agents_page.py::test_sr30_a_withdrawal_reopens_the_path_and_the_episode_and_a_second_is_refused, tests/test_scout_agents_page.py::test_sr30_the_console_accepts_open_paths_only_for_a_listed_reason_and_period, tests/test_scout_agents_page.py::test_sr30_the_runtime_writes_no_acceptance_but_through_its_functions, tests/test_scout_agents_page_migration.py::test_sr30_the_runtime_reads_the_history_and_writes_an_acceptance_only_through_its_functions, tests/test_console_api_scout_agents_page.py::test_sr30_accept_and_withdraw_answer_the_actor_as_the_server_holds_it, tests/test_console_api_scout_agents_page.py::test_sr30_accepting_and_withdrawing_ask_for_a_recent_factor_and_refuse_a_token, tests/test_scout_agents_page.py::test_sr30_the_acceptance_functions_check_the_actor_whatever_their_caller_checked, tests/test_console_api_scout_agents_page.py::test_sr30_an_acceptance_of_any_kind_the_command_line_records_is_withdrawn_from_the_console, tests/test_scout_agents_page.py::test_sr30_accept_and_withdraw_are_offered_only_where_the_session_may_make_themVerified
T6.46S, TA directory, an agent or a provider chooses where the page sends an administrator: a "Remove in Entra" link built from a display name, an application id or anything else a record carries, which points at an attacker's pageThe link is the console's constant address of the admin center's enterprise applications list, with rel="noopener noreferrer"; the application id is shown beside it as text to filter by, kept only as a GUID by the parser and the column's check, and nothing from a record becomes part of an address. The guidance names a managed identity's Azure resource and an agent identity's blueprint in words, and none for the synthetic directory or a Tines story (surfaces/console_api/monitoring.py, components/monitor/Agents.tsx)SR32; tests/test_scout_agents_page.py::test_sr28_an_application_id_that_is_not_a_guid_is_never_kept, tests/test_scout_agents_page.py::test_sr32_the_removal_guidance_is_the_classes_and_only_a_real_directory_s; the Vitest case in console/src/components/monitor/Scout.test.tsx that lists actors in the server's order, which holds the link to the constant address with rel="noopener noreferrer" and the application id beside it as textVerified
T6.47T, SA reclassification hides an actor or changes what is said of it: an agent identity or a Tines story recast as a classic principal or the reverse, a class decided by a tag or a name, or a consented app's class kept after its consent is withdrawnA class moves only within the classic family, which monitor_agents_class_family holds in the database (CLASS_FIXED); managed_identity comes from the type the directory states and consented_app only from what is held, another organization's application with a live application or delegated grant, decided again at the end of every detail write; a record that states no type keeps the stored class; the rules read the class only for their titles (monitoring/graph.py, monitoring/store.py, migration 0099_scout_agents_page)SR28; tests/test_scout_agents_page.py::test_sr28_each_class_is_what_the_directory_states_and_consent_moves_it, tests/test_scout_agents_page.py::test_sr28_an_agent_identity_never_moves_and_a_record_stating_no_type_keeps_the_class, tests/test_scout_agents_page.py::test_sr28_a_new_consented_app_with_ungoverned_reach_is_named_by_its_class, tests/test_scout_schema.py::test_the_runtime_cannot_rewrite_an_identity_or_its_history, tests/test_scout_schema.py::test_a_class_moves_only_within_the_classic_family_in_either_directionVerified

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
T7.6DAn agent, an Arcade control point or the assistant's model writes text shaped like a secret into an audit event, and the event export refuses that tenant's history at the event for good, and every replay export of the tenant with itThe export's secret check stays as it is, and text from outside is screened before the chain carries it. Admission refuses a request whose external_case holds secret-shaped text as INVALID_REQUEST, audited by its reason and quoting nothing; the case is the one text of the agent's own that an event carries, written into the first transition before any decision. A contradiction the Arcade extension appends withholds a reported tool identity of that shape (tool_identity: WITHHELD) rather than refusing the post hook, which would lose the record that a denied call ran. The assistant refuses a proposed rule holding such text as INVALID_PROPOSAL, naming the field. reference and the idempotency key reach no audit payload. The screens sit at those boundaries and not in the request model, because stored plans are read back through it (execution/admission.py, arcade/routes.py, assistant/proposals.py, secrets.py). Text a person, the owner or an identity provider supplies is T7.7's.tests/test_request_secret_screen.py, tests/test_event_export.py::test_se9_a_payload_that_trips_the_secret_check_stops_the_export_at_that_eventPartial, see R34
T7.7DA person, the owner or an identity provider writes text shaped like a secret into an audit event or a policy bundle, and the event export refuses that tenant's history at the event for good, and every replay export of the tenant with itText a person types is refused where it is written with the boundary's existing code, never quoting the value, against the one secret definition (secrets.py: secret_shaped, or find_secrets over a manifest entry or a bundle) over exactly what the event or the bundle will carry: reasons (execution/stops.py, execution/modes.py, provider/gateway.py reconcile_operation, execution/reconciliation.py reconcile, access_control.py, approval_rules.py change_rule, bindings.py unbind, surfaces/people_pages.py deactivate, assistant/turns.py set_tenant_switch, spaces.py, and a policy activation's reason in policy/store.py activate_bundle, refused as INVALID_REASON at reason and reaching the console as 400 INVALID_BODY naming reason); principal labels, through administration.screened_label (people_pages create_approver and label_person, agents_pages.create_agent, admin_pages.issue, administration.invite_person); group, approval rule, space and personal access token names (administration.create_group, access_control._change with the group's description, approval_rules.change_rule, spaces.create_space and rename_space, personal_tokens.issue_token); the owner's signal source labels and producers, context source writers and every context manifest entry (provisioning.py), a new executor key id (administration.register_executor_key, and rotate_executor_key's new id, never a key a revocation or a compromise report names), a resource's provider id, version and hostname alias and a mapping's operation ref and version (administration.create_resource, create_mapping), and the hosted sandbox seed, at each of its four writers rather than in the parser every reader of the mounted slots file goes through (in the hosted plugin's own code); an email address's domain, the only part an event carries (people_pages.set_email, administration.invite_person, passwords.set_email, invitations.create_invitation); an administrator's note on a monitoring acknowledgement (monitoring/store.py acknowledge and acknowledge_many, refused as INVALID_NOTE and reaching the console as 400 INVALID_BODY naming note); and a policy bundle's whole content in policy/store.py _insert_draft, the store's one writer of a draft (the harness's install_bundle, which the demos and the hosted sandbox seed use, writes fixed content without it), as INVALID_TEXT at the text's location, the ruleset first. Where the console takes the text, it answers 400 INVALID_BODY naming the field or the location (surfaces/console_api/); a command prints the code (cli.py, and the hosted plugin's commands), the space commands print INVALID_NAME for a reason, the code spaces._reason always raised, and the hosted service's own seed command prints a refusal sentence of its own. Text the identity provider supplies is withheld, never refused, by secrets.withheld: a subject of that shape in identity.linked, human_sign_in.directory, human_sign_in.google or human_sign_in.github, and an issuer in identity_provider.registered (directory_store.py register, link_self, link_person and sign_in; passwords.bind_identity; user_accounts.provider_sign_in), becomes WITHHELD beside <field>_digest, while identity_links, principal_identities, user_identities and identity_providers keep the real value, so a sign-in finds the same person. The digest is an unkeyed SHA-256 of the exact value: it correlates events across tenants and exports, and a short value can be guessed offline, so it gives correlation, not secrecy. A reader tells a withheld value by its <field>_digest key, never by the literal, which a plain value may spell; Arcade's tool_identity (T7.6) is withheld with no digest. An Entra display name of that shape governs its agent as Unnamed agent (agents_pages.govern), and monitor_agents keeps the name. account_links.finish_link's subject reaches no audit event: user_security_change stores it in user_identities, and account_security.provider_linked carries the session id and the provider, never the subject (mfa._event). The JWT alternative of SECRET_TEXT is case-sensitive (secrets.py), since every JWT begins eyJ, so a lowercase hostname or email domain such as eyjump.corp.example is no longer read as one. Every screen sits at a writer, never on a model stored rows are read back through.tests/test_text_screen_reasons.py, tests/test_policy_store.py::test_an_activation_reason_shaped_like_a_secret_is_refused_unquoted, tests/test_console_api_policy.py::test_activation_requires_a_reason_and_records_it, tests/test_text_screen_names.py, tests/test_text_screen_registry.py, tests/test_text_screen_email.py, tests/test_text_screen_bundles.py, tests/test_text_screen_identity.py, tests/test_monitoring_acknowledgement.py::test_mq13_a_note_shaped_like_a_secret_is_refused_and_never_quoted, tests/test_text_screen_export.py::test_shaped_text_from_people_and_the_identity_provider_leaves_both_exports_open, tests/test_secrets.py::test_a_lowercase_or_uppercase_three_label_name_is_not_a_jwt, ::test_every_real_jwt_still_matches; the hosted service's own testsPartial, see R34
T7.8IA regression fixture leaks a tenant's identifiers, names, free text or secrets, or misstates what replay decidedEvery UUID, and every open-text value the kept structure carries, becomes an order-preserving pseudonym: resource and target ids, resource and mapping versions, every fact's source version but a signal fact's (its source's registration number), the version a provider journal reports, the composites a connector record froze and read, the key id an executor or a connector signed a record under, an operation ref or a context writer an owner named, a fact's object and a signal's event id, request keys and references, the agent's external case (source, case id and link) on an execution's first transition, stop reasons, every transition reason that is not one of the kernel's own codes, approval comments and rule notes. A label is replaced once, from its own text, at the position it was recorded at and, unless its text is a word the fixture keeps anyway (a catalog name or value, a word of the Cedar language such as permit or when, one of the kernel's own transition reasons such as PROVIDER_RESPONSE, a field name, a value at a field the kernel fills from a closed vocabulary such as a state or a rule id, the published journal key's id, a case link pseudonym's fixed text, or a token of one), wherever else it appears as a whole token, Cedar text included; an operation ref or a version with no letter in it, such as 7, is replaced only as a whole value or JSON string. So a ref named after the vendor's own operation, or a one-word comment that reads a word the fixture keeps, even a fixture's own field name or a word of its expectations, neither rewrites the history nor stops the build. Times are kept, so a one-word comment that is no kept word but is a whole token of a time, such as 00 or 30, does stop it (FIXTURE_NOT_FAITHFUL), and so, as a survivor, does a label whose text is the pseudonym another label is given. Informational payloads are reduced to the keys replay reads. An ES256 attestation's base64url header and claims are opened into text before anything is replaced, so an identifier in them is replaced and searched for like any other. The build refuses and writes nothing when the source does not verify against its head, a secret is found in the source, the candidates or the output, any source identifier survives anywhere in the output, any collected label that is not a kept word survives anywhere in the sanitized history or the candidates (journal records included; a letterless operation ref or version found only as a whole value or JSON string, and the expectations, replay's own classification of that history held equal to the source's, not searched for labels), a label position holds anything but a pseudonym, or the sanitized history does not verify and replay to the same classified outcomes under the mapping. Reading a fixture applies the same secret screen to the whole fixture, candidates and expectations included, and the same shape check, so neither an export wrapped by hand nor a secret typed into a committed candidate is checked or re-recorded; tenant text typed by hand into a candidate's policy text cannot be told from other policy text without the source, and rests on review of the fixture's diff (R41). A fixture is written readable by its owner alone. Every retained record, a provider's journal, a connector record or an executor's Ed25519 record or ES256 attestation, is signed again under a key published for fixtures alone and never keeps its source's signature, which anyone holding the signer's public key could test guesses of the original identifiers against; reading refuses any record not signed under those keys, and the runtime refuses each key wherever a key becomes authority. Each settlement keeps its tier, so a fixture never shows an executor's word or ExecBound's own check as a provider's enforcement, and the check compares classified outcomes only (replay/pseudonyms.py, replay/sanitize.py, replay/fixture_keys.py, replay/fixture.py).RF3, RF5, RF6, RF7, RF8, RF9, RF11, RF12; tests/test_replay_fixture.py::test_rf5_every_kind_of_label_is_replaced_by_its_typed_pseudonym, ::test_rf5_the_agents_case_on_the_first_transition_never_reaches_a_fixture, ::test_rf5_a_kernel_reason_code_is_kept_and_any_other_reason_is_replaced, ::test_rf5_a_label_that_is_also_a_kept_word_is_replaced_only_where_it_was_recorded, ::test_rf5_a_label_that_is_a_word_the_fixture_writes_itself_still_becomes_a_fixture, ::test_rf5_a_label_that_is_a_cedar_word_or_a_kernel_reason_code_stays_where_the_kernel_wrote_it, ::test_rf5_an_owners_version_or_ref_inside_policy_text_is_replaced_there_too, ::test_rf5_text_a_person_typed_lends_no_kept_word, ::test_rf5_a_signal_facts_source_version_is_kept_and_any_other_facts_is_a_label, ::test_rf5_no_source_identifier_or_label_survives, ::test_rf5_each_label_is_replaced_once_from_its_own_text, ::test_rf5_a_label_that_reads_as_another_labels_pseudonym_is_refused, ::test_rf11_no_source_identifier_or_key_id_survives_inside_any_record, ::test_rf12_a_fixture_holding_a_record_another_key_signed_is_refused, ::test_rf12_an_identifier_hidden_in_an_attestations_claims_is_refused, ::test_rf7_a_label_that_survives_inside_other_text_is_refused, ::test_rf7_a_version_left_inside_policy_text_is_refused, ::test_rf7_a_one_word_comment_that_is_a_token_of_a_time_is_refused, ::test_rf3_an_export_wrapped_by_hand_as_a_fixture_is_refused, ::test_rf3_a_fixture_edited_to_hold_a_secret_is_refused_by_reading, tests/test_replay_fixture_cli.py::test_rf8_a_fixture_is_written_readable_by_its_owner_only, tests/test_replay_fixture_worlds.py::test_rf9_a_signal_world_fixture_is_deterministic_sanitized_and_checksVerified, see R41

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 development repository's 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..github/workflows/security-scans.yml in the development repository; the public repository runs no scheduled scanPartial
T8.2IA credential committed to the repositoryEnvironment and key files are ignored; gitleaks scans the whole history weekly and on demand in the development repository's 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..github/workflows/security-scans.yml in the development repository; the public repository runs no scheduled scanPartial
T8.3TEvidence not tied to the tested sourceThe CI manifest binds artifacts and collected test nodes to the exact commit and lock hashes.tests/test_validation_evidence.py::test_manifest_maps_exact_tests_and_excludes_private_files, ::test_manifest_rejects_results_not_matching_collectionVerified
T8.4TParsing or canonicalization bugs under unexpected inputExample-based tests, plus Hypothesis property-based and fuzz tests over the request, MCP and monitoring parsers, canonical JSON, the intent, plan and binding hashes and the contract domains.tests/test_properties.pyVerified
T8.5IStatic analysis limited to open-source toolingBandit runs on every change in CI, as a step of the required verify job in the development repository and in the lint stage of the public repository's, and weekly in the development repository's scheduled scans, which retain its findings. CodeQL and native secret scanning need GitHub Advanced Security on the private development repository and are available on the public one at no cost. No remediation SLA is adopted yet.scripts/ci.py (stage_lint); .github/workflows/security-scans.yml in the development repositoryAccepted
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 a hosting platform's operator shell (the image runs no SSH server); allowlist build context; CI builds and smoke-tests every image. The development repository's verify job 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; the public repository's CI builds, smoke-tests and describes the image the same way, in its provenance stage, without retaining the documents, and a release publishes them beside the signed image (T12.6).The development repository's verify job, in its .github/workflows/ci.yml, which the public repository's own workflow replaces in the export; 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 yet is accepted in writing in the development repository's security/acknowledged-advisories.toml, beside the scheduled scan that reads it, 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 development repository's test suite validates the committed file rather than leaving it to the weekly scan alone. The public repository carries no such file, and scripts/scan_summary.py reads an absent file as acknowledging nothing.tests/test_scan_summary.py, tests/internal/test_acknowledged_advisories.py::test_the_committed_acknowledgements_are_valid_and_current; .github/workflows/security-scans.yml in the development repository; the public repository runs no scheduled scanVerified
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, and a hand-written secret assertion; tests/internal/test_workflow_rules.py holds the same shape of rule for a traced shell step in a workflow, private-only there because .github is. 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, tests/internal/test_workflow_rules.pyVerified
T8.9IThe self-hosted console build carries hosted code (a hosted page, a call to a hosted route, hosted copy), or the hosted gateway serves the core consoleThe core build lists every module it loaded, and every asset it emitted, and fails on one under console/src/hosted/ (console/scripts/editions.ts); ESLint refuses an import of a hosted module, static or lazy, in core's files under console/src/ and console/scripts/, and @edition and @route-tree there outside console/src/main.tsx; the core build's own tests read the packaged build's document for its edition, core, its manifest for no module under src/hosted/, and its scripts and document for the hosted service's paths as strings, which the refusal of modules cannot see: /signup, /invitations and every path of the hosted plugin's committed console API document, each matched only where it ends, with a parameter standing for any one segment; the hosted deployment sets the build argument CONSOLE_EDITION=hosted on its gateway alone; CI reads core from the image it builds, and in the development repository its verify job also builds the image's console stage with that argument and reads hosted from it, a check the public repository's CI does not make because its tree carries no hosted edition; and /health/ready reports console, the edition the served build names. Core's pages take the hosted service's addresses from the deployment's service links (T12.5), so the core build carries no hosted address. One gap remains. A hosted gateway built without the argument serves the core console: readiness reports that and refuses nothing, so only the release check a person makes by hand after each hosted gateway deploy finds it.CE1, CE2, CE7, CE10; hosted: CE8; tests/test_console_serving.py::test_the_packaged_build_is_the_core_edition and tests/internal/test_blueprint.py::test_the_gateway_builds_the_hosted_console_and_no_other_service_names_an_edition, tests/test_service_links.py::test_without_service_links_no_page_links_to_a_service_pagePartial
T8.10RDocumentation claims a compatibility the repository cannot prove: an integration listed as tested whose test was renamed or never runs, or a platform named as validated with no record of the trialdocs/INTEGRATIONS.md is rendered from integrations/manifest.json, and the manifest test refuses a row whose claim outruns its evidence. A fixture-tested row names a mode other than Unknown, the ExecBound version it pins and at least one test, each a function its file defines, and every test any row names must be a node this suite collects, in one collection over all of them, so a test renamed or moved in one pull request fails it while the manifest still cites the old place. A platform-validated row names a trial record, and every record a row names must exist with its host, version, date, transport, gateway commit and fixture seed and its scenarios. A research-only row claims no mode, no tested version, no test and no trial. A recipe a row names must exist, no row and no line of the page uses a commercial word, and the page's own links resolve. Whether the page is still the manifest's current rendering is checked only in the development repository, because that check also compares the site's copy of the register, which a self-hosted export does not carry. What this holds is each row's claim against its own evidence at the versions it names, and nothing about a platform beyond the recorded runs; it judges a row's mode only as far as holding a research-only row to Unknown and a fixture-tested one away from it, so a path that can be skipped says so in its own limits (R35).tests/test_integrations_manifest.py::test_each_row_claims_only_what_its_evidence_supports, ::test_every_test_a_row_names_is_one_this_suite_collects, ::test_a_trial_a_row_names_is_a_record_with_the_interview_fields, ::test_the_integrations_page_has_no_commercial_words_and_its_links_resolveVerified

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, a registered key nobody holds the private half of, or one everybody holds the private half ofPublic keys only, Ed25519 or ES256, 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 executor protocol's published test keys, whose private halves its vectors publish, are refused at registration and rotation in every spelling, attest nothing if a ring already holds one, and count as no live key to admission, bundle validation or the revocation guard, and its published journal keys, and the one replay's regression fixtures are re-signed under, are refused as any connector's, Gate account's or sample key in every form HMAC-SHA256 reads as the same key (provider/published_keys.py); 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, runtime.py).tests/test_checkpoint_evidence.py, tests/test_admin_cli.py, tests/test_published_test_keys.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; what records that the question was unanswerable is the class the grant carried, which its execution.checkpoint_granted events retain and the execution records at the first answer. 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. The outcome route also refuses any journal, before checking it, for a grant whose execution records the class UNAVAILABLE, which carries no provider_command for a journal to be of, even one of the command frozen with the plan; the class is the one recorded at the first answer, and a grant issued before it was recorded is read as before (execution/outcomes.py, execution/admission.py).TG3, TG14; tests/test_gate_evidence.py, tests/test_gate_vendor_walk.py, tests/test_checkpoint_journal_class.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. Most rows here are fixture-verified; one live trial on 18 September 2026 observed blocking and state end to end (ARCADE.md).

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 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/ and its console routes are surfaces/console_api/assistant.py.

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

B12: Development repository to public export​

The self-hosted source is published by copying it, one commit at a time, from the development repository into a public one. Files cross one way, written by the exporter; a contribution crosses back only when a maintainer applies it. Nothing here runs in a deployment: each row is about what the published source carries, and who can change it.

IDSTRIDEThreatMitigationEvidenceStatus
T12.1IA private file, a secret, a private name or a link back into the development repository leaves in an exportThe export manifest names what may leave: code by pattern, less the hosted plugin, its tests, its console edition and the tests of unpublished files; scripts and root files one by one; and the documentation site's published set. The build reads one commit, never the working tree, and checks the manifest in both directions, so a written file it does not name and an entry that names nothing are both refused. A link into the development repository loses its link and keeps its text, and one that survives is refused. A scrub then reads every file of the built tree with the documentation site's rules, the secret definition, the private names, and the export's own rules for a hosted database's project reference, a tenant's own domain, an address outside the reserved example domains, the hosted service's address and, in documents, the hosted plugin's name. A finding is refused unless an allowance names its file and a reason, an allowance that accepts nothing is refused too, and a file the scrub cannot read is refused rather than skipped. Publishing refuses to run without the private names, and the first export is reviewed file by file before the public one is made (the development repository's exporter, scripts/export_foss.py).hosted: EX1, EX2, EX3; tests/internal/test_export_foss.py::test_the_build_copies_what_the_manifest_names_and_nothing_else, ::test_this_repository_manifest_names_only_what_is_here, ::test_the_build_refuses_a_link_it_cannot_take_out, ::test_the_scrub_refuses_what_it_is_for, ::test_a_private_name_is_refused_in_any_file, ::test_a_stale_allowance_is_refused, tests/internal/test_export_publish.py::test_publishing_refuses_without_private_namesPartial, see R29
T12.2EA fork's pull request runs code on the maintainers' own machines, or it, or anyone with write access to the development repository, reaches the credential that writes the public repositoryThe public repository's CI runs on GitHub-hosted ubuntu-latest runners alone, with every action pinned to a full commit and a token that reads the repository and nothing else; the development repository's self-hosted runners are registered to it alone. The job that publishes an export runs on ubuntu-latest too, so the key that writes the public repository never shares a machine with any other job. Only the people that job's workflow names can run it: its first step, before any step reads a secret, refuses unless both the person who dispatched the run and whoever started its latest attempt are named, and the environment that holds the key deploys from main alone, so the list changes only through a reviewed change there.EX5; hosted: EX7; tests/internal/test_public_workflow.py::test_the_workflow_runs_on_github_hosted_ubuntu_alone, ::test_every_action_is_pinned_to_a_full_commit, ::test_the_token_reads_the_repository_and_nothing_else, ::test_the_publishing_job_runs_on_github_hosted_ubuntu_alone, tests/internal/test_export_publish.py::test_only_the_people_the_workflow_names_can_run_it, ::test_the_named_people_check_runs_before_any_secret_is_read; the publishing job's runner is set in the development repository's .github/workflows/export-publish.ymlVerified
T12.3IA security fix is published before the hosted service runs itA merged pull request labelled embargo blocks an export while its merge commit or its head is in the source commit's history and not in the history of the commit the hosted service's readiness reports; the lower pull requests of a stack count by their heads. Readiness that cannot be read or reports no commit, and a labelled pull request whose commits the clone does not hold, are refused rather than passed. Without the label an export to the private test repository passes and says so, and the public repository is refused, so deleting or renaming the label cannot switch the rule off. The label is a maintainer's to apply: a fix nobody labels is not held (the development repository's exporter, scripts/export_foss.py).hosted: EX6; tests/internal/test_export_publish.py::test_an_embargoed_merge_hosted_does_not_run_yet_is_refused, ::test_an_embargoed_merge_hosted_already_runs_passes, ::test_a_stacked_embargoed_pull_request_is_found_by_its_head, ::test_an_embargoed_merge_is_refused_when_readiness_cannot_show_hosted_runs_it, ::test_the_public_target_is_refused_without_the_embargo_labelVerified
T12.4TCode contributed to the public repository reaches the development repository or the hosted service without reviewNothing flows back on its own. The public repository's main changes only through export pull requests, and a public contribution is applied in the development repository by a maintainer, after review and a check for the contributor agreement, as an ordinary pull request that passes that repository's own checks, with its author kept and a trailer naming the public pull request. The script that applies it refuses any head but the one reviewed, a merge commit and a commit its author did not sign off, and puts the result on a branch of its own, from the export the pull request was written against. Each export run is started by one of the people the publishing workflow names. This is review by people, not a control the code enforces.hosted: EX11; tests/internal/test_apply_public_pr.py::test_a_pull_request_that_is_not_the_one_reviewed_is_refused, ::test_a_merge_commit_is_refused, ::test_a_commit_its_author_did_not_sign_off_is_refused; the public repository's CONTRIBUTING.mdAccepted
T12.5IA self-hosted install sends its people to the hosted service for help, account recovery or its policiesCore names no service address but one, the Source link's default: the public repository, where the code an install runs can be read. The page footers, "Forgot your password or lost access?" on /login, the MFA pages' account help, and the console's Help page, footer, My account and requests card take their addresses from the deployment's service links: the runtime configuration's service block or one plugin's, never both, which refuses start. Without either, none of those links renders but Source, which a service block may point at another copy or turn off with null, and a value that is not an https link with a host or a plain address is refused, so nothing in one can add a header to a mailto: link (service_links.py, plugins.py). The export's scrub refuses the hosted service's address in any exported file that has no allowance (T12.1).EX8; tests/test_service_links.py::test_without_service_links_no_page_links_to_a_service_page, ::test_the_sign_in_page_and_every_footer_link_the_deployments_pages, ::test_a_service_block_beside_a_plugins_links_refuses_start, ::test_every_footer_links_the_public_source_where_nothing_names_other_links, ::test_a_service_block_keeps_the_source_link_unless_it_names_another_or_none, tests/test_plugins.py::test_the_service_links_come_from_the_configuration_or_a_plugin_never_bothVerified
T12.6SA release publishes an image nobody approved, from a commit CI did not pass or under a version the code does not carry, or an image a user cannot tell from a forgeryThe public repository's release workflow runs on a v* tag alone, in two jobs on GitHub-hosted runners. build holds a token that can only read, so the third-party code it runs (the image build, the locked sync and the SBOM generator) can push, sign and publish nothing. Before it builds anything, scripts/release_preflight.py refuses a tag that is not v<major>.<minor>.<patch> or does not point at the commit, a package version in any project file or the lock that is not the tag's, missing notes, a commit off main, an image name off ghcr.io or carrying a tag or a digest, and a commit on which ci.yml has not passed for a push to main. publish waits for a maintainer's approval in the release environment, and refuses before it pushes unless the run carries one. It also refuses an image other than the one the provenance describes, and a version the registry already holds. It signs the digest the registry holds for both tags, never a tag, with a cosign release checked against its published SHA-256 and under the workflow's own identity, and verifies that signature before it publishes anything else. The release record names the approval, the CI run and each published file's digest, and provenance is amended only for a build whose SBOM matched its image. Four gaps remain. Until a version tag is pushed, the workflow is held by tests rather than by a run. The environment, the tag ruleset, the image variable and the package's visibility are repository settings that no test reads. One maintainer can push the tag and also approve it. And a dependency compromised inside the lock is built and signed like the rest.EX10; tests/test_release_preflight.py::test_a_tag_that_is_not_a_release_version_is_refused, ::test_every_package_must_carry_the_tags_version_in_its_project_and_the_lock, ::test_a_commit_that_is_not_on_main_is_refused, ::test_ci_must_have_passed_on_exactly_this_commit_for_a_push_to_main, ::test_the_publishing_job_starts_only_on_an_approved_run, ::test_without_an_approval_for_the_release_environment_nothing_is_recorded, tests/test_release_provenance.py::test_only_a_build_whose_sbom_matched_its_image_can_become_a_release, tests/internal/test_public_workflow.py::test_only_the_approved_publishing_job_can_write_anything, ::test_third_party_code_runs_only_where_nothing_can_be_written, ::test_nothing_is_pushed_on_a_run_nobody_approved_or_over_a_published_version, ::test_the_signature_covers_the_pushed_digest_and_is_verified_before_anything_is_publishedPartial, see R29

B13: Security signal producer to gateway​

A producer is an out-of-process detector, first an EDR. It holds one credential that opens one route, and it can never read, decide, approve or dispatch. What its verdict means is the operator's mapping in the policy bundle, applied by one function, signals/mapping.combine, which resolution, evaluation and replay all call. Each row names the tests that hold it.

IDSTRIDEThreatMitigationEvidenceStatus
T13.1SA forged or stolen producer tokenEach source has its own expiring sg1. token, stored only as a SHA-256 digest the runtime role cannot read; the owner command writes the token once to a private file and audits without it; rotation keeps the previous token only through its grace window, and revocation is immediate, final and ends a grace window too; signal_admit authenticates inside the fence, a rotation committed between its two reads refuses the old token, and every credential refusal reads alike to the caller; the tenant, the source and its scope come from the registration, never the envelope (signals/tokens.py, provisioning.py, signal_admit in migrations/versions/0068_security_signals.py). What a thief holding a live token can do before it is revoked is T13.18.SG2; tests/test_signal_admit.py::test_an_unauthenticated_call_stores_and_records_nothing, ::test_the_previous_token_works_only_inside_its_grace, ::test_a_rotation_committed_between_the_two_reads_refuses_the_old_token, tests/test_signal_registration.py::test_registration_writes_the_token_once_privately_and_audits_without_it, ::test_rotation_keeps_the_old_token_only_through_its_grace, ::test_revocation_is_immediate_and_final, ::test_revocation_inside_a_grace_window_ends_the_previous_token_too, tests/test_signal_schema.py::test_the_runtime_never_reads_a_producer_digest, ::test_check_runtime_refuses_a_runtime_that_can_read_a_producer_digest, tests/test_signal_intake.py::test_every_credential_refusal_answers_the_same_and_records_its_reasonVerified, see R30
T13.2SThe protected agent attests its own target's verdict: compromised to take the review out of an isolation, or clean to pass a checkA producer token is its own credential kind: the intake refuses an agent credential and every other route refuses a producer token, and registering a source is an owner command whose actor must be an ADMIN. clean needs liveness wherever a mapping is applied: validation and activation refuse a mapping to clean over a source not registered as attesting liveness, and signals/mapping.combine refuses a combined clean when any record it combines has attests_liveness false, a field every frozen SignalRecord must carry, so evaluation and replay refuse it over frozen records under whatever bundle they apply (signals/tokens.py, signals/http.py, provisioning.py, policy/store.py, signals/mapping.py).SG3, SG14; tests/test_signal_conformance.py::test_sg3_credentials_are_refused_both_ways, tests/test_route_authority.py::test_a_producer_token_reaches_nothing_but_the_signal_intake, tests/test_signal_registration.py::test_an_unknown_group_and_a_non_admin_actor_are_refused, tests/test_signal_bundle_validation.py::test_clean_needs_a_source_that_attests_liveness, tests/test_signal_mapping.py::test_clean_needs_every_record_from_a_source_that_attests_liveness, tests/test_signal_evaluation.py::test_a_forged_clean_over_a_source_without_liveness_is_refused, tests/test_signal_replay.py::test_a_candidate_reading_clean_from_a_source_without_liveness_cannot_be_readVerified
T13.3SThe agent launders a verdict by choosing what the producer is shownThe input path is recorded at registration, and a required mapping admits only an independent source: validation and activation refuse an agent_forwarded one at its location, and resolution re-checks the source row it read (policy/store.py, signals/resolve.py). The input path is the owner's statement about how the producer is fed, which ExecBound cannot observe.SG13; tests/test_signal_bundle_validation.py::test_an_agent_forwarded_source_is_refused_in_a_required_mapping, tests/test_signal_resolution.py::test_an_agent_forwarded_source_is_refused_in_a_required_mappingPartial, see R31
T13.4TAn envelope widens its own authority through tenant, capability, subject or severityThe envelope carries no tenant, space or principal, and a request naming one in an x-tenant-id, x-principal-id or x-role header or a query string is refused before it is read; the runtime role cannot write either table, so signal_admit is the only writer, and inside the fence it checks the capability, the subject kind and the producer's name and version against the registration, and the subject's account and provider against the token's own tenant's connector accounts, recording each refusal against the source; severity is stored, but resolution never reads it and no plan, audit event or view carries it, so the severity a producer claims changes no decision (signals/http.py, signals/envelope.py, migrations/versions/0068_security_signals.py).SG1, SG6, SG7; tests/test_signal_conformance.py::test_sg1_to_sg9_a_hostile_or_broken_envelope_is_refused_and_stores_nothing, tests/test_signal_admit.py::test_a_field_outside_the_registration_is_refused_and_recorded, ::test_another_tenants_account_is_an_invalid_subject, tests/test_signal_intake.py::test_a_request_shaped_outside_the_bounds_is_refused_and_stores_nothing, tests/test_signal_schema.py::test_the_runtime_role_cannot_write_either_table, tests/test_signal_views.py::test_severity_attributes_and_evidence_never_leave_the_recordVerified
T13.5TA conflicting duplicate or a reordered envelope flips a verdictThe first write wins per source and event id: a retry answers its original receipt, and different content under the same id is refused CONFLICTING_DUPLICATE and counted against the source; resolution reads each source's newest observation, so an older observation that arrives later never replaces it, and only a tie on observation falls to the later receipt and then to the event id in byte order (migrations/versions/0068_security_signals.py, execution/snapshot.py).SG8; tests/test_signal_conformance.py::test_sg8_a_conflicting_duplicate_keeps_the_first, tests/test_signal_admit.py::test_a_retry_answers_its_receipt_and_a_conflict_keeps_the_first, tests/test_signal_resolution.py::test_a_later_observation_supersedes_an_earlier_one_whatever_the_arrival_order, ::test_the_later_receipt_wins_a_tie_on_observation, ::test_a_tie_is_broken_by_event_id_in_byte_orderVerified
T13.6RA producer disputes having sent a signalEvery record keeps the digest of its canonical form, its receipt time on the intake's clock and the registration version it was admitted under, and each plan that used it freezes those with the record, in the decision capture that replay reads; creating, rotating and revoking a source are audited under the acting ADMIN (signals/envelope.py, resolution/models.py, provisioning.py).SG17; tests/test_signal_envelope.py::test_the_canonical_form_and_its_digest_are_pinned, tests/test_signal_admit.py::test_an_accepted_record_is_stored_with_the_intake_clock, tests/test_signal_resolution.py::test_the_newest_record_is_mapped_and_frozen, tests/test_signal_replay.py::test_four_records_at_maximum_width_fit_the_decision_capture, tests/test_signal_registration.py::test_every_change_to_a_source_is_audited_in_its_spaceVerified, see R3
T13.7IPrompts or reasoning traces leak into auditThe envelope has no field for them and refuses unknown fields, and the intake refuses secret-shaped text without quoting it; attributes are at most sixteen bounded scalars that stay in security_signals and never enter a plan, its decision capture, an audit event or a view; an approver sees the mapped value, the source's registered label and the capability, on the console and on the classic approval page, and only an administrator reads the frozen records, which hold no attributes (signals/envelope.py, signals/intake.py, resolution/models.py, surfaces/console_api/approvals.py, surfaces/console_api/executions.py, surfaces/human.py).SG5; tests/test_signal_envelope.py::test_anything_else_is_refused, tests/test_signal_intake.py::test_secret_shaped_text_is_refused_and_never_quoted, tests/test_signal_views.py::test_an_approver_sees_the_value_the_label_and_the_capability, ::test_an_approver_reads_the_capability_but_not_the_records, ::test_an_administrator_sees_the_record, ::test_severity_attributes_and_evidence_never_leave_the_record, ::test_the_classic_page_names_the_label_and_the_capability_and_nothing_of_the_recordVerified
T13.8Ievidence_ref used to make ExecBound fetch a URLIt is opaque text of at most 256 visible characters, refused with any control or invisible character, and it stays in security_signals: no frozen record, audit event or view carries it, and nothing fetches or links it (signals/envelope.py, resolution/models.py).SG5; tests/test_signal_envelope.py::test_invisible_and_control_characters_are_refused_in_free_text, tests/test_signal_intake.py::test_an_envelope_with_every_field_is_stored_exactly_as_sent, tests/test_signal_views.py::test_severity_attributes_and_evidence_never_leave_the_recordVerified
T13.9DFlooding the intake or its storageAn 8 KiB body bound counted in bytes, a 5-second body deadline, 8 concurrent intake slots with an immediate 429, 600 envelopes per source per minute, and a cap of 500,000 records per source, counted over the source's whole life because nothing prunes them; signal_admit counts the last two in the transaction that refuses, and authenticates before it takes the source's row lock, so a caller without the token never waits behind a genuine admission (signals/http.py, migrations/versions/0068_security_signals.py).SG4; tests/test_signal_conformance.py::test_sg1_to_sg9_a_hostile_or_broken_envelope_is_refused_and_stores_nothing, tests/test_signal_intake.py::test_an_envelope_of_exactly_the_bound_is_read_and_one_byte_more_is_not, ::test_a_slow_body_is_refused_at_its_deadline, ::test_an_endless_body_is_cut_off_at_the_bound, ::test_the_ninth_concurrent_intake_is_refused_at_once, tests/test_signal_admit.py::test_the_rate_limit_refuses_commits_and_resets, ::test_the_retention_cap_refuses, tests/test_signal_schema.py::test_a_caller_without_the_token_never_waits_for_the_sources_lockVerified, see R8, R32
T13.10DA producer outage blocks decisionsDeliberate: a required fact fails closed, the operator chooses in the bundle where a producer is required, and no producer is on the synchronous path; the gateway never calls one. A producer whose clock runs ahead has its newest record refused at every decision until the gateway's clock passes the observation, up to the intake's 60-second allowance; one that runs behind loses that much freshness; both fail closed.SG10; tests/test_signal_execution.py::test_no_signal_is_refused_audited_and_nothing_runs, ::test_a_newest_record_observed_after_the_decision_refuses, tests/test_signal_resolution.py::test_a_record_observed_after_the_decision_refuses, tests/test_signal_admit.py::test_time_boundsAccepted
T13.11EA signal satisfies a fact for another resource, principal or tenantExact correlation on account, provider, resource type and provider id inside the tenant; a source's principal scope, up to 64 agents or one group, is a predicate of the reader, so a record applies only to a request from an agent inside it; forced row-level security on both tables; the tenant comes from the token, and another tenant's fence never finds the source (execution/snapshot.py, signals/resolve.py, migrations/versions/0068_security_signals.py). Run-subject capabilities are not built, so every record's subject is a resource. A record scoped to a request reference never applies, even to a request that names it, because a request's reference is the agent's own string: the reader reads only unscoped records and the intake refuses a request reference (signals/intake.py), so a request-scoped capability will need a binding the agent does not choose.SG1, SG5, SG12; tests/test_signal_resolution.py::test_every_way_the_fact_is_not_there_is_invalid_context, ::test_a_request_scoped_record_never_applies_even_to_the_request_it_names, ::test_a_request_scoped_record_alone_is_no_record, ::test_the_correlation_is_exact, ::test_a_record_for_another_resource_never_reaches_the_fact, ::test_a_source_scoped_to_another_agent_does_not_apply, ::test_a_group_scoped_source_applies_only_to_its_members, tests/test_signal_intake.py::test_a_request_reference_is_refused_before_the_token_or_the_writer, tests/test_signal_admit.py::test_another_tenants_fence_never_finds_the_source, tests/test_signal_schema.py::test_the_catalog_names_both_tables_and_the_one_writer, ::test_a_tenant_never_reads_another_tenants_sources, tests/test_signal_conformance.py::test_sg0_the_reference_producer_is_accepted_and_lands_in_its_own_tenant_onlyVerified
T13.12EA source in one space satisfies a fact for an agent in anotherThe reader's space predicate is spaces.space_clause's, and resolution re-checks the space on the row it read; validation and activation refuse a source that the bundle's own space, an offered bundle or the activating space cannot use; negative tests, because spaces have no row-level security second line and signal_sources is not among the tables the space isolation test reads through the shared predicate; these are the tests that test names as signal_sources' case (execution/snapshot.py, signals/resolve.py, policy/store.py), beside the label reader's (surfaces/human.py, R25).SG12; tests/test_signal_resolution.py::test_a_source_of_another_space_does_not_apply_unless_offered, ::test_the_statement_never_reads_a_source_of_another_space, tests/test_signal_bundle_validation.py::test_a_source_of_another_space_is_refused_unless_offered, ::test_activation_in_another_space_needs_a_source_that_space_can_use, ::test_an_offered_bundle_admits_only_offered_sourcesVerified, see R25
T13.13EAn approval overrides a missing required signalMissing is INVALID_CONTEXT, which denies before any execution or approval exists. A person's decision refuses a plan whose source was revoked or whose record went stale, and dispatch refuses one whose source was revoked, each as STALE_AUTHORIZATION; an approval never outlives the signal's validity (execution/snapshot.py, execution/approvals.py, execution/admission.py).SG10, SG11; tests/test_signal_execution.py::test_no_signal_is_refused_audited_and_nothing_runs, ::test_an_approval_cannot_rescue_a_revoked_source, ::test_an_approved_request_cannot_run_on_a_revoked_source, ::test_a_decision_after_the_signal_goes_stale_is_refused, ::test_a_person_may_decide_while_the_signal_holdsVerified
T13.14EA signal after dispatch alters a settlementThe plan is frozen, the last re-attestation is inside the transaction that commits dispatch, and settlement never reads a signal: a record that changes the value and commits while dispatch is being committed refuses it, and a record after dispatch changes neither the execution, its answer to a retry, nor its impact and claim (execution/admission.py, execution/snapshot.py).SG16; tests/test_signal_execution.py::test_a_record_after_dispatch_changes_nothing, ::test_a_record_arriving_while_dispatch_is_committed_refusesVerified
T13.15EA clean from one producer outweighs compromised from anotherMost restrictive is the only combination, in the capability's order and over exactly one record from every source the mapping admits; a silent source is INVALID_CONTEXT, never skipped, and a tie names the lowest source id (signals/mapping.py).SG10; tests/test_signal_mapping.py::test_the_most_restrictive_value_wins_and_names_its_source, ::test_compromised_outranks_suspect_whatever_the_text_order, ::test_every_admitted_source_must_contribute_exactly_once, tests/test_signal_resolution.py::test_every_admitted_source_must_speak, ::test_the_most_restrictive_source_names_the_fact_and_every_record_is_frozenVerified
T13.16EA host whose sensor stopped reporting reads cleanclean comes only from a source registered as attesting liveness. Validation and activation refuse a mapping to clean over any other source at its threshold's location, and refuse a source that does not attest liveness for a capability with no no_detection value, which leaves it nothing to report when it sees nothing; resolution re-checks the rows it read; and signals/mapping.combine refuses a combined clean when any record has attests_liveness false, which every frozen SignalRecord must state, so evaluation and replay refuse it over frozen records whatever bundle they apply (policy/store.py, signals/resolve.py, signals/mapping.py, resolution/models.py). Whether a source attests liveness is the owner's statement at registration.SG14; tests/test_signal_bundle_validation.py::test_clean_needs_a_source_that_attests_liveness, ::test_a_capability_without_no_detection_needs_a_source_that_attests_liveness, tests/test_signal_resolution.py::test_clean_needs_a_source_that_attests_liveness, tests/test_signal_mapping.py::test_clean_needs_every_record_from_a_source_that_attests_liveness, tests/test_signal_evidence.py::test_a_record_must_say_whether_its_source_attests_liveness, tests/test_signal_evaluation.py::test_a_forged_clean_over_a_source_without_liveness_is_refused, tests/test_signal_replay.py::test_a_candidate_reading_clean_from_a_source_without_liveness_cannot_be_readVerified, see R31
T13.17EResolution's reading of the records is trusted without a checkEvaluation re-applies the evaluated bundle's own mapping, through the same combine, to the records the plan froze, and denies a plan whose value, source, source version, object, observation or validity they do not reproduce, or whose records were observed or received after the decision (policy/evaluate.py).SG10; tests/test_signal_evaluation.py::test_a_fact_its_records_do_not_reproduce_is_denied, ::test_an_observation_its_records_do_not_reproduce_is_denied, ::test_validity_beyond_the_producers_expiry_is_denied, ::test_a_record_observed_after_the_decision_is_refused, ::test_a_record_received_after_the_decision_is_refused, ::test_a_plan_is_invalid_under_a_bundle_that_reads_its_records_differentlyVerified
T13.18EA mistaken or compromised detector, anyone able to raise a Medium alert on a host, or a thief holding a live producer token makes an isolation skip reviewNot prevented. A record authenticates its source, not its verdict, and the mapping is applied as written, so one admitted source's compromised satisfies the no-review rule for any inventoried host, on a request from an agent inside that source's principal scope. It is bounded by the policy's forbids (a domain controller is denied whatever the EDR says), the source's principal scope, the token's lifetime (at most 720 hours from issue, which a rotation's grace never extends), immediate revocation, and isolation being reversible.tests/test_signal_execution.py::test_a_compromised_host_is_isolated_without_review, tests/test_signal_evaluation.py::test_a_domain_controller_is_denied_whatever_the_edr_says, tests/test_signal_registration.py::test_revocation_is_immediate_and_final, ::test_the_grace_never_outlasts_the_old_tokens_expiryAccepted, see R30

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, and audit-verify places them on the live chain every hour where they are configured (on Community, a copy in private/checkpoints/, which catches a change made through the database alone, since the host's owner can replace it), but keeping the originals 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. A security signal is authenticated by its producer's bearer token and carries no producer signature, so its frozen digest shows what ExecBound admitted and under which registration version, which answers a producer's dispute only for a party that trusts ExecBound's audit chain (T13.6).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
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; tests/test_cedar.py::test_pinned_native_entry_points_keep_their_shape
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
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
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 hosted service's pilot of September 16 to 18, 2026, with synthetic data and mock providers, TLS terminated by its host and the database reached with a pinned CA bundle. It is a pilot, not a production deployment, and named host trials are unperformed.DEPLOYMENT.md
R12One real binding exists, Microsoft Graph's user disable, and it ran against a lab directory only; its precondition is a read and comparison with a measured window, not atomicity (T3.15). Every other binding is a controlled mock: CrowdStrike, Defender and the remaining Entra operations, and their precondition and journal guarantees at the real vendor, are unverified.PROVIDER.md; ACTION_CATALOG.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 has not been run.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; INTEGRATIONS.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
R21Little about a real Arcade deployment is established: one live trial on 18 September 2026 observed blocking twice and state end to end on one call (ARCADE.md). The hook contract was read from a beta published schema on one day; 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
R22A gateway compromise could mint invitation codes for free hosted sandboxes and mail them to an attacker's addresses. The prize is a mock-backed sandbox tenant with no real connector in it, bounded by signups.capacity and by the per-domain and global caps; every invitation is single use, and a claimed sandbox is never handed out again. The operator's signup report shows sign-ups diverging from the sandboxes taken. Nothing about it reaches a customer tenant.T1.12, T4.18
R23A 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.SURFACES.md; OPERATIONS.md
R24A 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.POLICY.md; SP7, SP8
R25The space predicate has no database backstop. A missing one is a correctness bug with a customer-visible consequence rather than a refusal, and the isolation test is the whole of the mitigation (T2.9). It reads the tables 0041_spaces.py names in OWNED_TABLES through the shared predicate, and it holds every other table the catalog shows carrying a space to a case of its own, so a table given a space later fails it until somebody writes one; what it cannot do is write the case. Those other cases are negative tests of the readers they name, not of every reader a table has: a role in access_role_grants (0047) through the approval queue, the approval, Activity, Home, the visible set and the decision; a rule in approval_rules (0048) through the queue, the approval, the decision and the notification; a source in signal_sources (0068) through resolution and bundle validation (T13.12), and through the label the approval and execution pages name it by; an agent's principals row and its space's policy selection through admission and the action listing; and an agent's principals row and its keys' identifiers and status through the administrator's credentials page (T5.29). The signal source labels the console and the classic approval page show take the shared predicate too, over the visible set the record they belong to is read under (human.signal_source_labels): a row the reader may see whose frozen plan names a source of a space they cannot see, and which is not offered tenant-wide, names the fact by its kind and nothing of that source. The record's frozen plan is still shown as it froze: an administrator narrowed to spaces reads its signal records, each with its source's id and producer name, though never that source's label. Since 0071_space_credential_scope, the credentials page reads only the administrator's spaces: an agent and its keys' identifiers and status take the shared predicate, as the Agents page's do (admin_pages.credentials_overview). Since 0072_space_principal_scope, what an administrator may change has a predicate of its own beside the visible set, spaces.administered_clause, the rule the definer functions apply (T5.29, T5.30): the credentials page's people, the People list and a person's page, the workspace's token list and the console's require_agent and require_person take it, so a narrowed administrator is shown only the people they may change, and a change to an agent they may not change answers 404. A missing one shows them a target they cannot change and the function still refuses the write, so here the database does back the write and only the read has no backstop. Lists that only name people stay unfiltered on purpose: the approvers page's assign form and assignments, and the approvers an approval names.SP1, SP15, SP18; tests/test_spaces_isolation.py::test_every_space_carrying_table_has_a_case, tests/test_signal_views.py::test_a_label_of_another_spaces_source_is_withheld_from_a_reader_of_this_one, ::test_a_source_offered_tenant_wide_keeps_its_label_in_every_space, ::test_a_member_of_the_sources_space_reads_its_label_whatever_space_they_look_at
R26Deprovisioning 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, throughout the two days a session may last and not only before it reaches its end, 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).T5.19; tests/test_directory_store.py::test_an_unreachable_directory_is_tolerated_and_then_refuses
R27A 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
R28The sample key is one secret for every workspace's sample connection. Whoever holds it can act on every workspace's sample endpoint mock as that workspace's sample account, and on nothing else: the mock holds synthetic targets only and nothing real is derived from the key. Rotating it is a restart of the gateway and the endpoint mock that cuts every sample connection at once, and there is no rotation from the console. A sample target's facts are recorded at install and go stale after the starter ruleset's hour; an administrator's refresh (POST /api/console/v1/samples/refresh) records them again by removing and adding the samples in one transaction, which gives each target a new incident. The refresh is refused, changing nothing, and the facts stay stale while a request on the samples is unresolved, a request whose approval lapsed included until something ends it; while a bundle a person made names the sample account, as a copy of the starter from Set your controls' copy of the active policy or its templates does, until it is retired; while a space other than the default one selects the starter; and once the workspace has a policy or a connection of its own. The way past the second is to retire that bundle in Policies, activating the starter again first where it is the active one, since a selected bundle cannot be retired and the refresh's install refuses any other active policy.PROVIDER.md
R29Until an export runs on the public repository, its CI workflow is held by tests rather than by a run, and until a version tag is pushed, so is its release workflow. Until then its shape is held by tests and not by a run, and the release environment, the tag ruleset, the RELEASE_IMAGE variable and the package's visibility are repository settings that no test reads (T12.6). What the development repository proves after every merge is the stages scripts/ci.py runs, not the YAML that calls them, and the bounds of the suite's timed tests, set on a quiet runner, are unmeasured on shared ones. The file-by-file review of an export before it is published is a person's step, not a check.T12.1, T12.6
R30A security signal proves which registered source sent it, never that its verdict is true. A false positive, a compromised EDR console or a stolen producer token is enforced as the mapping reads it, and in the signals guide's example scenario the enforced direction is permissive: compromised lets an isolation run without review, the most restrictive combination lets any one admitted source supply it, and Defender's Medium maps to it. A false None from the only admitted source sends a compromised host's isolation to review instead.SIGNALS.md; T13.18
R31A signal source's placement, input path and liveness attestation are the owner's statements at registration, which ExecBound cannot observe. A source registered independent but fed by the agent's runtime lets a compromised runtime choose what the producer judges; one registered as attesting liveness that cannot show its sensor is still reporting lets a silent host read clean. The Defender for Endpoint producer is an EDR, independent by construction, and does not attest liveness, so it can never produce clean.SIGNALS.md; T13.3, T13.16
R32Nothing prunes security signal records, so a source's 500,000-record cap counts its whole life: about 347 host-days at one report per host a minute, a week for fifty hosts, and about 14 hours for a thief at the rate limit. Then every envelope is refused SOURCE_CAPACITY, and every fact resting on the source is INVALID_CONTEXT until the owner registers a new source and activates a bundle admitting it. It fails closed.SIGNALS.md; T13.9
R33Replay keeps each fact's validity as it was frozen, so a candidate bundle that shortens a signal's freshness replays as a certain DENY even where the record was fresh enough, and a certain denial leaves the what-if ledger, so later decisions can show more room than the candidate would have left them. A freshness change cannot be previewed over history; inventory facts behave the same way.REPLAY.md; tests/test_signal_replay.py::test_rederive_reads_the_frozen_records_through_the_candidate
R34Text the screens of T7.6 and T7.7 cannot reach. Audit events already written and bundle contents already stored are immutable, and a destination an earlier event failed stays failed: export-resume reads that event again and no command moves a cursor past a refused event (export.py resume_export), and the replay export refuses every export of that tenant the same way. Values the registry already holds pass no screen when new plans and events copy them: a resource's provider id and version, a mapping's operation ref and version and a manifest's values reach the frozen plan an execution.transition carries, an executor key id reaches executor_key.revoked or executor_key.compromised when it is named, the old id a rotation retires reaches executor_key.rotated as previous_key_id (administration.py rotate_executor_key, which screens only the new id), a context source's writer label reaches context.imported, and a hosted slot's seed reaches tenant.claimed; refusing them at resolution would deny every action on them. A policy bundle stored before the bundle screen existed keeps copying its rule ids into new execution.evaluated events for as long as it stays active: policy/evaluate.py derives HARD_LIMIT:<rule id> and limit:<rule id> from a bundle's own rule ids on every evaluation, and the bundle screen in policy/store.py _insert_draft reaches only a bundle written from now on, never one already stored. Operator configuration strings that reach an event, the assistant's model in assistant.asked and the hosted consent_version in tenant.provisioned, are deployment configuration and are not screened. A person whose stored address already has a domain of that shape cannot be invited again under it by invite_person, which fails closed. A slots file that already holds a seed of that shape fails closed only where the seed reaches an event: the hosted plugin's invitation writers refuse it before anything is written. Every other reader of the file reads it as it always has; a screen where the file is parsed would have stopped the service starting over a seed no write of theirs carries, so the screen sits at those writers instead.T7.6, T7.7; OPERATIONS.md, event export; REPLAY.md
R35Both Tines stories are Advisory, because nothing in Tines forces a story through ExecBound. The governed-action story is governed only because its author wrote the call: a caller holding its own vendor credential can skip it, and in its trial a story with no provider credential was refused by the provider, not by ExecBound. The Gate story leaves the vendor credential in the customer's Tines team, so any story there that holds it can call the vendor without asking, a bypass the Gate trial demonstrated, and what a story does with a grant, honouring a denial, acting once on the granted target and reporting truthfully, is its own cooperation. ExecBound decides, charges and records only what a story asks it; an action no story asked about leaves nothing here to find.INTEGRATIONS.md; the Tines recipe; T8.10
R36A directory outage locks people out of the console by design once T5.19's grace runs out. A directory session that cannot be rechecked stands for fifteen minutes past its last verification and is refused after that, and signing in again needs the same directory, so for the rest of the outage a person whose only sign-in is that directory, an approver included, can neither read the console nor decide an approval in it. Nothing is revoked, so an outage that resolves does not force a tenant-wide sign-in. Passwords and credentials keep working beside a directory, so a person who holds one is the way in during an outage; refusing them once everybody is linked is not built, and nor is a break-glass path for when it is. This is the availability side of T5.19, the opposite case to R26, where access outlives a disabled account.T5.19; OPERATIONS.md; tests/test_directory_store.py::test_an_unreachable_directory_is_tolerated_and_then_refuses
R37A Policy manager's preview, or a narrowed administrator's, replays the whole tenant's chain in server memory, because the export's integrity check needs the complete prefix. Only the decisions recorded in their Spaces leave it, but complete and issues are tenant-wide codes, and a candidate's limit differences count tenant-wide impact, as shared limit scopes intend, so a preview can reflect that other Spaces acted without naming what.REPLAY.md; tests/test_policy_preview_spaces.py; T5.35
R38The value report's and the weekly summary's counts are counts of what ExecBound recorded, not of harm prevented: a refused request may have been benign and an executed one harmful. The report, the card and the summary say so in fixed copy. Observe mode's would-have counts are a simulation of the active bundle at the time and are never added to enforced counts.CONSOLE_GUIDE.md; T4.53
R39Text a person typed in an approval's comment rests in that browser tab's storage until a decision, a refusal that ends the approval, sign-out, the approval's deadline (swept when the console next loads the store) or the tab's closing, and a secret pasted by mistake rests there until the kernel refuses it when the decision is sent. It is never sent anywhere but that decision.T4.59; tests/test_browser_console_approvals.py::test_a_typed_comment_survives_signing_in_again
R40Email control is account control for an account without a second factor: whoever reads its mailbox can reset its password on the hosted service. The reset ends every session, mails a notice and appears in the bell and in each workspace's audit chain, so the owner learns of it, but cannot stop it in advance except by enrolling a factor. For an enrolled account, mailbox control buys online guessing of the factor at a bounded rate (five guesses a link, ten links a day), and can set the factor's fifteen-minute lock up to ten times a day. Anyone who knows an account's address can spend its three reset emails an hour and ten a day, and someone who knows twenty addresses can spend the sixty an hour for everyone. The database call takes slightly longer or shorter with an account than without, which the network hides but the design does not equalise.T4.81, T4.82
R41A replay regression fixture is pseudonymized, not anonymized. It keeps the event sequence and every time, the number and kinds of decisions and their outcomes, catalog actions, contracts and writers, policy text, rule and obligation ids, enumerated fact values and claims, a signal fact's registration number, verdicts, producer names and versions and request numbers, and the counts of identifiers and labels it replaced, so someone who knows a tenant's history well can recognize it. A label whose text is also one of those kept words, or a token of one, is replaced where it was recorded and left as that word everywhere else, Cedar text included: the word is in the fixture anyway, but a copy of the label in free text keeps it. An operation ref or a version with no letter in it, such as 7 or 2026-10-01, is replaced and looked for only as a whole value or JSON string, so a copy of one inside policy text stays as it is. Its integrity is internal consistency only: its head travels with it and every retained record is re-signed under a published fixture key, so a fixture is regression evidence about the engine and never provenance, and an attested or provider-verified decision in it says what tier the source settled at, not that any executor or provider signed these bytes. Only fixtures built from synthetic worlds are committed to this repository; one built from a real tenant is handled like the export it came from, and is written readable by its owner alone. Reading a fixture refuses one holding a secret, an identifier that is not a pseudonym or source text at a label position, but it cannot tell a tenant's free text typed by hand into policy text, such as a host named in a comment added to a committed candidate, from any other policy text, since nothing of the source travels with a fixture; such an edit rests on review of the pull request's diff, as re-recorded expectations do. A correctly built fixture of a real tenant reads like a synthetic one, so which world a committed fixture came from rests on review too.REPLAY.md; T7.8

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, and signal source registrations (placement, input path and liveness) 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.

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 public repository's CONTRIBUTING.md 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, except one written after hosted:, which names a row of the hosted service's own inventory and is checked against it where a tree carries it and counted as evidence the tree cannot see where it does not, 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. A row may cite a test in tests/hosted, which covers the hosted service's plugin, or in tests/internal, which covers files that are not published; a tree that holds the directory checks the citation like any other, and a tree without it, such as the self-hosted source tree, counts the citation as evidence it cannot see and neither fails nor skips it. 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.

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.