ADR 0039: Personal-Observe Deployment Profile
Status: Accepted Date: 2026-09 Ticket: HORO-1375 ([AASM DogFood] Provide durable personal observe profile and verify local audit wiring), sub-tasks tracked under HORO-1369
Context
Personal, unmanaged use of Agent Assembly’s local single-process
aa-api-server entrypoint wants a deployment posture where a policy denial
is recorded — audited — without blocking the agent’s action, for an agent
that has not been given an explicit per-agent enforcement_mode. Nothing in
the codebase before this ticket exposed that posture as a supported,
documented feature.
Two structural properties were also found broken by inspection of the codebase before any new code was written (§2 of the design amendment this ADR formalizes):
AgentRegistry::set_enforcement_mode_persistedhad no guard against persisting(Some(Observe), None)— an uncapped, permanently-shadow record structurally invisible to the shadow-expiry reconciler (which requiresenforcement_mode_expires_at.is_some()).aa-api’s local audit hash chain was rooted understd::env::temp_dir()and reseeded to[0u8; 32]/seq = 0on every process boot — silently forking the audit trail from itself across every restart, the exact failure AAASM-5959 argued against for the gateway’s own audit sink.
Both are fixed as part of this ticket, independent of whether personal-observe is ever enabled, because a feature whose entire purpose is “denies are still recorded” cannot ship on top of a durability defect that can silently lose that same record.
§0: The spine — one invariant, everything else falls out
personal-observe supplies the policy-default slot only, and NEVER writes
AgentRecord.enforcement_mode or AgentRecord.enforcement_mode_expires_at.
Structural consequences that fall out of this one invariant, not asserted independently of it:
- The 72h-capped per-agent override column always wins:
resolve_enforcement_modeisagent_override.unwrap_or(policy_default). - personal-observe never time-expires: the shadow-expiry reconciler
(
shadow_expiry_watcher::tick/agents_with_expired_shadow) only ever selects records withenforcement_mode = Some(Observe); aNonerecord — the only thing personal-observe affects — is invisible to it by construction, not by a special-case check. - personal-observe does not reuse the 72h enterprise shadow-renewal path; it never touches the expiry column at all.
Decision
§4: The structural anti-bypass mechanism
Two newtypes gate the two ends of the policy-default slot, in a
deliberately submodule-free leaf file, aa-gateway/src/engine/effective_mode.rs
— NOT declared in engine/mod.rs. A private field is module-scoped in Rust;
declaring these types in mod.rs would let every descendant module
(engine::decision, engine::cache, …) construct them freely. Putting them
in a leaf module with no mod declarations of its own makes “this module”
and “the exact scope of the invariant” the same one file — this ADR
deliberately does not claim a crate-wide guarantee, only a
one-file-scoped one.
PolicyDefaultModegates construction of the default. Its onlyObserve-yielding constructor,personal_observe(&PersonalObserveGrant), requires a grant that is itself only mintable byaa_core::observation::authorize_personal_observe— the boot gate below.EffectiveModegates consumption of the resolved result.transform_for_observe_modenow takes anEffectiveMode, not a bareaa_core::EnforcementMode; the only producer of one isresolve_enforcement_mode, which always consults the capped per-agent column first.
Gating only the input would leave
transform_for_observe_mode(eval, EnforcementMode::Observe) directly
callable — exactly the “future code path added carelessly” this is meant to
close off. Narrowing the consumer closes it: there is no EffectiveMode
constructor, no From<EnforcementMode>, and no Default that yields
Observe.
§4.2: Closing the two real uncapped-Observe holes found in the registry
Inspection found two producers below the request-validation layer that could
already create an uncapped Observe, independent of this ticket’s new
feature:
AgentRegistry::set_enforcement_mode_persistednow rejects(Some(Observe), None)— and, for symmetry,(Some(Disabled), None)— with a newRegistryError::UncappedShadowWindow, before any mutation.storage_bridge::storage_to_runtimenow rehydrates a persisted("observe", NULL expiry)row fail-closed toSome(Enforce)(notNone— under personal-observe,Noneitself resolves toObserve, so falling through toNonewould silently reproduce the exact outcome this guard exists to prevent) and logs a warning naming the agent.
§5: Audit durability fix
LocalDurablePaths::resolve() roots the registry DB, the audit JSONL
directory, and the audit/retention SQLite file under durable paths
(~/.aasm/local.db, aa_gateway::server::default_audit_dir(),
~/.aasm/audit.db respectively) instead of a per-process temp directory,
and local_hardened_at resumes the hash chain from the last persisted entry
(AuditWriter::read_last_hash / read_last_seq) instead of reseeding to
zero. The audit JSONL directory is deliberately unified with
aa-gateway’s own default_audit_dir(): AuditReader globs every
*.jsonl file in its directory, so this is one governance trail per host,
not two that could silently fork — the same reasoning AAASM-5959 already
established for the gateway’s own sink. hermetic_temp() preserves the
pre-ticket temp-path behaviour for tests.
§6: Boot-refusal gate
Two layers, described in full on the
personal-observe documentation page:
Layer 1 (GatewayConfig::validate()) is advisory only — three of six
non-test GatewayConfig::load() call sites in this codebase discard its
error. Layer 2 (aa_core::observation::authorize_personal_observe) is
authoritative: it re-derives every config-shaped signal directly and adds
nine runtime/env signals Layer 1 cannot see, and is the only place that can
mint a PersonalObserveGrant.
aa-gateway refuses unconditionally when the key is set — it does not
implement personal-observe (tracked as gap G5) — so an operator can never be
left believing the key applies there while a co-located aa-gateway
continues to enforce under Standard.
§2.6: Explicitly out of scope
aa-core/src/integration/plan.rs’s ObserveOnly is a different axis — the
devtool-integration plan’s own mode, not the gateway’s CheckAction
decision path — and is untouched by this work. PolicyDocument.enforcement_mode
remains dead on the hot path; wiring it is explicitly deferred (gap G4) and
must not happen without routing through this same gate. SHADOW_MAX_HOURS,
resolve_enforcement_transition, and shadow_expiry_watcher’s own
behaviour are unchanged.
Consequences
- Every existing deployment is a no-op:
ObservationProfile::default()isStandard, andGatewayConfig::default()’s behaviour is bit-for-bit unchanged. local_hardened_at’s signature changes (registry_db_path: PathBuf→paths: LocalDurablePaths), with four call sites updated. Chosen over an env var so the durable path is explicit and testable, and because the audit-durability fix needed a place to carry three related paths together rather than three new individual parameters.resolve_enforcement_mode/transform_for_observe_mode’s public signatures inaa-gateway’s engine API change. Chosen because the founder asked for a structural, not test-level, invariant — nothing weaker (a runtime assertion, a lint, a code-review checklist item) gives that.- The detector is best-effort. It buys a cheap block on obvious enterprise
coupling at the cost of a false sense of coverage if the wording
discipline in
docs/src/security/personal-observe-profile.md#wording-disciplineerodes — mitigated by an automated wording test (N4) in addition to review, and by the always-visible known-gaps table (G1-G7). - aa-api’s local audit reads widen to include gateway-written entries on the same host. Chosen over a second, forkable trail.
Alternatives considered
- A single bare
bool“personal mode” flag onGatewayConfig. Rejected: it would not have forced the two-layer gate or the newtype anti-bypass mechanism, and a boolean invites exactly the “future code path added carelessly” this ADR’s §4 mechanism exists to prevent. - Wiring
PolicyDocument.enforcement_modeas the mechanism for personal-observe instead of a newObservationConfigsection. Rejected: it is dead on the hot path today, both production callers hardcode a default, and wiring it without the same gate would create a second, ungatedObservesource (gap G4) — a strictly worse outcome than adding one new, gated config section. - Implementing personal-observe in
aa-gatewaytoo. Deferred (gap G5): the local single-process entrypoint is where personal, unmanaged use actually happens;aa-gatewayrefusing outright is safer than an under-scoped implementation shipped to satisfy symmetry.
Last updated: 2026-09-27 by Chisanan232