Phase AI target — not yet implemented: the daemon API becomes REST over HTTP: Unix HTTP/UDS or loopback TCP, Windows loopback TCP, and HTTPS/TCP for remote peers. ADR-032 through ADR-038 and
REQ-CORE-TRANSPORT-*govern that migration. The current implementation remains the custom transport contract documented by the current boundary manifests.
The current target architecture keeps the ATM CLI surface, but moves durable mail and roster ownership to SQLite and reintroduces one tightly-bounded singleton daemon runtime for routing, notification, transport, and runtime health/state queries.
The current merged workspace contains:
atm-core: reusable service libraryatm: CLI binary
The daemon/runtime expansion adds:
atm-daemon: daemon runtime binary / transport hostatm-runtime: concrete runtime/store composition rootatm-storage-rusqlite: first concrete SQLite store implementation
The CLI stays thin. Product logic moves into atm-core.
The retained command surface is:
sendlistreadackclearlogdoctorteamsmembers
Approved additive CLI feature for the Phase Y line:
help- the
helpaddition stays on the CLI conceptual-help surface only; it does not reopen mailbox-truth or boundary-ownership work insideY.1
ATM has two distinct documentation audiences:
- repo/developer documentation under
docs/ - installed end-user documentation sourced from
docs/user-documents/
The installed user-doc surface is part of the product architecture:
- the repo-owned source tree is
docs/user-documents/ - packaging copies that tree into
<install-root>/share/doc/atm/ - the installed primary entrypoint is
<install-root>/share/doc/atm/README.md - the default local install root is
~/.local/atm/<version>/ - installed-doc lookup is executable-relative from
<install-root>/bin/atmas../share/doc/atm/ - runtime state under
~/.atm/remains separate and must not be presented as the installed document tree ATM_HOMEremains the runtime/data root only and must not be used as the installed-doc locator- long-form operator guidance lives in installed markdown, not in new help-only commands
atm helpremains the concise CLI-owned conceptual-help layer that points users toward the installed corpus- installed user docs must survive the copy step unchanged, so inter-document links are relative and validated mechanically
- fenced
json,xml,toml, andbashexamples in the installed corpus are release artifacts and must be validated before publish - one canonical verifier should validate both the repo-owned source tree and the staged/installed copy
Documentation structure is governed by
documentation-guidelines.md.
This file owns product architecture. Crate-local architectural detail is being moved into:
docs/atm/architecture.mddocs/atm-core/architecture.mddocs/atm-daemon/architecture.mddocs/atm-runtime/architecture.mddocs/atm-rusqlite/architecture.md
site/ is the repository's durable static verification-report publish root.
just reports-index runs .just/generate_report_index.py to discover
schema-versioned report envelopes, validate their relative HTML/evidence
paths, and generate site/reports/index.html. AI.47 owns generation of
site/index.html, which links to that reports index. The generator owns report
discovery: benchmark envelopes aggregate to one report; fuzz envelopes produce
one report entry per campaign. Report producers invoke it after every artifact
write, and just reports-index --check rejects stale or invalid report
indexes. Under ADR-044, site/ is public data: envelopes carry only an opaque
host_label, never raw host or endpoint data.
.just/build_view_site.py may expose links only; artifacts/view is transient
and never a second report renderer.
Phase-Q supersession note:
- earlier daemon-free architecture statements in this file are historical from the prior rewrite line
- for the current mail/runtime target architecture, Section 21 is authoritative
Phase-R redesign note:
- the abandoned early SQLite/daemon line is not the architectural baseline for forward work
- the Phase R redesign starts from crate-local boundary inventories and ADRs, then rebuilds the implementation under lint/visibility guardrails
- Phase R treats thin-client extension pressure as a first-class architectural
input (
atm-graftwas implemented in T.6–T.8, then completed and narrowed through Phase U as the supported thin-client host line rather than removed) - for the boundary / adapter model, Phase R supersedes any earlier
pre-Phase-R architecture statements in this document that conflict with the
crate-local boundary inventories, ADRs, or
docs/plans/phase-R/plan-phase-R.md
Phase-S portability note:
- the Phase R integrated daemon proved the runtime split, but it still hard- coded Unix-only assumptions into the same-host host/runtime shell
- Phase S is the active planning line for making the daemon feature-complete on Windows as well as Unix-like hosts
- feature parity across supported operating systems is mandatory; platform- specific implementation differences are allowed only behind documented ATM- owned portability boundaries
- the legacy frame protocol is deleted and has no fallback contract
- Phase AI's target HTTP resources and schemas are owned by
docs/atm-daemon/http-api.md - S.5 planning adds
atm listas a distinct CLI query surface; S.7 owns the implementation line that refines the queue-query packet mapping instead of preserving the old multi-messagereadresponse shape as the final contract - Phase S planning is tracked in
docs/plans/phase-S/plan-phase-S.md
The Herdr local-steer client is documented in
docs/atm-herdr/architecture.md, especially
section 12's Phase AY compatibility and platform contract. Its normative
requirements, including the cross-platform command/error/lifecycle contract,
are in docs/atm-herdr/requirements.md.
The Phase AQ2-6 Windows deferral is superseded: AY.7 owns Windows process
correctness and release-readiness owns live Windows proof, while the supported
Herdr contract remains shared across macOS, Linux, and Windows.
Phase-AA simplification note:
- the current daemon composition root and daemon-routed doctor model are not the intended steady-state architecture
- Phase AA moves concrete SQLite construction into a dedicated
atm-runtimecrate and removes adapter-specific health/observability ownership fromatm-daemon - the daemon remains in the product, but as a thin router rather than a concrete storage/runtime host
- subsystem-specific diagnosis belongs behind subsystem-owned diagnostic traits instead of daemon-local backend-aware helpers
- top-level doctor code may aggregate subsystem reports and daemon-owned runtime state, but must not reimplement backend-specific diagnosis logic
MailStoreandRosterStoreremain the primary storage-neutral capability traits in the simplification line described here- backend-specific implementations such as SQLite-backed and Claude-JSON-backed adapters are allowed to satisfy the approved behavior-named trait family
AA.5relocks the daemon-to-SQLite edge in both the runtime-composition and SQLite boundary records, addscrates/atm-architecture/as the primary code-driven merge gate as the sole second enforcement layer, and treats policy widening as an architecture change rather than routine lint-data churnAA.11narrows the active SQLite runtime baseline to the currentmessage_iddurable schema; abandoned pre-production compatibility shapes such aslegacy_message_idremain historical documentation only and are not part of normal bootstrap/migration behavior- Phase
AClater supersedes this simplification line for storage contracts:MessageStoreandRosterStorebecome the approved shared contract, while task storage is explicitly deferred until a later line starts from canonical Claude-code task schema plus Pydantic validation instead of inheriting any speculative legacy task-store surface;AC.6deletes that speculative contract instead of preserving it as a compatibility line
The post-Q product runtime is implemented by five crates:
atm-coreatmatm-daemonatm-runtimeatm-rusqlite
Product-level boundary rules:
atm-coreowns ATM business logic and the strict I/O boundaries that the current SQLite/daemon architecture routes through a daemon runtime.atmowns CLI parsing, dispatch, rendering, and bootstrap.atm-daemonowns transport adapters, singleton enforcement, live-status runtime state, request routing, and daemon-owned runtime projection.atm-runtimeowns concrete runtime/store composition and storage-neutral doctor/runtime assembly for daemon and direct CLI doctor callers.atm-rusqliteowns the first concrete SQLite implementation of the durable store boundaries.atm-coremust not own clap or terminal-formatting concerns.atmmust not own mailbox, workflow, log-query, or doctor business logic.atm-daemonmust not become a second business-logic crate.atm-runtimemust remain a thin composition crate rather than a second daemon or workflow host.atm-rusqlitemust not absorb workflow or command logic; it implements store contracts only.- crate-local boundary records in
docs/<crate>/boundaries.mdare the machine-readable contract used to drive architectural linting and review - thin-client workflow surfaces should be modeled around
sendandreceiverather than a broad command inventory - Phase T added
atm-graftas a thin-client line (T.6–T.8); Phase U later completed and tightened that supported line rather than deleting it as out-of-scope for the 1.0 surface ackmay remain a retained CLI/user workflow, but thin-client protocol surfaces should carry it through send-shaped request data rather than a separate top-level method family- Phase R may depend on
sc-lintfor boundary/parser gate verification, butsc-lintis an external tool dependency rather than an ATM-owned product subsystem - durable ATM state is one host-scoped SQLite database at
~/.atm/db/mail.db - the daemon is the only ATM writer for that database
- direct read-only SQLite consumers are an allowed integration surface, but ATM-owned command/runtime writes must not bypass the documented daemon/store boundaries
- canonical roster truth is the ATM roster in SQLite; Claude Code
config.jsonis ingress/projection/diagnostic surface only and must not become a second runtime roster-truth dependency - mailbox row provenance/timing convenience fields are not part of the public message contract unless one clear product requirement explicitly keeps them
Lint and tooling boundary rules:
atm-coreowns repository-local lint orchestration throughjust,.just/, andscripts/- reusable static-analysis engines are incubated on
atm-corethrough the embeddedcrates/sc-lint-*workspace members, then migrated to the standalonesc-lintrepository only after the rule semantics stabilize - ATM-specific repository policy checks stay local to
atm-corewhen they depend on ATM role names, ATM-only document schemas, or ATM team-process records - postmortem-linter partition for the current follow-up line is:
- reusable/static rules:
- Unix platform-gating checks
- bare production
Condvar::wait(...)checks - fixed-sleep test-hygiene checks after the current repository-local rule
shape is proven and extracted to
sc-lint config.jsonroster-boundary rules after the repository-local allowlist and false-positive shape is proven onatm-core
- ATM-local rules:
- duplicate semantic string-literal checks in non-test Rust code
- targeted same-host daemon test unbounded-wait checks until or unless the
rule family proves reusable enough for
sc-lint - triage Turtle consistency checks
- staged
config.jsonallowlist gates until the reusable rule semantics stabilize
- reusable/static rules:
Crate-local boundary detail is owned by:
docs/atm-core/architecture.mddocs/atm-core/boundaries.mddocs/atm/architecture.mddocs/atm/boundaries.mddocs/atm-daemon/architecture.mddocs/atm-daemon/boundaries.mddocs/atm-rusqlite/architecture.mddocs/atm-rusqlite/boundaries.md
Historical Phase R boundary direction (retired by Phase AI):
- shared protocol contract:
AtmProtocolinatm-core - outbound transport boundary:
ClientTransport - inbound transport boundary:
ServerTransport - request routing boundary:
RequestDispatcher - receiver-only notification boundary:
MessageReceivedHookEmitter - inbound runtime status boundary:
StatusSource - historical production composition ownership:
atmis the CLI client composition rootatm-daemonis the runtime composition root- a separate composition crate remains out of scope unless an ADR opens it
- Phase AI target boundaries (not yet implemented):
ApiRequest/ApiResponseapplication contractDaemonApiClientfor CLI, graft, and testsApiRouterreached by every HTTP transport adapterPostWriteRouterinvoked after canonical persistence
- Phase AA target ownership:
atmremains the CLI composition rootatm-runtimebecomes the concrete runtime/store composition rootatm-daemonconsumes storage-neutral runtime inputs and stops constructing SQLite-backed adapters directly in production composition- relocked boundary records forbid a direct
atm-daemon -> atm-rusqliteedge; any reintroduction must fail the Rustcrates/atm-architecture/dependency guard (cargo test --package atm-architecture), which is the sole code-driven boundary enforcement layer
Current Phase R lint partition direction:
- extend the existing
sc-portabilityanalyzer for reusable platform-gating rules - extend the existing
sc-boundaryanalyzer for reusable production-liveness rules that need Rust-aware analysis - treat fixed-sleep test hygiene as a reusable lint family whose current
repository-local rule is the proving implementation before
sc-lintextraction - keep ATM duplicate semantic literal policy in the existing repository-local identity lint
- keep targeted same-host daemon unbounded-wait checks as repository-local
lint/CI first, then reevaluate extraction only after the false-positive and
allow-list shape is proven on
atm-core - keep triage-record validation as repository-local lint/CI unless its rule semantics become clearly reusable
- keep
config.jsonroster-boundary allowlist checks as repository-local lint/CI until the rule semantics and approved-caller inventory stabilize
Active postmortem rule families:
- reusable analyzer rules:
- test-scope portability helpers:
PORT-001hardcoded Unix-only absolute paths in test codePORT-002directdirs::home_dir()without configured override checksPORT-003std::env::set_var()in test code
- production portability rules:
PORT-004ungatedstd::os::uniximports in production codePORT-005cfg_attr(not(unix), allow(dead_code))portability suppressorsSCB-RUNTIME-001bare productionCondvar::wait(...)SCB-RUNTIME-002discardedwait_timeout*results in production codeSCB-CONFIG-001production directconfig.jsonroster reads outside the explicit allowlistSCB-CONFIG-002generic runtimeload_team_config(...)helper use from retained command/runtime pathsSCB-CONFIG-003Claude send pre-writeconfig.jsonmembership gatesZ.7keeps the rule family machine-runnable by checking in both the explicit allowlist and a known-bad fixture self-test forjust lint boundaries
- test-scope portability helpers:
- ATM-local repository rules:
- duplicate semantic role-name literals in non-test Rust code
- targeted same-host daemon test unbounded-wait checks
- triage Turtle aggregate/branch consistency checks
The 1.0 retained-surface release is a source-repo replacement of the old
agent-team-mail CLI/core publication path, not a new public package family.
Architectural rules:
- this repo becomes the source of truth for publishing:
agent-team-mailagent-team-mail-core
- this repo does not publish its retained CLI/core release under the crate
names
atmoratm-core - crate identity continuity for downstream users is preserved by package-name
replacement while keeping the CLI binary name
atm - historical parity channels remain:
- crates.io
- GitHub Releases
- Homebrew
wingetis not part of historical parity, but it is required in the new release architecture because Windows installation must be first-class for1.0without Rust tooling or manual archive extraction
Release-process ownership rules:
- release automation is repo-owned infrastructure, not ad hoc operator procedure
- the new repo must own:
- release artifact manifest
- preflight workflow
- release workflow
- release-gate script/helpers
- release inventory generation and verification
- Homebrew formula update automation
wingetmanifest/update automation and verification
- the
publisheragent instructions are part of the release-control surface and must be ported into this repo with source-of-truth paths updated to the new repo layout and retained crate list
Release infrastructure notes:
- Homebrew continues to use the shared
randlee/homebrew-taprepository and existingFormula/agent-team-mail.rb/Formula/atm.rbformulas HOMEBREW_TAP_TOKENis a required secret for theatm-corerepo before the ported Homebrew update automation can run successfullywingetuses the samerandleepublisher namespace proven inclaude-history; the retained CLI package ID for this repo israndlee.agent-team-mail- the ported
wingetflow requires a dedicatedWINGET_GITHUB_TOKENrepo secret because the default workflow token cannot create branches / PRs against therandlee/winget-pkgsfork - the release workflow should use
vedantmgoyal2009/winget-releaser@v2against the Windows ZIP release asset and its SHA256 rather than inventing repo-specific manifest plumbing first - the initial
wingetmanifest submission is a one-time manual bootstrap action; recurring releases are workflow-driven after the package exists inmicrosoft/winget-pkgs - release verification must treat
wingetsubmission success and manifest generation as the immediate release signal because Microsoft review normally delays public installability by 1-2 days
Schema ownership references:
- Claude Code-native message schema:
claude-code-message-schema.md - ATM additive/interpreted message schema:
atm-message-schema.md - legacy ATM read-compatibility schema:
legacy-atm-message-schema.md sc-observabilityschema ownership pointer:sc-observability-schema.md- ATM-owned error-code registry:
atm-error-codes.md - schema enforcement models:
tools/schema_models/claude_code_message_schema.pyandtools/schema_models/atm_message_schema.pyandtools/schema_models/legacy_atm_message_schema.py
atm-core must not import sc-observability directly.
Instead, atm-core defines a sealed ObservabilityPort boundary plus ATM-owned event and query models. atm implements that port using sc-observability.
ATM still owns:
- ATM-specific event naming
- ATM-specific structured fields
- mapping CLI filters to shared query/follow APIs
- ATM doctor projections over shared health models
- the host-scoped retained-log root contract, including
ATM_LOG_DIRas the exact retained-log-directory override - ATM-owned config semantics for baseline roster, alias resolution, and runtime-identity precedence
sc-observability should own as much generic functionality as possible:
- emission
- record storage and retention policy
- historical query
- follow/tail
- severity filtering
- structured field filtering
- runtime health reporting
Phase K delivered the ATM-side integration work. Phase L now governs the remaining release-hardening, boundary cleanup, and validation needed before initial release.
Initial retained-command integration scope:
sc-observability-typessc-observability
Deferred from the initial retained-command integration scope:
sc-observesc-observability-otlp
Phase W typed observability migration note:
DaemonSubsystemand the typedemit_subsystem_event(...)boundary are complete on the current line.- The remaining migration from raw
&'static strlabels to validatedActionName/OutcomeLabelvalues atDaemonEventfields,SubsystemLoggerhelpers, andSubsystemObservability::event()call sites is intentionally deferred pending upstreamsc-observability-typessupport for a validated static-construction helper such asvalidated_static!orconst new_static(). - The deferred scope is still tracked architecture work across roughly 76 call sites in 10 files; it is not an approved permanent mixed-typing end state.
The controlling ATM-side implementation design is:
Detailed crate/module layout is owned by the crate-level docs:
Product-level constraints that remain relevant here:
- no plugin framework
- no daemon client
- no runtime spawning layer
- no separate
tailcommand in the initial rewrite - no separate
statuscommand in the initial rewrite - the retained release-critical team recovery surface is limited to:
teamsmembersteams add-memberteams backupteams restore
- broader historical team lifecycle/orchestration commands remain out of scope
Supersession note:
no daemon clientandno runtime spawning layerdescribe the pre-Phase-Q retained CLI/runtime line only- the current SQLite/daemon architecture in §21 supersedes those constraints with:
- one explicit daemon runtime
- no hidden direct SQLite fallback
- one explicit daemon auto-start path when the daemon is absent
Per rust-best-practices, validated primitives and semantic ids should not remain as raw String values across the service boundary.
Required public newtypes:
TeamNameAgentNameIdentityNameMessageKeyMessageIdMessageBodyMessageSummaryIsoTimestampMailAddressTaskId
Required resource/config wrappers:
ConnectionCapQueueDepthRetryBudgetBusyTimeoutRequestDeadlineHomeDirAbsolutePathLogFieldKeyLogFieldValue
These are required to reduce repeated validation and remove stringly typed command paths.
Canonical axis enums:
pub enum ReadState {
Unread,
Read,
}
pub enum AckState {
NoAckRequired,
PendingAck,
Acknowledged,
}
pub enum MessageClass {
Unread,
PendingAck,
Acknowledged,
Read,
}Display bucket enum:
pub enum DisplayBucket {
Unread,
PendingAck,
History,
}Selection enum:
pub enum ReadSelection {
Actionable,
UnreadOnly,
PendingAckOnly,
ActionableWithHistory,
All,
}Ack requirement state:
pub enum AckRequirementState {
NotRequired,
RequiredPending,
RequiredAcknowledged,
}Display mapping is fixed:
MessageClass::Unread->DisplayBucket::UnreadMessageClass::PendingAck->DisplayBucket::PendingAckMessageClass::Acknowledged->DisplayBucket::History- displaying a message may mark it read, but it must never promote pending acknowledgement; ADR-022 keeps ack state sender-owned and durable
MessageClass::Read->DisplayBucket::History
Per rust-best-practices, legal workflow transitions should be encoded in the type system inside the core pipeline.
Private marker states:
pub struct UnreadReadState;
pub struct ReadReadState;
pub struct NoAckState;
pub struct PendingAckState;
pub struct AcknowledgedAckState;
pub struct StoredMessage<R, A> {
// persisted fields + read-state marker + ack-state marker
}
impl StoredMessage<UnreadReadState, NoAckState> {
pub fn display_without_ack(self) -> StoredMessage<ReadReadState, NoAckState>;
pub fn display_and_require_ack(self, at: IsoTimestamp) -> StoredMessage<ReadReadState, PendingAckState>;
}
impl StoredMessage<UnreadReadState, PendingAckState> {
pub fn mark_read_pending_ack(self) -> StoredMessage<ReadReadState, PendingAckState>;
}
impl StoredMessage<ReadReadState, PendingAckState> {
pub fn acknowledge(self, at: IsoTimestamp) -> StoredMessage<ReadReadState, AcknowledgedAckState>;
}There is no inverse transition on either axis.
The public axis enums and MessageClass are for reporting and filtering. The typestate markers enforce legal transitions inside atm-core.
Log query types should remain generic enough to map onto shared sc-observability APIs.
Required public types:
pub enum LogMode {
Snapshot,
Tail,
}
pub enum LogLevelFilter {
Trace,
Debug,
Info,
Warn,
Error,
}
pub struct LogFieldMatch {
pub key: LogFieldKey,
pub value: LogFieldValue,
}
pub struct LogFieldMap(BTreeMap<LogFieldKey, LogFieldValue>);
pub struct AtmJsonNumber(String);
pub enum LogFieldValue {
Null,
Bool(bool),
String(String),
Number(AtmJsonNumber),
Array(Vec<LogFieldValue>),
Object(LogFieldMap),
}Architectural rules:
LogFieldKeyreplaces raw field-name strings at the public observability boundaryAtmJsonNumberreplaces raw numericserde_jsonvalues at the public observability boundaryLogFieldValueandLogFieldMapreplace rawserde_json::Value/Map<String, Value>inLogFieldMatchandAtmLogRecord- these ATM-owned types must serialize to the same JSON shape the CLI exposes today; the boundary cleanup is a Rust API cleanup, not a CLI wire-format redesign
- conversion to and from raw
serde_jsonvalues remains centralized insideatm-core
CliObservability (atm crate) should expose one structured construction path
for initial release, and CliObservabilityOptions is also owned by the atm
crate:
pub struct CliObservabilityOptions {
pub stderr_logs: bool,
}
impl CliObservability {
pub fn new(home_dir: &Path, options: CliObservabilityOptions) -> Result<Self, AtmError>;
}Architectural rules:
- the top-level
init(stderr_logs)helper may remain as a CLI convenience, but it should delegate toCliObservability::new(...) - dynamic dispatch via
Box<dyn ObservabilityPort + Send + Sync>remains acceptable for initial release - the current sealed-trait pattern remains acceptable for initial release
DoctorCommandinjectability is explicitly deferred unless implementation surfaces a concrete need
ATM must distinguish canonical routing identity from the Claude-facing sender projection.
Architectural rules:
- commands that require caller identity/team resolve them according to the
matrix in
docs/requirements.md§4.1, never from repo-local[atm].identity/[atm].default_team - caller-context-owned commands must resolve required context at the CLI boundary before any daemon dispatch
- daemon-backed caller-owned request DTOs must carry required resolved caller context as request data
- the daemon must execute caller-owned commands against declared request
caller context only and must never substitute daemon ambient
ATM_IDENTITY/ATM_TEAM atm doctoris diagnostic and remains outside the mandatory caller-context path- ATM-owned aliases are input shorthands that resolve to canonical member names
- same-team messages keep current canonical sender projection behavior
- cross-team messages may project an alias-friendly sender in the persisted
fromfield for Claude-facing ergonomics - whenever cross-team alias projection is used, ATM must also persist canonical sender identity in SQLite-owned state
- self-send checks, target validation, routing, and audit logic must use the
canonical sender identity rather than the display-oriented
fromprojection - the shared send-context path rejects canonical same-team self-addressed sends
only when their destination has no host, before message-body persistence and
before
dry-runcan report success; a host-qualified destination proceeds to ordinary host routing without a locality lookup - ATM-owned post-send hooks are best-effort recipient-scoped helpers, not part of the atomic send boundary
- the hook runs only after a successful non-
dry-runsend or ack; it fires after bothatm sendandatm ack - Current runtime addition: the retained hook contract now includes
atm ackreply writes as hook-producing outbound messages, not onlyatm send - each
[[atm.post_send_hooks]]rule binds one recipient selector and one command argv recipient = "*"acts as a wildcard match for all recipients- multiple matching rules all execute, in config order
- relative post-send-hook paths resolve from the discovered
.atm.tomldirectory and execute with that same directory as the working directory - bare executable names use normal
PATHlookup - the hook receives inherited environment plus one ATM-owned JSON payload in
ATM_POST_SEND - the payload includes
from,to,sender,recipient,team,message_id,requires_ack,is_ack(bool), and optionaltask_id - Current runtime addition:
is_ackis the explicit send-vs-ack discriminator for daemon-owned hook evaluation and downstream nudge logic - the hook may optionally emit one structured result object on stdout with a declared log level, message, and optional structured fields; ATM parses it on a best-effort basis for post-send diagnostics
- absent or invalid hook-result stdout is ignored rather than treated as hook failure
- recipient non-match is silent
- retired flat hook keys and
[atm].post_send_hook_membersare configuration errors, not compatibility aliases - hook execution is the direct post-persist emission seam; the accepted
runtime must not route post-send behavior through
NotificationSink,DeliveryPlan, or Claude mailbox compatibility machinery - hook-decision logging must preserve sender, recipient, matched rule selector, and final execution outcome for troubleshooting
- hook failure or timeout never rolls back a successful send
The rewrite reuses the existing team config schema where feasible.
Only a small subset is required by the retained surface:
- member roster
- enough member metadata to preserve round-trips when present
- bridge remote host configuration needed for origin-file merge when present
ATM config and team-launch config are distinct concerns:
- ATM-owned config uses the
[atm]section of.atm.toml - launcher-owned sections such as
[rmux]and future[scmux]remain outside theatm-coreruntime config boundary and are ignored by ATM [atm].default_teamremains a config/bootstrap default only for flows that explicitly consume ATM config defaults; it is not a runtime caller-team fallback for commands governed by the caller-context matrix[atm].team_membersis the ATM-owned baseline roster for doctor/orchestration checks[atm].aliasesis the ATM-owned shorthand map for canonical agent names[[atm.post_send_hooks]]is the ATM-owned best-effort post-send automation surface- retired flat hook keys and
[atm].post_send_hook_membersmust fail fast with migration guidance [atm].identityand the legacy top-levelidentitykey are obsolete in the retained multi-agent model and must not participate in runtime identity resolution
Team config loading must follow a narrow-scope recovery policy:
- compatibility-only schema drift may use deterministic defaults at the schema boundary
- malformed member records should be isolated at member scope only when the remaining roster is still trustworthy
- missing
config.jsonis a distinctmissing-documentcondition, not a parse error - root-document corruption or invalid root structure remains a command error
- identity and routing fields must never be guessed to keep commands running
Diagnostics for team config failures must preserve:
- failure class when known
- file path
- member or collection scope when known
- parser line and column when available
- original parser cause for operator repair
[atm].identity and the legacy top-level identity key remain
parse-compatible only as obsolete migration fields. They are no longer part of
runtime sender or actor resolution.
Current runtime contract:
- caller-context-owned commands resolve required caller identity/team according
to the matrix in
docs/requirements.md§4.1 - if required caller context is unavailable, the CLI fails before daemon dispatch
atm doctorremains the explicit identity-free, optional-team exception[atm].identityand legacy top-levelidentityare ignored for runtime resolution even when still present in.atm.toml
Deprecation and migration contract:
atm doctorreports stale config identity fields withATM_WARNING_IDENTITY_DRIFT- operator migration path is: remove
[atm].identityand any legacy top-levelidentitykey, then setATM_IDENTITYin the active agent environment instead - keeping the obsolete key temporarily is tolerated for migration diagnostics only; it must not change runtime behavior
Sample operator-facing repair cases live in
persisted-data-repair.md.
Current persisted inbox superset may contain:
- Claude-native baseline fields:
fromtexttimestampreadsummary- optional producer field
color
- ATM additive compatibility fields:
message_idparentMessageIdthreadModetaskId
- unknown fields
Schema ownership split:
- Claude-native baseline fields are documented in
claude-code-message-schema.md - ATM additive compatibility fields are documented in
atm-message-schema.md
U.3 body projection rule:
- terminal
add-detailscomposes predecessor context into the effective body - terminal
supersedeexposes only the replacement body - crate-local ownership for the exact projection algorithm lives in
atm-core/architecture.md
Architectural rules:
- Claude JSON is a compatibility surface, not ATM-owned durable truth.
- No normal ATM runtime/query path may read machine state from Claude JSON.
- ATM-owned machine state belongs in SQLite-backed state and projections.
metadata.atmis not an approved namespace and must not survive in active compatibility output.- ATM keeps one logical message identity; retained
message_idis the ULID text form of that identity. - if SQLite persists
message_id, it stores that same identity in the retained ULID text form rather than as a second ATM-owned id. - compatibility reads may tolerate established historical top-level additive
fields and
metadata.atmderivatives, but they remain read-compatible inputs rather than the active or forward-write contract - compatibility writes may preserve only the current approved additive surface and must not become the place where new ATM-owned machine state accumulates
- removed compatibility fields such as
source_team,pendingAckAt,acknowledgedAt,acknowledgesMessageId, andexpiresAtmust stay SQLite-only or workflow-only even if older inbox files still contain them.
File-ownership rule:
- the private watcher/import/export boundary is the only approved place that may read or write the shared Claude inbox surface for ATM-owned behavior
- list/read/ack/clear/send runtime correctness must come from SQLite-owned state and boundary-owned projections
Canonical read and ack axes are derived from SQLite-backed persisted state and not serialized separately in Claude JSON.
Supersession note:
- the API shape in this section remains relevant
- the file-append-first ordering details below are compatibility-line behavior for the pre-Phase-Q runtime
- the authoritative current send ordering is defined in §21 as:
SQLite commit -> Claude export / remote daemon handoff
Public entrypoint:
send::send_mail_via_store(request: SendRequest, store: &dyn SendStore, ingress: &dyn SourceIngress, exporter: &dyn ProjectionExport, observability: &dyn ObservabilityPort) -> Result<SendOutcome, AtmError>
Current runtime note:
- Q.2 replaced the earlier
send_mail(request, observability)entrypoint withsend_mail_via_store(...) - the store, ingress, and exporter parameters make the SQLite-first write, ingest-before-export, and projection/export boundaries explicit at the public service seam
SendRequest contains:
- home directory
- current directory
- sender override
- target address input
- team override
- message source
- summary override
- requires-ack flag
- optional task id
- dry-run flag
SendMessageSource variants:
- inline text
- stdin text
- file reference
SendOutcome fields:
| Field | Type | Description |
|---|---|---|
action |
&'static str |
Stable send action marker. |
team |
String |
Resolved target team. |
agent |
String |
Resolved target recipient. |
sender |
String |
Resolved sender identity. |
outcome |
&'static str |
Delivery result such as sent or dry_run. |
message_id |
MessageId |
The one logical ATM message identity rendered in retained ULID text form. |
requires_ack |
bool |
Whether the message requires acknowledgement. |
task_id |
Option<String> |
Optional task identifier persisted on the message. |
summary |
Option<String> |
Generated or caller-supplied summary text. |
message |
Option<String> |
Rendered message body for dry-run output. |
warnings |
Vec<String> |
Actionable degraded-mode warnings surfaced when send succeeds under a permitted fallback condition. |
dry_run |
bool |
Whether the send was executed as a dry run. |
The file-reference path may be rewritten through the file policy layer.
The CLI JSON output mirrors the current contract.
Normal send JSON output includes:
action = "send"teamagentoutcomemessage_idrequires_acktask_idwarningswhen send completed in a degraded but permitted mode
For the retained ATM wire shape, message_id is the shared ULID identifier
used by ATM-authored messages.
Dry-run send JSON output includes:
action = "send"agentteammessage_idmessagedry_run = truerequires_acktask_idwarningswhen dry-run surfaces degraded send conditions
Send ordering rules:
- resolve target address, team existence, and agent membership as one address-resolution stage before mailbox path selection
- enter the atomic append boundary before final inbox mutation
- validate message text inside the atomic append boundary
- generate the one logical message identity inside the atomic append boundary
- perform duplicate suppression and final append inside the same atomic append boundary
- message classification first attempts to parse the persisted
textfield as JSON and treat the message as an idle notification when the parsed object hastype == "idle_notification" - if parsing fails, or
typediffers, the message is classified as a normal message - when a newly appended message is classified as an idle notification, the mailbox append boundary removes any older unread idle notification from the same sender in the same inbox before appending the new record
atm clear --idle-onlyremains manual backlog cleanup, not the primary lifecycle path
Deferred follow-on work:
- read-time auto-purge of displayed idle notifications
- daemon-side idle-notification removal behavior
- classification uses the same text-field JSON detection pattern and treats a
message as a task assignment when the parsed object has
type == "task_assignment" - because the Claude Code schema is fixed, classification must populate
extra["task_id"]andextra["priority"]from the parsed text-field JSON rather than extendingMessageEnvelopewith new top-level fields - final field naming and task-subsystem semantics remain coordinated with the
future
arch-ctasktask subsystem design; seeatm-coreissue#17 - task-assignment extraction remains deferred until the
arch-ctasksubsystem is defined
Missing-team-config fallback is limited to send:
- fallback applies only when
config.jsonis missing and the target inbox already exists - malformed
config.jsonremains a command error - fallback must surface an actionable sender warning
- fallback may send a best-effort repair notice to
team-lead - repair notices must be deduplicated by unresolved condition so repeated sends do not flood inboxes
('queue' here = the mailbox/query surface, unrelated to queue-kind nudges)
Phase S.5 splits queue inspection into two command surfaces:
atm listfinds messages through a bounded metadata queryatm readopens one full message
Target service shape:
list::list_mail(query: ListQuery, observability: &dyn ObservabilityPort) -> Result<ListOutcome, AtmError>read::read_mail(query: ReadQuery, observability: &dyn ObservabilityPort) -> Result<ReadOutcome, AtmError>
Shared query model:
- home directory
- current directory
- actor override
- optional target address
- team override
- sender filter
- timestamp filter
- task filter
- contains filter
- queue-state filters (
unread,pending_ack,all)
ListQuery adds:
- optional limit
ReadQuery adds:
- optional exact
message_id - optional timeout
- read-mutation controls such as seen-state update
ListOutcome contains:
- action
- resolved team
- resolved agent
- messages
- count
- bucket_counts
Each list row contains:
message_idsummaryfromtimestampreadpending_acktask_id
ReadOutcome contains:
- action
- resolved team
- resolved agent
- selected message
selected_message_idmatch_countadditional_match_countmutation_applied- bucket_counts
Read-mutation output invariants:
- when
mutation_applied = trueand a selected message is present, that message andselected_message_idmust identify the same durable message - a read-side transition may later mark the selected message
read = true; the returned payload retains that selected-message identity rather than re-running unread selection and swapping in a different unread message bucket_countsdescribe the reader-lane snapshot. Read-side state-handoff acceptance does not promise durable visibility in that response; consumers use a bounded later list poll when durability matters.- ack-side mutation remains separate; only
atm ackclearspending_ack_atand setsacknowledged_at
Queue-inspection architectural rules:
- default
atm listmust stay bounded by query behavior rather than materializing full mailbox history and truncating it at render time - bare
atm readmust return one most-recent unread actionable message, with pending-ack messages prioritized ahead of non-ack unread messages - selector-driven
atm readmust return the most recent match and report additional matches in metadata rather than returning multiple full bodies - selector-driven
atm listandatm readoperate on logical current messages; successor/update chains are collapsed to their terminal node before result selection or row shaping --task <task-id>selection happens after that terminal-node collapse so one logical task thread does not surface as several superseded matches--containsapplies to both summary text and full durable message body text- summary/count queries must remain separable from full-body detail fetch
- metadata-backed
--containsevaluation must remain summary-first and bounded: rows rejected by earlier metadata-only filters or already matched by summary must not trigger durable-body reload, and only surviving summary-miss candidates may fetch durable body text for the final contains check
Deduplication rule:
- collapse multiple entries with the same non-null
message_idto the most recent entry before bucket selection and output rendering - when timestamps tie, keep the later encountered inbox record
Read rule:
- durable workflow semantics come from SQLite-backed state, not from ATM-owned metadata read back out of Claude JSON
The queue-query services derive MessageClass from (ReadState, AckState) and
apply display-bucket selection to the derived class, not to raw persisted
fields.
For merged inbox surfaces, a displayed message's legal read/seen transition is offered to the supervised non-blocking handoff for the authoritative ATM store. The merged view is a read projection, not a mutation target; retained origin inbox files are compatibility inputs rather than the write destination.
Public entrypoint:
ack::ack_mail<S>(request: AckRequest, store: &S, observability: &dyn ObservabilityPort) -> Result<AckOutcome, AtmError> where S: AckStore
AckRequest contains:
- home directory
- current directory
- actor override
- team override
- source message id
- reply body
AckOutcome contains:
- action
- resolved team
- resolved agent
- source message id
- optional task id from the acknowledged message
- reply disposition
Sent { reply_message_id, reply_target }when a reply message was emittedSuppressedSelfAckwhen a historical self-addressed pending-ack was acknowledged without emitting a replacement reply
- reply text
- warnings: Vec
- Current runtime addition:
warningscarries best-effort post-send-hook diagnostics foratm ackwithout changing the successful acknowledgement state
The ack service is responsible for the legal transition from (Read, PendingAck) to (Read, Acknowledged) plus the reply append.
Phase R continuation rules:
atm ackemits exactly one visible reply and that reply must hardcoderequires_ack = false- historical self-addressed pending-ack messages are the explicit exception:
they terminate at
(Read, Acknowledged)withAckReplyDisposition::SuppressedSelfAckand no replacement reply message - acknowledgement replies must never request acknowledgement themselves
- compatibility/export surfaces encode successor metadata with
parentMessageIdandthreadMode - message update chains are linear and terminal-node driven:
add-detailsappends contextsupersedereplaces the prior message as the effective current one
- the logical-current projection is mode-aware:
- terminal
add-detailskeeps the terminal id but composes the still-valid predecessor context into the current body - terminal
supersedekeeps only the replacement body
- terminal
- only the original sender may append successors to the chain
- one acknowledgement clears the chain through the current terminal node
- the root message establishes whether the chain is ack-required and successors inherit that ack class
- if a later successor arrives on an already acknowledged ack-required chain, the chain becomes pending again until the new terminal node is acknowledged
- ephemeral messages are standalone, time-bounded rows only:
- they use
expires_at - they are not updatable
- they may not participate in successor chains
- they are cleaned up by periodic expiry sweep rather than first-read deletion
- once read, they hide from normal reads but remain visible through
--view-alluntil expiry
- they use
The current SQLite/daemon architecture supersedes the legacy source-file writeback rule: SQLite is the authoritative durable store for ack state, while inbox/file-surface projection is deferred to the Q.4 export/runtime path.
Public entrypoint:
clear::clear_mail(query: ClearQuery, observability: &dyn ObservabilityPort) -> Result<ClearOutcome, AtmError>
ClearQuery contains:
- home directory
- current directory
- actor override
- optional target address
- team override
- optional age filter
- idle-only flag
- dry-run flag
ClearOutcome contains:
- action
- resolved team
- resolved agent
- removed total
- remaining total
- removal counters by class
Clear eligibility is computed from the two-axis model:
- clearable:
(Read, NoAckRequired)and(Read, Acknowledged) - non-clearable: every other combination
The observability boundary is a sealed ObservabilityPort (or equivalent injected interface) defined in atm-core and implemented in atm.
It is responsible for:
- command lifecycle emission
- log query
- log tail/follow
- observability health projection
The retained boundary must remain ATM-owned and must not leak shared
sc-observability types directly into atm-core public APIs.
atm-core owns the ATM-specific event and query vocabulary needed for ATM’s
messaging workflows, retained-log query/follow, and doctor readiness.
atm owns the concrete sc-observability integration and CLI-facing routing
decisions such as --stderr-logs.
Future hook- or schooks-driven observability orchestration remains out of
scope for the initial ATM release and must not be inferred from this boundary.
Public entrypoints:
ObservabilityPort::query(query: AtmLogQuery) -> Result<AtmLogSnapshot, AtmError>ObservabilityPort::follow(query: AtmLogQuery) -> Result<LogTailSession, AtmError>
ATM CLI surfaces such as atm log snapshot, atm log filter, and atm log tail
consume those boundary methods directly rather than routing through a separate
log::query_logs(...) or log::tail_logs(...) wrapper.
AtmLogQuery contains:
- mode
- level filters
- field matches
- time window
- limit
AtmLogSnapshot contains:
- returned records
- truncation flag when the shared query source truncates results
LogTailSession is an owning stateful object that yields matching records from the shared observability follow API without exposing a public callback trait.
Ordering rules:
- snapshot queries return newest-first records before CLI output limits are rendered
- tail sessions yield records in follow arrival order
ATM must not parse daemon log files directly in this service.
Public entrypoint:
doctor::run_doctor(query: DoctorQuery, observability: &dyn ObservabilityPort) -> Result<DoctorReport, AtmError>
DoctorQuery contains:
- home directory
- current directory
- team override
DoctorReport contains:
- summary
- findings
- recommendations
- environment override visibility
- current team member roster projected from canonical ATM roster truth and
ordered against the live
config.jsonbaseline - observability health
- informational post-send configuration and recipient delivery-path projection with redacted matcher/argv/config-root fields only
- distinct caller-context and daemon-process version/identity visibility for compatibility diagnosis
- aggregate-only subsystem doctor output from:
MailStoreDoctorRosterStoreDoctorConfigDoctor
Current-state caveat:
- the historical task-store doctor surface was removed during
AC.6; future task storage, if approved later, starts from canonical Claude-code schema rather than from a preserved speculative doctor contract
DoctorFinding contains:
- severity
- code
- message
- remediation
The report model should reuse the current doctor command’s severity/finding
structure where useful, but in the current SQLite/daemon architecture it must include
daemon/runtime checks rather than assuming a daemon-free local-only model.
Daemon/CLI orchestration stays aggregate-only: those top-level paths may
compose the MailStoreDoctor, RosterStoreDoctor, and ConfigDoctor reports,
but they must not reimplement backend-specific store investigation logic.
Phase AF compatibility rule:
- following local-IPC connection and before a write-shaped dispatch, clients
perform the ADR-027
CompatibilityPreflight; an incompatible verdict is a typedATM_CLIENT_DAEMON_VERSION_INCOMPATIBLEresponse with no write - this compatibility check composes after ADR-026 host-runtime admission; it cannot select an alternate daemon endpoint, state root, or transport path
Roster output rules:
- show all current
config.jsonmembers in doctor output - show baseline
[atm].team_membersfirst - show
team-leadfirst among the baseline members when present - show extra runtime members after the baseline set
- snapshot
~/.claude/teams/*/inboxes/*.lockat doctor start and end; any lock path present in both snapshots is stale and should surface asATM_WARNING_STALE_MAILBOX_LOCKwith recovery guidance that explicitly marks the lock as a transitional compatibility diagnostic rather than a current-runtime mail-correctness dependency
The retained release-critical local team surface is intentionally narrow.
ATM-owned public entrypoints should cover:
- local team discovery
- local member listing
- local
add-member - local
update-member - local team backup
- local team restore
Architectural rules:
- these services are local file/config/inbox operations; they must not depend on daemon orchestration or runtime spawning
teamslist is discovery-oriented and should remain deterministic over the ATM home directoryadd-memberis the retained local roster-repair path and must reject duplicates before mutating configadd-memberpersists the member's durablehome_diron the canonical ATM roster row and projects that samehome_dirinto compatibilityconfig.json.membersupdate-memberis the retained local roster-metadata repair path for existing members and must not create new members implicitly- accepted terminology must distinguish:
home_dir= durable SQL-backed agent-home directory for the member; for worktree-backed members it preserves the worktree home and the canonical association back to the owning main repolive_cwd= runtime-only working-directory overlay for the invoking ATM member when the active CLI/doctor process can bindATM_IDENTITYto that displayed member; it is not durable roster metadatalaunch_cwd= startup-only current-directory snapshot emitted to ATM CLI startup logs; it is not durable roster metadata
- operator repair paths may repair
home_dirbut must not treatlive_cwdorlaunch_cwdas durable roster metadata - accepted implementations must prefer direct roster-row and runtime-roster fields over new directory-state coordinator structs
backupsnapshots current team config, the ATM-owned.atm-stateworkflow compatibility state, a team-scoped export from the host-scoped SQLite database at~/.atm/db/mail.db, inboxes, and the ATM team task bucket into a timestamped snapshot directory- inbox backup excludes transient mailbox
*.locksentinels, dotfiles, and restore markers restoreis a local recovery path and must:- preserve the current team-lead entry and
leadSessionId - restore only missing non-lead members
- clear runtime-only restored-member state before persistence
- restore the ATM-owned
.atm-stateworkflow compatibility state from the chosen snapshot when present - restore the selected team's durable records into the host-scoped SQLite database from the chosen snapshot
- restore non-lead inboxes from the chosen snapshot
- treat stale mailbox
*.locksentinels as compatibility-only diagnostics; restore must not require sweeping them in order to restore durable ATM state or inbox compatibility files - recompute
.highwatermarkfrom the maximum restored task id - support a dry-run path without making changes
- preserve the current team-lead entry and
- Claude Code project task-list restoration remains separate from the retained ATM team backup/restore surface
The retained members surface is a local roster inspection service.
Architectural rules:
- it must succeed without runtime or hook-only state; when the replacement
runtime is reachable it projects the canonical ephemeral master-roster state,
and when unavailable it falls back to durable roster identity with explicit
Unknown/unavailable enrichment rather than manufacturingDead - it must load the roster from local team config
- it should order members deterministically, with
team-leadfirst when present - it may surface persisted member metadata already present in config
- runtime-sourced session, pid, state, availability, and timestamp enrichment may be layered on without changing the base local verification purpose of the command; the metadata is diagnostic and is never required for roster inspection to succeed; the nudge path reads the canonical state owner, never this display projection
- this daemon-free fallback is the CLI-side half of the runtime-health
observation boundary described in Section 21.6.3:
MembersCommand::runrenders the retained roster even whenruntime_snapshotcannot obtain a runtime response, while any returned observation remains a projection of the master-roster record
Historical note:
- the earlier file-backed/reconcile-fed line is historical only
- the accepted runtime does not use
ingest/reconcile -> SQLite projectionas a live read pipeline - AD.4 removed the remaining daemon watch/reconcile lane from the accepted runtime and retired the corresponding daemon/core boundary traits
The accepted read pipeline stages are:
- resolve caller identity and target mailbox from the accepted CLI/runtime contract
- load durable message state from the authoritative ATM store
- classify read axis, ack axis, and derived message class
- apply sender, timestamp, selection-mode, and seen-state filters
- sort newest-first and apply limit
- apply legal read/seen mutations for displayed messages
- offer any legal read/seen state changes to the supervised non-blocking handoff without awaiting durable application
- return the reader-lane outcome and handoff-acceptance result
Architectural rules:
- no accepted read path depends on watcher events, reconcile completion, or mailbox-file ingest
- durable ATM state, not merged mailbox-file truth, is authoritative for read
- accepted handoff does not promise durable visibility in the returned read
outcome; a consumer that requires it uses a bounded later
atm listpoll - any retained mailbox-file compatibility readers are historical or repair-only surfaces and do not redefine the accepted read contract
The ack pipeline stages are:
- resolve actor identity and own inbox
- load the merged inbox surface and locate the source message
- classify the source message into read and ack axes
- require pending acknowledgement before mutation
- resolve the reply target inbox from the source envelope
- atomically apply the ack transition and append the reply
- emit command lifecycle records
- return outcome
This stage list describes the pre-SQLite compatibility line. The current target pipeline is superseded by the SQLite SSOT and daemon-boundary design in Section 21.
The clear pipeline stages are:
- resolve actor identity and target inbox
- load the persisted inbox surface
- classify each message into read axis and ack axis
- compute clear eligibility from the two-axis read and acknowledgement model
- apply optional age and idle-only filters
- atomically persist the kept set when not in dry-run mode
- emit command lifecycle records
- return outcome
This stage list describes the pre-SQLite compatibility line. The current target pipeline is superseded by the SQLite SSOT and daemon-boundary design in Section 21.
The log pipeline stages are:
- resolve the injected observability port implementation
- map CLI filters into shared query/follow filters
- query or follow records through the observability port
- project ATM-owned record fields for CLI rendering
- return records to the CLI layer
Shared sc-observability should own record storage, filtering, and follow mechanics. ATM should own only ATM-specific query defaults and field projections.
The doctor pipeline stages are:
- resolve config and environment overrides
- resolve optional diagnostic team scope and inspect caller-context visibility
- inspect ATM config for obsolete fields such as
[atm].identity - verify local team/mailbox/config paths
- verify caller-context visibility and invalid override situations without making caller identity/team mandatory
- compare baseline
[atm].team_membersagainstconfig.json.members - verify observability initialization and health
- verify observability query readiness for
atm log - assemble findings, recommendations, and ordered roster output
- render report
Supersession note:
- this section describes the retained mailbox/file-storage line
- the current SQLite/daemon architecture supersedes it with SQLite durable truth and Claude inbox files as compatibility ingress/export only
- any mailbox-lock or file-truth rule in this section is transitional unless restated in §21
The mailbox layer owns:
- tolerant reads
- atomic append
- duplicate suppression
- conflict merge
- origin-inbox merge
- atomic workflow-state updates
- atomic clear-set replacement
- sender-scoped idle-notification dedup inside the atomic append boundary
The mailbox layer does not own selection policy, display buckets, output formatting, log query behavior, or doctor diagnostics.
All inbox modifications use atomic full-rewrite for durability and consistency:
Atomic write pattern:
- Acquire per-inbox file lock before any read
- Read and deserialize the full inbox document (JSON array or JSONL)
- Apply modification in memory (append message, update workflow state, replace clear set)
- Write to a temporary file with fsync to guarantee data durability
- Atomically rename temp file over original (single filesystem operation on POSIX; platform-equivalent on Windows)
- Release lock after rename completes
This pattern ensures:
- crash-safety: partial writes never corrupt the original file
- consistency: concurrent ATM processes never lose updates due to race conditions
- idempotency: replay of the same operation twice (e.g., after daemon restart) produces the same state
The lock is held from step 1 through step 5 to prevent concurrent read-modify-write races. Full-rewrite applies to all inbox operations: append_message, read-state writeback, ack transition, and clear set replacement.
Repair/rebuild is reserved for malformed mailbox state. Normal healthy mailbox operations never trigger repair.
When an inbox file is encountered:
Claude mailbox JSON is historical only in the accepted runtime.
Architectural rule:
- retained send/read/ack behavior must not depend on current Claude inbox JSON arrays or JSONL mailbox exports
- if historical compatibility readers remain temporarily during deletion work, they are not the governing runtime path and must not influence live send/read semantics
Repair guidance for operators is documented separately in persisted-data-repair.md.
When ATM_POST_SEND is set for a configured post-send hook, the payload must
contain:
senderrecipientteamfrommessage_iddescriptiontask_idas a string; it may be empty when no task is associatedrequires_ackis_ack- optional
to - optional
recipient_pane_idwhen ATM already knows the authoritative pane mapping for the recipient
The post-send hook is the steer-nudge path: it runs only after a successful
outbound mailbox write from atm send or atm ack — persist, then emit the
steer nudge; queue-kind nudges defer emission until harness readiness
(ADR-054), and neither kind ever precedes persistence. It executes once when
recipient matching succeeds, uses is_ack = false for atm send and
is_ack = true for atm ack, may optionally emit one structured stdout
result for observability, and never rolls back a successful message write on
failure or timeout.
Hook configuration lookup note:
- send/ack must resolve post-send hook configuration from the sender's
authoritative ATM roster
home_dirmetadata
Current runtime hook-note:
- once roster and pane mapping truth move to SQLite, the send path should place
the authoritative recipient pane id into
ATM_POST_SEND.recipient_pane_id - post-send hook implementations should prefer that payload field over local file rediscovery when it is present
- external hook commands consume
ATM_POST_SEND - any retained built-in
atm internal-nudgehelper consumes one separateATM_INTERNAL_NUDGEenvelope carrying the canonical event, sink target, resolved template kind, and resolved template body or explicit disabled state; the live production built-in path remains in-process - retained compatibility helpers must treat committed
.atm.tomlpane ids as non-authoritative and use roster/payload pane truth or explicit--paneonly
Supported structured hook-result levels remain:
debuginfowarnerror
Caller-owned command context is not guessed.
The authoritative command-by-command caller-context matrix lives in
docs/requirements.md §4.1.
The accepted command contract is:
- commands that require caller identity resolve it from explicit override when
supported, otherwise from invoking-shell
ATM_IDENTITY - commands that require caller team resolve it from explicit override when
supported, otherwise from invoking-shell
ATM_TEAM atm peekandatm listare inspection-only mailbox/message surfaces and may inspect another member only through the documented--asoverride pathatm send,atm read,atm ack, andatm clearare owner-only mutating surfaces and must not expose caller impersonation- if required caller context is unavailable, the CLI fails before daemon dispatch or retained command execution
- downstream caller-owned request DTOs carry required resolved caller context as request data
- the daemon never treats hook files, repo-local config, roster state, or
daemon ambient
ATM_IDENTITY/ATM_TEAMas fallback caller context atm doctoris the explicit exception and may run without caller identity or caller team while still honoring optional--teamdiagnostic scoping
An obsolete [atm].identity field may be diagnosed by doctor, but it must not
control sender/actor resolution.
The accepted mailbox split is explicit:
atm peekinspects one selected message without mutating mailbox stateatm listinspects queue metadata without mutating mailbox stateatm readis the owner-only mutating detail view- mailbox inspection paths must not change read, seen, or acknowledgement state
The current send --file behavior is retained:
- inspect Claude settings permissions when available
- if the referenced file is allowed, send a direct file reference
- otherwise copy to ATM share storage and rewrite the message body accordingly
atm-core::observability defines ATM event/query models plus the sealed ObservabilityPort boundary.
atm provides the concrete sc-observability implementation and injects it into core services.
Initialization:
atminitializes logging once at process startupatmconstructs the concrete observability port after startup initialization- logging failures degrade to best-effort behavior for explicit mail commands
Required ATM event classes:
- command start
- command success
- command failure
- mailbox record skipped
Required ATM event fields:
- command
- team
- actor
- target
- task id
- outcome
- error class when applicable
- stable error code when applicable
- message count when applicable
- transition count when applicable
For explicit observability consumer commands:
atm logdepends on shared query/follow APIsatm doctordepends on shared health APIs- failures in those consumer paths are command errors, not silently dropped events
The retained implementation uses an ATM-owned emit/query/follow/health boundary that projects shared observability behavior into ATM-owned types:
- ATM-owned
AtmLogQuery - ATM-owned
AtmLogRecord - ATM-owned
AtmLogSnapshot - ATM-owned
AtmObservabilityHealth - an ATM-owned synchronous
LogTailSession
Required boundary responsibilities:
ObservabilityPort::emit(...)ObservabilityPort::query(...)ObservabilityPort::follow(...)ObservabilityPort::health(...)
The exact ATM-owned projected types and object-safe follow-session split are defined in:
Initial-release boundary rulings:
- this boundary is intentionally ATM-local; it does not attempt to model future
hook-driven or
schooks-orchestrated observability concerns - the health contract remains intentionally closed at:
HealthyDegradedUnavailable
- public ATM observability projections must not expose raw
serde_json::Value/Map<String, Value>directly - the concrete
sc-observabilityadapter is queue-backed as of PhaseAA.6; ATM usesLogger::log()for blocking admission, treatsflush()/shutdown()as the only durability barriers, and projects queue/writer/ maintenance state through ATM-owned health detail rather than leaking raw shared types across the public boundary
Implementation rules:
atm-coreremains concrete-crate-neutral and consumes only the injected boundaryatminitializes the shared logger exactly once per process- the shared file sink is the authoritative retained log store for
atm log - the default ATM-owned retained log file is in the host-scoped retained-log
root governed by ADR-011; it is not selected by workspace
ATM_HOME ATM_LOG_DIRoverrides the exact retained log directory- without
ATM_LOG_DIR, the retained log path is derived from that host-scoped retained-log root - under planned ADR-026, the invocation directory and
ATM_HOMEare not daemon/socket/lock/database selectors; the OS-userHostRuntimeScopeowns those runtime and durable-state paths, whileATM_HOMEremains only an approved workspace/config discovery input - the shared console sink remains opt-in so it does not contaminate normal command output
- the initial-release dependency is the published crates.io version
sc-observability = "1.0.0" - the default retained logger baseline must include:
- daemon lifecycle
info!events - every subsystem
warn!event - every subsystem
error!event
- daemon lifecycle
- the daemon event-emission hot path must not perform per-event retained file reopen/append/flush work inline; retained JSONL persistence must cross one bounded in-memory queue into a background maintenance worker instead
- the synchronous daemon success path is budgeted for one bounded in-memory handoff only; retained logging must not delay request/lifecycle completion on file reopen, append, flush, rotate, or prune work
- retained-log rotation and pruning may run only on that background maintenance worker, not on the synchronous daemon event-emission path
- retained-log pruning must use a bounded work budget per maintenance tick; it must not rely on an unbounded "scan until wall-clock deadline" strategy
Required diagnostic behavior:
- CLI bootstrap failures must be logged before process exit
- CLI parse/validation failures that occur before a core service runs must be logged before process exit
- retained command-service failures must emit structured failure diagnostics with stable ATM-owned error codes
- degraded recovery warnings that continue the command must also log stable error codes
- command success-only logging is insufficient for the retained architecture
Smoke automation is a repo-owned execution/reporting surface, not an ad hoc operator script bundle.
Ownership and layout:
- operator skill surface:
.claude/skills/smoke-test/
- smoke implementation:
scripts/smoke/
- smoke report templates:
templates/smoke-report/
- smoke report artifacts:
reports/smoke/
- coverage implementation:
scripts/coverage/
- coverage report templates:
templates/coverage-report/
- coverage report artifacts:
reports/coverage/
Command architecture:
just smoke- defaults to the normal smoke lane
just smoke fast- runs the clean-room happy-path lane
just smoke thorough- runs the full CLI/checklist lane
just test coverage- runs coverage reporting only and must remain separate from plain
just test
- runs coverage reporting only and must remain separate from plain
Artifact architecture:
- smoke and coverage each keep tracked latest markdown reports
- smoke and coverage each also write gitignored timestamped artifacts using the same timestamp convention
- smoke execution produces one canonical JSON payload per run and renders the human-readable markdown reports from that payload
- coverage execution produces one canonical JSON payload per host-platform run, renders the matching tracked latest markdown report, and leaves the other tracked platform report unchanged unless only a placeholder exists
- Linux coverage reporting is an explicit deferred/unsupported platform in the current Phase Z line; the coverage runner must fail clearly on Linux instead of emitting misleading tracked-latest artifacts
Logging architecture:
- smoke/debug mode may enable detailed lifecycle/send/read/ack/nudge event visibility so retained-log analysis can prove the happy path explicitly
- routine production logging remains on the normal retained baseline and must not log every ordinary send/read/ack success at default operator verbosity
Current implementation. AtmError is defined in atm-storage as ATM's
sole serializable error contract. The Phase AI HTTP response body uses this
same stable { code, message, cause? } shape; it does not introduce a second
public error model.
Root public error:
pub struct AtmError {
code: AtmErrorCode,
message: String,
cause: Option<String>,
}pub enum AtmErrorCode {
// single central registry re-exported from atm-storage
}Required families:
- config
- missing document
- address
- identity
- team not found
- agent not found
- store
- mailbox read
- mailbox write
- file policy
- validation
- serialization
- timeout
- observability emit
- observability query
- observability health
Every public error must include:
- a stable ATM-owned error code
- a stable class
- human-readable cause
- recovery guidance when the user can act
The single source of truth for ATM-owned error codes is:
Persisted-data errors should additionally carry file/entity/parser context so CLI surfaces can report the exact failing document and scope.
Current runtime error-model rules:
AtmErrorCodemust not use wildcard or catch-all variants where a more specific code can be named- every documented
AtmErrorCodemust carry one recoverability classification in the central registry so CLI, daemon, and doctor surfaces can reason about retry vs operator-action vs fail-closed behavior - pattern matches over
AtmErrorCodeat module/crate boundary surfaces must be exhaustive; wildcard_match arms are not permitted
The initial rewrite should avoid public extension traits.
If a trait becomes necessary:
- prefer a sealed trait
- verify object safety before stabilization
Current runtime boundary rule:
- all I/O-owning boundary traits are sealed by default
- opening a boundary for external implementation requires explicit design review and crate-level documentation of the exception
atm-core tests:
- address parsing
- config precedence
- tolerant team-config parsing for compatibility-only schema drift
- precise persisted-data diagnostics for non-recoverable config failures
- bridge hostname resolution for merged inbox reads
- settings resolution
- caller identity precedence and missing-identity rejection
- file policy behavior
- team membership validation
- tolerant inbox parsing
- origin-inbox merge
- atomic append behavior
- duplicate suppression
- read-time duplicate collapse by
message_id - workflow axis classification
- workflow axis transitions
- task-linked classification (never ack-required, ADR-062)
- seen-state behavior
- timeout behavior
- ack transition behavior
- clear eligibility behavior
- pending-ack clear override behavior
- observability port emission behavior
- observability port query/filter behavior
- observability port failure behavior
- doctor health projection behavior
atm tests:
- clap parsing
- JSON output shape
- human-readable output snapshots
- send/read/ack/clear integration behavior
atm logintegration behavioratm doctorintegration behavioratm teamsintegration behavioratm membersintegration behavior
append_message in mailbox/mod.rs:23-27 performs an unlocked read-modify-write:
read_messages(path)— reads and deserializes the full inboxmessages.push(envelope)— appends the new record in memoryatomic::write_messages(path, &messages)— writes to temp file, fsyncs, renames over original
Step 3 is atomic with respect to partial writes but not concurrent callers. Two concurrent callers can both complete step 1 before either reaches step 3; the later rename silently overwrites the earlier, losing its appended message. The same race affects read writeback, ack transition, and clear set replacement.
Decision: Use the fs2 crate.
Rationale:
fs2providesFileExt::lock_exclusive()andFileExt::try_lock_exclusive()which map toflock(2)on Unix andLockFileExon Windows- 98M+ downloads, maintained, compatible with the project's MSRV
- avoids maintaining separate
cfg(unix)/cfg(windows)implementations - the current
atm-coreCargo.toml already carrieslibcandwindows-sys, but only as low-level building blocks, not as a cross-platform mailbox-locking API
Alternative rejected: direct libc::flock + windows-sys::LockFileEx — more control but
duplicates what fs2 already provides correctly.
+-----------------------+
| MailboxLockGuard |
| (RAII, Drop releases) |
+----------+------------+
|
+----------v------------+
| lock.rs::acquire() |
| open/create sentinel |
| fs2::try_lock_excl() |
+----------+------------+
|
+-------------------+-------------------+
| |
Unix: flock(fd, LOCK_EX) Windows: LockFileEx(handle)
- Sentinel:
{inbox_path}.lock— pid-bearing runtime artifact, created lazily, removed onMailboxLockGuarddrop, and best-effort evicted when the recorded pid is no longer alive - Granularity: per-inbox-file — concurrent sends to different recipients never contend
- Lock lifetime: acquired before
read_messages, held throughatomic::write_messagesdurability boundary (temp-file write, rename, and any parent-directory sync), then the sentinel is unlinked and the guard is released - Timeout: bounded retry loop with
try_lock_exclusive()+ 50ms sleep, default 5s; on expiry returnsAtmError { code: MailboxLockTimeout } - Error classification: only genuine "lock busy" results participate in the
retry loop. Non-contention I/O and OS failures from the lock path fail fast as
MailboxLockFailedwith filesystem/permissions recovery guidance instead of being collapsed into a timeout. - Cooperative limitation:
fs2locks are advisory and only coordinate ATM processes that participate in the same locking protocol. Direct file edits or other tools that bypass ATM locking are outside the protection boundary. This is an accepted limitation for the ATM shared-inbox model.
The current path.extension() == "lock" filter is too narrow because it misses
rotated sentinels such as inbox.json.lock.old. The executed P.10 design must
match only filenames that still carry the sentinel suffix chain:
let is_lock_sentinel_candidate = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".lock") || name.contains(".lock."));Why this exact predicate:
ends_with(".lock")preserves the ordinary live sentinel pathcontains(".lock.")catches rotated forms such as.lock.oldand.lock.replaced- basename-only matching avoids broad false positives from parent directories
- rejecting generic
contains("lock")avoids matching unrelated files such aslocksmith.txt
Eviction remains conservative:
- read the candidate contents as the documented
pid[:token]owner record - if parsing fails, leave the file in place
- if
process_is_alive(pid)is true, leave the file in place - only then attempt removal
This is still best-effort cleanup, not a second ownership protocol. The actual
authority boundary remains the later fs2 advisory lock plus the existing
lock_path_matches_file(...) identity recheck after acquisition.
Platform note:
- Windows may not permit renaming a live locked sentinel the same way Unix does, so the broadened sweep is not a live-handoff mechanism
- the predicate exists to clean up crash leftovers, repair leftovers, or
externally rotated sentinel artifacts that otherwise evade the old exact
.lockextension test
P.10 should add a dedicated read-only-filesystem mailbox-lock code instead of overloading the generic non-contention lock failure bucket.
Required platform mapping:
- Linux:
libc::EROFS(30) - macOS:
libc::EROFS(30) - Windows:
windows_sys::Win32::Foundation::ERROR_WRITE_PROTECT(19)
The classification helper belongs at the lock-path error-conversion boundary, not duplicated ad hoc at individual call sites. The intended shape is:
fn is_readonly_filesystem_error(error: &io::Error) -> booland then a shared mapper such as:
fn mailbox_lock_path_error(
operation: &'static str,
lock_path: &Path,
error: io::Error,
) -> AtmErrorCall-graph decisions:
open_lock_file(...)maps read-only failures directly toMailboxLockReadOnlyFilesystemwrite_lock_owner_record(...)maps both truncate and write failures through the same helperremove_lock_sentinel_with_retry(...)explicitly does not retry read-only failures before the current permission-denied/backoff logic- public
sweep_stale_lock_sentinels(...)surfaces the read-only diagnostic to the caller rather than logging and continuing - pre-acquisition stale eviction inside
acquire(...)propagates the read-only diagnostic when the cleanup path hits it, because subsequent owner record writes cannot succeed on the same mount; this early-exit happens before any latertry_lock_exclusive()attempt - each retry iteration must classify raw OS errors before consulting the
timeout budget:
EROFS/ERROR_WRITE_PROTECTexits immediately asMailboxLockReadOnlyFilesystem, while non-contention path failures such asENOSPC,EMFILE, andESTALEexit immediately asMailboxLockFailed MailboxLockGuard::dropstill warns only, because the successful mailbox mutation has already completed andDropcannot change the command result
Recommended recovery text:
- message includes the attempted operation and lock path
- recovery tells the operator to remount or move the ATM home to a writable filesystem before retrying, not merely to wait for another process
Reason for a new code instead of enriching MailboxLockFailed:
- read-only filesystem state is a stable, operator-actionable class with different remediation from ACL failures or transient path I/O
- the retry policy must branch on this distinction
- QA and integration tests need a stable machine-readable contract for it
append_message is a true single-file read-modify-write and should use one shared helper:
pub fn locked_read_modify_write<F>(
path: &Path,
timeout: Duration,
mutate: F,
) -> Result<(), AtmError>
where
F: FnOnce(&mut Vec<MessageEnvelope>) -> Result<(), AtmError>,
{
let _guard = lock::acquire(path, timeout)?;
let mut messages = read_messages(path)?;
mutate(&mut messages)?;
atomic::write_messages(path, &messages)
}That helper is the right shape for:
append_message- the missing-config team-lead notice path, because it also calls
append_message
It is not sufficient by itself for read, ack, and clear, because those
commands call load_source_files(...) and compute a merged surface across the
requested inbox plus any origin inboxes before writing back. To make those paths
concurrency-safe, Phase M needs a second abstraction:
pub fn acquire_many_sorted(
paths: impl IntoIterator<Item = PathBuf>,
timeout: Duration,
) -> Result<Vec<MailboxLockGuard>, AtmError>Required usage:
- discover the full source-file set first
- dedupe paths and sort them deterministically by canonical path string
- source-file discovery must finish before the first inbox read
- legitimately absent inbox paths at discovery time are excluded from the lock set rather than locked speculatively
- source discovery must fail closed for mutation commands: unreadable
read_dir(...)entries or equivalent enumeration faults are treated as source set instability, not as warnings that can be skipped - source discovery faults abort the command before lock acquisition; mutation commands never attempt a partial lock set after a discovery failure
- acquire all locks against one total timeout budget
- if any acquisition fails, drop every earlier lock immediately and abort before any source-file read
- if a discovered file disappears or becomes unreadable after lock planning but
before
load_source_files(...)completes, abort without persisting any partial state; this remains a normal operator-actionable file-read failure, not a partial-lock degraded mode - then call
load_source_files(...) - hold every guard until every source writeback completes
This intentionally preserves a single logical merged-surface decision boundary
for read, ack, and clear. Those commands are not allowed to degrade into
partial-lock best-effort mutation, because doing so would mix snapshots from
different logical times and make writeback correctness nondeterministic.
ack_mail sometimes needs to mutate a source inbox set and append the reply to
another inbox that was not part of the initial actor-source set. The accepted
implementation does not use a subset-lock then upgrade-to-superset sequence.
Instead it uses:
- an unlocked observational snapshot of the actor-source set
- unlocked validation of the pending-ack state and reply inbox path
- one final acquisition of the full sorted superset that includes the reply inbox
- re-discovery of source paths, reload of current source files, and re-validation of the pending-ack state under that final lock set
- persistence of both the updated source message and reply while the superset locks are still held
This avoids the deadlock risk of trying to expand a held subset into a larger
sorted lock set. The unlocked preflight is acceptable only because ack_mail
does not mutate from that preflight snapshot: the shared commit helper reloads
and re-validates both the source-path set and the pending-ack state under the
final superset lock before writing anything. If the state drifted, ack_mail
aborts instead of mutating a stale snapshot.
| Caller | Lock required |
|---|---|
append_message |
locked_read_modify_write |
send missing-config notice append |
append_message coverage |
source discovery fault (read / ack / clear) |
abort before lock acquisition; no partial lock set attempted |
read writeback |
initial selection load is unlocked; acquire the multi-file lock set only for the reload + writeback phase |
ack transition + reply |
unlocked preflight, then one final cooperative superset lock including reply inbox; see §18.4.1 |
clear set replacement |
multi-file lock set held from first read through persist |
read_messages (read-only, no writeback) |
No |
ATM now treats mailbox access as two distinct patterns:
-
Read-only snapshot:
- discover source inbox paths
- load and classify the current merged surface without mailbox locks
- use this for display-only selection and timeout polling
-
Read-modify-write:
- re-acquire the deterministic source lock set only when a command is about to persist mailbox state
- re-discover and re-validate the source path set under lock
- reload the mailbox state, recompute selection, apply transitions, and persist while the lock set is still held
This keeps non-mutating reads out of the lock path while preserving a stable writeback boundary for commands that actually rewrite inbox files.
Executed command mapping:
readuses an unlocked observational snapshot for display selection and timeout polling, then enters the shared lock+reload+recompute path only when display-state mutation is actually requiredackuses an unlocked preflight to resolve the reply target and candidate source message, then acquires one final sorted superset lock and re-validates the pending-ack state under that lock set before writing source/reply state- mutating
clearacquires the shared lock plan before its mutating reread and holds it through removal computation, mailbox replacement, and workflow-state updates;clear --dry-runremains observational only
Phase P completed the mailbox workflow-state migration. P.4 delivered the sidecar move, and the current architecture documents the post-P.5 executed state.
Current executed rule:
- ATM-owned mailbox workflow durability lives in SQLite-backed state.
- mutable per-message mailbox state is owned by the explicit
mail_message_statestable rather than split visibility/ack storage. sendcommits durable message/content state first.read,ack, andclearread and mutate the SQLite-backed state model.- Claude inbox export is a compatibility projection only.
Current executed requirement:
- filesystem workflow sidecars are retired; SQLite is the exclusive mailbox
state authority and legacy
.atm-state/workflowfiles are ignored.
Unified-state ownership notes:
mail_messageskeeps immutable content onlymail_message_statesowns mutable mailbox/runtime state behind themessage_keyforeign-key relationshipexpires_atmoved out ofmail_messagesand now lives only onmail_message_states- deleted-row visibility is admin-only; normal list/read/count queries must
exclude rows with
deleted_at - the earlier split model (
mail_visibility_statesplusack_state) is retired and must not be reintroduced under new names
Phase U removed weak round-trip provenance from the durable mailbox contract.
Current executed rule:
imported_fromis removed fromMailStoreMessageRecorddurable truth and is no longer part of the mailbox-row schemarecorded_atremains SQLite-owned ingest timing inatm-rusqlite, not caller-supplied message data
Governing ADR:
docs/adr/ADR-005-host-scoped-sqlite-state-root.md
MailboxLockFailed/ATM_MAILBOX_LOCK_FAILED— lock-path creation, open, or acquisition failed for a non-contention filesystem or OS reasonMailboxLockReadOnlyFilesystem/ATM_MAILBOX_LOCK_READ_ONLY_FILESYSTEM— the lock path or lock sentinel lives on a read-only filesystem, so ATM cannot create, update, or remove the required mailbox-lock artifactMailboxLockTimeout/ATM_MAILBOX_LOCK_TIMEOUT— lock not acquired within timeout- New
AtmErrorCode::MailboxLockcode in the central registry
Mailbox locking closes the concurrent lost-update race for inbox files, but it is only one part of the persistence contract. Phase M also treats atomic file replacement as a repo-wide rule for shared mutable ATM-owned structured state.
Scope:
- live inbox files
- team
config.json - ATM-owned task-bucket files restored or rewritten by team recovery
.highwatermark- shared persisted coordination/state files such as send-alert or restore-progress markers when they carry ATM-owned operator state
- any future ATM-owned JSON/JSONL/state file rewritten by more than one ATM process or operator workflow
Architectural rule:
- no live shared mutable structured file may be rewritten in place
- writers must use a temp-file + fsync + rename style replacement on the same filesystem, or a documented equivalent with the same atomicity guarantee
- for rename-based replacement, the helper must also fsync the parent directory after the rename whenever the platform supports directory-sync semantics; this is the Phase M crash-durability boundary for mailbox/config/shared-state replacement
atm-coremust own one shared low-level atomic persistence primitive and a small set of typed writer helpers layered on top of it, rather than open-code file replacement logic at individual call sites- existing helpers such as
atomic::write_messages(...)andwrite_team_config(...)are the preferred integration points; new shared state added by Phase M should extend that helper pattern with typed helpers for task-bucket, highwatermark, and shared coordination files instead of open-coding directfs::write(...)mutations
Single-write-path guardrail:
- each live file family should have one owning write boundary
- low-level atomic replacement belongs in
persistence.rs - file-family semantics belong in one owner-layer helper such as mailbox or team-admin
- command handlers should express intent and call the owner-layer helper rather than assemble write mechanics locally
- if a new write precondition appears, the default response should be to extend the shared helper or owner-layer helper rather than introducing a parallel write path
Current owner-layer boundaries:
- Historical Claude-owned inbox compatibility surface:
AD.3 retired the Claude inbox append backend and the old nudge/context
injection path. The retained mailbox commands now cross the
RetainedServiceRuntimeseam and delegate through injected store adapters; low-level source-file discovery, lock/reload orchestration, and persistence remain internal leaf helpers behind that seam during the Phase R store transition. - ATM-owned source-of-truth state:
workflow::{load_workflow_state(...), save_workflow_state(...), project_envelope(...), remember_initial_state(...), apply_projected_state(...), remove_message_state(...)},read::seen_state::save_seen_watermark(...),send::alert_state::{register_missing_team_config_alert(...), clear_missing_team_config_alert(...), save(...)}, andteam_admin::write_team_config(...) - ATM-owned restore/task state:
team_admin::restore::restore_task_state_from_backup(...),team_admin::restore::write_restore_marker(...), andteam_admin::restore::clear_restore_marker(...) - staging/scratch artifacts:
team_admin::restore::prepare_restore_workspace(...)andteam_admin::restore::cleanup_restore_workspace(...)
Current architectural limitation:
- mailbox replacement is atomic and lock-coordinated for concurrent ATM writers, but it is not yet compare-and-swap against non-cooperating Claude writers
- therefore the current shared-inbox rewrite path is still a compatibility boundary, not the ideal long-term source-of-truth architecture for ATM-local workflow state
- separately, send-side workflow seeding still lacks a dedicated freshness boundary across concurrent same-recipient sends; that is a post-P.5 hardening gap rather than a reason to move workflow durability back into Claude-owned inbox records
This rule intentionally applies beyond mailbox files so future work does not reintroduce partial-write or torn-state risks through backup/restore or shared auxiliary state paths.
The follow-up locking fixes require failure-path tests, but those tests must not depend on races or hang-prone construction.
Test strategy:
- contention tests use a helper thread/process that acquires the target lock and signals readiness through a channel or barrier
- the command under test uses a short bounded lock timeout
- assertions use
recv_timeout(...), elapsed-time ceilings, and scoped guard teardown instead of indefinitejoin()/sleep loops - source-discovery fault tests use a deterministic seam (for example, an injected directory-entry iterator/fault source) to force an unreadable origin entry without depending on filesystem timing or permission quirks
- non-contention lock error tests use a deterministic seam around the lock attempt/classifier rather than trying to synthesize platform-specific OS failures opportunistically
- durability tests validate helper sequencing and error propagation through deterministic seams; they do not attempt literal crash simulation in unit or integration test runs
This is intentionally stricter than the Phase M success-path deadlock tests so CI remains bounded and repeatable across macOS, Linux, and Windows.
restore_team in team_admin.rs currently mutates in this order:
- Copy inbox files to the live inbox directory
- Restore task bucket
- Recompute highwatermark
- Write
config.json
If the process crashes between steps 1 and 4, inbox files for members not in config exist with no detection mechanism.
1. Validate backup and compute restore plan (no mutations)
2. Write .restore-in-progress marker to team directory
3. Stage inbox files to .restore-staging/inboxes/
4. Move staged files to live inboxes/ (fs::rename — atomic same-filesystem)
5. Restore task bucket
6. Recompute highwatermark
7. Write config.json + fsync (atomic temp+rename via write_team_config)
8. Remove .restore-in-progress marker
Key properties:
- crash at steps 2-6: config.json unchanged, extra inbox files harmless, marker signals re-run
- read-only failure during the pre-copy stale-sentinel sweep aborts before live inbox replacement begins, preserving the pre-restore team state
- crash at step 7: config write is itself atomic via the existing
write_team_config(...)temp-file + rename path, so no partial config write is possible - crash at step 8: config is written, stale marker cleaned up by next doctor/restore run
- location:
{team_dir}/.restore-staging/inboxes/ - lifecycle: created at step 3, contents moved at step 4, directory removed after config write
- failure path: staging directory cleaned up, no config written
New check: scan for .restore-in-progress in team directories.
- Severity: warning
- Recovery guidance: "A previous
atm teams restorewas interrupted. Re-run the restore command to complete it, or remove the marker file manually if the restore is no longer needed."
If .restore-staging/ already exists at restore start, the implementation must
either clean it before staging begins or fail with actionable recovery text.
It must never merge old staging contents with the new restore attempt.
AtmError keeps the user-facing Display output concise:
Displayrenders only the primary message and recovery text- captured backtraces stay available through Debug output and a dedicated
accessor on
AtmError
This avoids multi-kilobyte backtrace blobs in normal CLI/log output while preserving full diagnostic depth for explicit debugging.
Duplicate function in ack/mod.rs, clear/mod.rs, and read/mod.rs moves to
identity/mod.rs as pub(crate) fn resolve_actor_identity(...). All three call sites
update to use the shared helper while preserving the existing override -> runtime-env
identity resolution order.
normalize_json_number(...) must not panic on untrusted numeric text. Phase M
replaces the old panic path with graceful fallback: on exponent parse failure or
unsupported exponent range, return the raw string unchanged and emit tracing::warn!.
A library function must not panic on potentially untrusted input.
Phase M uses an explicit audit methodology for REQ-CORE-ERROR-DOC-001 and
REQ-CORE-ERROR-RECOVERY-001 so signoff does not depend on ad hoc review.
Method:
- grep the production source tree for
expect(and bareAtmErrorconstruction sites - review the resulting inventory manually against the explicit Phase M audit inventory in the sprint plan
- exclude:
- test-only code
#[cfg(test)]modules embedded in production files- intentional invariant assertions that do not represent operator-actionable failures
- keep the remaining production-path sites in scope for either:
# Errorsdocumentation updates.with_recovery()additions- panic removal or other structural correction when the failure mode is not acceptable in library code
The initial planning audit identified 16 production-path expect(...) sites
requiring review under this methodology. Phase M treats that number as a
starting inventory, not as a substitute for a fresh grep during implementation.
Phase M builds on the already-landed L.7 runtime surface
(team_members, aliases, post_send_hook, doctor identity drift warning).
Phase M does not re-open that feature set; it only adds the remaining concurrency,
restore, and code-review hardening needed for 1.0.
Phase O adds three architecture-level hardening decisions:
-
Address validation is the trust boundary for path construction
- team and agent names must be validated before any helper constructs
{ATM_HOME}/.claude/teams/{team}or{agent}.json address.rsandhome.rstogether form the boundary; downstream code must not attempt ad hoc sanitization after path joins are already built
- team and agent names must be validated before any helper constructs
-
PID-file locking remains conservative by design
- the send-alert lock uses a PID-file-style stale-lock check
- PID reuse is an accepted limitation: a reused PID can make a stale lock look alive, so ATM may conservatively preserve that stale lock until timeout or manual cleanup
- this limitation favors false-alive availability loss over false-dead lock eviction
-
Atomic writes must use collision-proof temp names
- temp files for atomic replacement must use ULID-based or equivalently collision-proof non-UUID suffixes instead of timestamp-only suffixes
- this keeps same-process rapid writes to the same target path from colliding on the temp-file name while preserving the target basename for operator debugging
The current SQLite/daemon architecture supersedes the mailbox-lock architecture as the target design for ATM mail correctness. The file-based mailbox line remains an interim compatibility surface only.
ATM uses one logical master-roster record with split persistence domains:
- SQLite is the authoritative durable store for:
- messages
- ack/task state
- read/clear/delete message state
- team roster
- the write-through RAM master roster is the authoritative live runtime view
for each durable member's:
- one current
RuntimeMemberState - typed observation revision, source, and freshness/edge timestamps
pid: transient process identity cached as diagnostic metadata, never a second liveness state or policy inputlast_active_at: daemon-memory-only runtime state used for live overlays
- one current
SQLite must not persist live state or its observation metadata. RuntimeHealth
and command output project the RAM roster; they do not own another member map.
The Phase R first implementation uses one authoritative schema contract with concrete SQLite table names:
mail_messages- logical durable message store
- stores the full
MessageEnvelopeinenvelope_json - also stores queryable message columns:
from_agentmessage_textsummarymessage_at- compatibility
message_id
- one canonical mutable message-state table
- logical
message_stateprojection
- logical
mail_ingest_replay_states- logical
inbox_ingestreplay/high-water projection
- logical
- one canonical roster/member table
- per-member durable projection keyed by
(team_name, agent_name)
- per-member durable projection keyed by
Minimum key rules:
message_keyis the canonical ATM durable message identitymessage_keymust be source-typed:atm:<ulid>for ATM-authored rowsext:<fingerprint>for imported external rows without ATM ids
- retained
message_idis the ULID text encoding of the one logical ATM message identity
Minimum index/constraint rules:
- unique identity enforcement on
message_key - dedupe index for imported external/compatibility identities
- one-successor enforcement on
(team, agent, parent_message_id)for threaded update chains - lookup indexes for:
- recipient/team mailbox projection
- task lookup
- message-state projection
- ingest replay/high-water tracking
Minimum canonical roster-member durable fields:
team_nameagent_namemember_kindharnessagent_typemodelmetadata_jsonrecipient_pane_id TEXT NULL- authoritative post-send-hook pane mapping when known
The canonical harness values are claude-code, codex-cli, gemini-cli,
opencode, hermes, and python-graft. hermes is the named Hermes Python
gateway integration; python-graft is the generic value for any Python host
that receives messages through the atm-graft interface. Both Python graft
harnesses use the non-Claude delivery path and do not require a tmux pane.
pid is not part of the canonical roster-member durable schema. It remains
transient daemon-owned runtime state only.
Schema-governance rule:
- any SQLite schema change is a contract change
- schema changes require explicit user approval plus synchronized requirements, architecture, and boundary doc updates before implementation is accepted
The SQLite runtime contract is part of the architecture, not an implementation detail.
Required invariants:
journal_mode = WALforeign_keys = ON- schema bootstrap is deterministic, idempotent, and runs once per database root before normal command operations use the durable store
- per-operation connection acquisition may reapply runtime pragmas, but must not rerun full schema bootstrap on every connection use
- mutating ATM flows use explicit transactions
- no normal command path relies on implicit autocommit as its correctness model
Crash recovery preserves the committed local mailbox. The daemon owns no remote replay store, deferred outbox, or retry state.
Historical Claude-owned shared inbox compatibility previously existed for:
- direct Claude-native writer interoperability
- the prior shared
.jsoninbox container shape, whose file container was one top-level JSON array of inbox messages
Phase AD rule:
- Phase
AD.3completes retirement of Claude context injection through inbox append perADR-019 - no accepted runtime path requires Claude-owned shared inbox files
Architectural rule:
- Claude inbox-append runtime behavior and the former
crates/atm-storage-claudebackend are retired from the accepted line because Claude Code no longer uses them - durable SQLite state is ATM's authoritative mail state
- send/ack must not depend on Claude
.jsonor.jsonlmailbox writes - the shared backend contract remains required so SQLite stays one backend implementation rather than becoming the architecture
- any surviving compatibility readers or repair paths are historical/deletion work only and must not redefine current runtime behavior
config.json remains a team-ingress surface, but roster truth moves to
SQLite.
Historical compatibility-export policy and current notification policy must not remain scattered through command code.
Architectural rules:
- one central delivery-policy coordinator dispatches write-affecting events by:
- event family
- canonical roster
harness
- the coordinator is not a universal mail state machine; it is a dispatcher and policy gate
- event legality remains in dedicated event-family state machines
- at minimum the runtime must model:
NewMessageStateMachineThreadUpdateStateMachine
NewMessageStateMachinemust expose two auditable harness paths:- Claude harness
- non-Claude harness
ThreadUpdateStateMachineremains separate because supersede/update legality differs materially from standalone send- write-affecting transitions must emit observable transition records
There are three distinct paths:
-
Claude / compatibility path
- on the earlier compatibility line, Claude inbox files used one top-level JSON-array mailbox document as the shared compatibility shape
- historical Claude
.jsoninbox writes used atomic full-document replacement: load existing array, append, write replacement via temp-file- rename
- healthy historical Claude
.jsoninboxes previously stayed on that compatibility path and did not require repair/rebuild warnings - historical ATM-owned
.jsonlcompatibility projections were append-style only where ATM explicitly owned that export surface rebuild_compat_inbox_projection(...)is reserved for explicit malformed-state repair/rebuild and is not part of the ordinary send/ack write path- ATM imports through one owned inbox-ingress boundary
- imported records become durable in SQLite
- replay is idempotent and parseable rows are not silently dropped
- ATM-authored oversized-body exports replace compatibility-surface
textwith exactlyatm read --message-id <id>while keeping the full body durable in SQLite
-
Native agent path
- native agent/plugin traffic does not use JSONL
- native agents talk to the local daemon API
- the daemon commits through the SQLite store boundary
Nudge taxonomy (Phase AQ). "nudge" is the umbrella term for any post-delivery recipient notification. "steer" (steer nudge) is the immediate kind, emitted right after durable persistence — this is the only kind that existed before Phase AQ, so legacy text below that says plain "nudge" for the immediate case means steer nudge. "queue" (queue nudge) is the deferred kind, introduced by Phase AQ, delivered when the recipient harness is ready. Persistence ordering is unchanged for both kinds: neither kind ever precedes durable persistence.
The accepted daemon + SQLite runtime keeps one direct post-persist rule for new messages.
Architectural rules:
- send success is durable ATM persistence
- after persistence, ATM emits the steer nudge when the recipient exposes that capability; queue-kind nudges defer emission until harness readiness (ADR-054), and neither kind ever precedes persistence
- the shipped default emitter path is the receiver-only
MessageReceivedHookEmitterdelivery path - the built-in renderer selects exactly one of eleven named template kinds:
delivery,delivery_ack,queue,queue_ack,acknowledge,task_queued,task_ready,task_reminder,task_started,task_complete, andtask_closed;taskandacknowledge_taskare retired,NudgeKindselects the delivery or queue family, and a task-linked message selects the kind named by itstask_transition - any team-scoped built-in template override row must be resolved through the
storage-neutral
NudgeTemplateOverrideStorecontract before the built-in emitter/render path runs;atmandatm-coremust not perform direct SQLite lookup for this feature, and any retainedatm internal-nudgehelper must not reopen the lookup after it receives resolved input - the authoritative Phase AD post-send smoke lane is fixed to five closure
cases only:
- external hook success
- external hook partial failure
- built-in fallback across both tmux and graft sinks
- override reset-to-default after deleting a prior stored override row
- explicit disable behavior when the retained design keeps that state
- resolved built-in template lifecycle is explicit: no row => product default, override row => stored text, disabled row => no emission, clear/reset => row deletion
- external
[[atm.post_send_hooks]]commands remain the explicit full-override path - post-send emission failure is logged and returned as a sender-visible warning
- post-send emission is not durable message delivery and does not redefine send success
- the accepted compact built-in acknowledge forms are:
<atm kind="ack" from="..." message-id="..."/>
- the accepted seam is a dedicated post-send emitter with optional direct
notification-log append at the event site, not
DeliveryPlan/NotificationSinkor a daemon-owned notification worker/runtime
ATM uses one same-host daemon API plus one test transport:
- same-host target: HTTP over Unix UDS or loopback TCP; Windows loopback TCP
- tests: in-process
test-socket
This is one protocol with multiple implementations, not multiple systems.
Supported-platform parity rule:
- same-host daemon functionality is not complete until the Unix and Windows implementations both satisfy the same retained product behavior
- platform-specific implementation differences are allowed only in:
- same-host local IPC adapter internals
- lifecycle-control source adapter internals
- host-ownership adapter internals
- business logic, dispatcher routing, replay/state handling, health projection, and runtime-lane behavior must not diverge by operating system
- compile-only support or typed unsupported-path stubs are acceptable only as temporary implementation states and must not be documented as final support
Test-transport rule:
test-socketimplements the same dispatcher/handler contract without real socket I/O so subsystem and daemon-boundary tests can exercise the transport boundary in process
The daemon is required at runtime, but it must remain thin.
Hard invariant:
- it must be impossible for two active ATM daemons to run on one host at the same time
Replacement-runtime responsibilities:
- transport listeners
- route selection
- canonical ephemeral member state in the write-through RAM master roster
- daemon-facing diagnostics and health queries used by
atm doctor - direct post-send emission routing
Daemon non-responsibility:
- it must not become the only home of ATM business logic
Auto-start path:
- production ATM commands first attempt to connect to the already-running daemon
- if the daemon is absent, the CLI/runtime path may perform exactly one auto-start attempt
- after one auto-start attempt, the CLI/runtime path retries connect once
- daemon startup waits at most
10sfor control-state publication (AUTO_START_PUBLISH_TIMEOUT) - if the daemon remains unavailable, the command fails with a typed actionable error
- there is no silent fallback from the production path to direct SQLite or inbox-file access after auto-start failure
The current runtime's key architectural rule is strict ownership of all external I/O.
Required ownership model:
- only the store subsystem touches SQLite
- only the inbox ingress/export subsystem parses or writes inbox JSONL
- only the config-ingress subsystem parses team
config.json - only the transport subsystem touches sockets
- only the notifier/plugin subsystem talks to agent processes
This is the architectural mechanism intended to prevent the boundary leakage that made the old daemon line unmaintainable.
Privacy rule:
- each boundary must expose only the trait or façade needed by callers
- concrete implementations, helper constructors, and storage/transport details stay private to the owning module unless a later crate extraction makes the boundary stricter
Each I/O-owning subsystem needs one explicit architectural boundary.
Dispatch model:
- synchronous request/response from service code
- transaction-scoped mutating calls
Object-safety rule:
- callers depend on an object-safe store trait or façade, not concrete SQLite types
Minimum method set:
- open/bootstrap store
- run transaction
- upsert/load message rows
- upsert/load unified message state
- record/load ingest replay state
- return health/readiness snapshot
Scope rule:
MailStoreowns message rows plus unified read/ack/delete/expiry state tied directly to message lifecycleMailStoreis not the long-term owner of generic task-orchestration or daemon-status domains
Phase AC closeout note:
- speculative
TaskStoreandTaskStoreDoctorsurfaces were deleted inAC.6 - future task storage is out of scope for the current shared storage contract
- if approved later, task storage starts from canonical Claude-code task schema plus Pydantic validation rather than from preserved transition scaffolding
Phase-AX amendment (2026-09-04): superseded. Task storage is approved in
Phase AX (phase plan §2). ADR-062 defines the daemon-owned,
message-derived Rust state machine implemented by atm-storage and
atm-storage-rusqlite; the Claude-code-schema-plus-Pydantic direction is
withdrawn because the daemon already persists source messages, its write path
has no Python, and a Claude Code task list is a per-session harness artifact,
not a cross-host record. The AC.6 deletion stands: ADR-062 is a fresh design,
not a revival of deleted scaffolding.
Phase-BA amendment (2026-09-11): the task ledger keys one row per
(team, task_id), enforces at most one active task per agent with a
database unique index, and orders each agent's queue by
(position, assigned_at, task_id) with assigned_at reset only by reassign/reopen and never by move; see
requirements Sections 15.4 and 22.1.
| state | Assigned |
Started |
Completed(outcome) |
|---|---|---|---|
| none | → assigned |
reject: no open task | reject: no open task |
assigned |
same agent: resend with no event; other agent: reassign in place → assigned |
→ active; reject when the assignee already has an active task |
→ complete(outcome) |
active |
same agent: resend with no event; other agent: reassign in place → assigned and free the old active slot |
reject ATM_TASK_ALREADY_ACTIVE: the write fails, nothing is delivered, one state-neutral rejected event is appended (Phase BB amendment, ADR-062) |
→ complete(outcome) |
complete |
reopen the same id → assigned |
ordinary mail write reports already_closed |
ordinary mail write reports already_closed |
The runtime's pure disposition function combines the canonical roster state,
the head open task, pending mail, the reminder threshold, and the consecutive
refusal run. It nudges only an Idle assignee, never diverts an Active
assignee, and turns Blocked, Offline, stalled, and refusal-threshold states
into terminal escalation/hold decisions without a parallel task state machine.
An atm queue message is an ephemeral scheduling item, not a task row. Its own
unread or pending-ack state is the lifecycle, and nudge_pending_at is the next
prompt time. Handoff re-arms that marker; read, acknowledgement when required,
or task close discharges it. The queue item is considered before the next task
and never appears in atm task list.
Historical inherited boundary limitations recorded during the pre-BA review
(all four were fixed at the Phase BA shipped head 9f5aef2fe):
RBP-F001: theTaskStoreescalation-recipient methods still expose rawString/&strvalues.AgentAddressvalidation occurs at the runtime use site, not at the storage trait boundary.RBP-F002:load_escalation_targetscurrently maps roster-store read failures toResult<_, ()>, so the helper does not retain the underlying error context.RSH-001: the queue pump'srun_blockinghelper awaitsspawn_blockingwithout its own timeout. Its current closures are local SQLite operations, not network calls.RBQA-BA5-F004: the six-methodPendingNudgeStoretest surface is reimplemented by four hand-written doubles across consumer crates; there is no shared configurable double yet.
Phase BA closeout — shipped state (2026-09-12): RBP-F001, RBP-F002,
RSH-001, and RBQA-BA5-F004 are historical finding labels, not open
limitations. The shipped storage-boundary validation, error propagation,
bounded blocking work, and shared test surface are the current contract.
Dispatch model:
- synchronous request/response for roster replacement, lookup, and readiness checks
Object-safety rule:
- callers depend on an object-safe roster-store trait or façade, not concrete SQLite types
Minimum method set:
- replace/load canonical roster member rows
- query roster membership for routing/validation
- return roster health/readiness snapshot
Ownership rule:
- runtime
pidcontinuity is transient daemon-owned state and must not become part of durable roster truth config.jsonremains an ingress document, not a general runtime-read truth
Dispatch model:
- batch import from one changed inbox source
Object-safety rule:
- callers depend on an object-safe ingress trait or façade, not direct JSONL parser structs
Minimum method set:
- import changed inbox source
- compute canonical imported identity/fingerprint
- report degraded/skipped rows with structured diagnostics
Dispatch model:
- one-way export / re-export after durable commit
Object-safety rule:
- callers depend on an object-safe export trait or façade, not direct file writer implementations
Minimum method set:
- export ATM-authored Claude-compatible record
- re-export by durable
message_key - return typed export failure / retry-needed result
Dispatch model:
- request/response for same-host daemon traffic
- the same dispatch contract must also support the in-process
test-sockettransport used by tests
Object-safety rule:
- callers depend on an object-safe transport trait or façade so the local adapter remains replaceable by the test transport
Minimum method set:
- serve local daemon API
- query daemon health
- shut down listener/connection set gracefully
- construct or bind an in-process
test-socketendpoint for transport-boundary tests
Dispatcher rule:
- transport hands off to one injected dispatcher boundary
- the dispatcher owns request-kind routing only
- request-family behavior lives in injectable handlers behind that dispatcher
- adding a new request type must not require embedding business logic into local-IPC adapter code
Socket receive loop rule:
- the receive loop must stay intentionally small
- allowed responsibilities:
- read one framed request
- parse it into a qualified request enum/value
- validate/authenticate the transport envelope
- dispatch immediately to the owning handler boundary
- serialize one typed response
- forbidden responsibilities inside the receive loop:
- direct SQL/store logic
- background watch/reconcile logic
- direct receiver-side post-send handling logic
- embedded workflow/business-state transitions
Dispatch model:
- qualified request -> handler routing inside the daemon/runtime service layer
Object-safety rule:
- transport adapters depend on an object-safe dispatcher trait or façade, not on concrete request-family handler implementations
Minimum method set:
- dispatch parsed request to the correct request-family handler
- return one typed response or typed error
Boundary rule:
- dispatcher owns routing, not business logic
- request-family behavior lives in injectable handlers behind the dispatcher
- adding a new request family should be an additive handler/registration change, not transport-adapter logic growth
Watcher/reconcile is historical only.
Architectural rule:
- the accepted runtime does not own a daemon watch/reconcile subsystem
- no retained send/read/ack path may depend on watcher events, debounce, or reconcile completion
Dispatch model:
- one-way notification plus status-reporting callbacks
Object-safety rule:
- callers depend on an object-safe notifier/plugin boundary, not agent-specific concrete implementations
Minimum method set:
- notify message/task delivery
- report live status update
- return typed backpressure / unavailable results
Current implementation note:
- the historical
R.17daemon-owned queued notifier worker (retired internal worker queue — unrelated to queue-kind nudges) was retired byAD.5 - the accepted runtime must not require a daemon notification queue/worker just to append one post-send event or warning
- if notification logging survives, it is a direct append at the event site rather than a retained daemon-owned worker subsystem
The current runtime must keep production failure handling and observability structured at compile time.
Architectural rules:
- fallible production paths return typed
Result/ discriminated error enums across crate boundaries rather than relying on panic or unwrap - pattern matches over
AtmErrorCodeat module/crate boundary surfaces must be exhaustive; wildcard_match arms are not permitted - adapter layers may translate errors, but must preserve structured identity
- when reviewing transitional compatibility paths, apply these structured-error rules together with the pre-Phase-Q pipeline stage lists and their supersession notes; see Sections 8 and 9 for the Ack and Clear pipeline stage lists and the inline notes that supersede them under the current runtime
- SQLite-specific transaction, busy-timeout, shutdown-checkpoint, and
rusqliteblocking-I/O rules are defined indocs/atm-rusqlite/architecture.mdSections 4, 5, and 6 and are part of this same current-runtime error boundary atmowns CLI-sidesc-observabilitybootstrap and CLI event emissionatm-daemonowns daemon/runtime/transportsc-observabilityemissionatm-coreowns ATM event and error models above the shared observability boundary- daemon-side observability remains bottom-of-stack:
- the shared daemon observability layer imports no daemon subsystem types
- daemon subsystems emit typed daemon event payloads through a sealed, object-safe injected trait
AtmMessageIdandTaskIdremain typed identifiers in daemon event payloads; raw string semantic identifiers are not the Phase V target shape
- native plugins may emit plugin-local diagnostics, but daemon-owned runtime, store, ingest, and transport events remain daemon-owned observability sinks
- production runtime diagnostics must not collapse into ad hoc stdout/stderr debugging
atm doctor remains a CLI command, but the current SQLite/daemon architecture requires one
explicit daemon health interface.
Architectural rules:
- CLI doctor code may answer direct local config/store checks without daemon routing, but daemon-owned runtime state still crosses one explicit request / response boundary
- the runtime-health projection reads runtime-only signals such as:
- canonical ephemeral master-roster member state, updated by authenticated heartbeat POST and successful Herdr poll ingress
- singleton ownership state
- live status-cache health
- ingest backlog / degraded-ingest state
- the runtime-health DTO returned across that boundary must carry:
- liveness
- readiness
- singleton-owner pid when known
- degraded-ingest state
- aggregate active/idle/offline/unknown member counts
- CLI code must not inspect private daemon state directly to synthesize health answers
- Runtime member state and its pid/session/timestamp metadata live only in the
RAM master roster. Authenticated heartbeat POST and successful Herdr poll
ingress converge there.
RuntimeHealthprojects it and must not merge or retain a second member map. Pre-cutover local activity metadata is tolerated for wire compatibility but does not mutate canonical state. - Session, pid, source, and timestamps never select routing, retry, admission,
delivery, or notification behavior. The nudge invariant (requirements
Section 15.4) is the only policy that reads runtime member state; it reads
the exact canonical
RuntimeMemberState, never aRuntimeHealthor picker projection. - A failed Herdr poll preserves prior state and triggers no nudge. A
successful covered unknown/absent result becomes
Unknown; only explicit heartbeatSessionEndedbecomesOffline. Projection gaps never renderDead. - Changed trusted pid/session replaces the current observation and emits
retained diagnostic evidence. It does not reject ingress, create an
IdentityConflictlifecycle state, degrade readiness, or alter cache policy.
Phase AA target doctor split:
- daemon health remains a separate explicit request/response boundary for daemon-owned runtime state
- direct local doctor checks that only require config or store access do not need daemon routing
- SQLite/store readiness has been removed from daemon-owned health collection
in
AA.3;RuntimeStatusSnapshotcarries no store-specific readiness fields - store readiness then lives in direct local diagnostics or other subsystem doctor reports assembled above the backend, not in the daemon runtime DTO
The daemon runtime must use one documented operational contract.
Daemon singleton is requirement #1.
Architectural rules:
- only one
atm-daemonprocess may exist per OS account per host for the supported runtime model (Rand, 2026-09-08.) - a container is its own host for this requirement (Rand, 2026-09-08.)
- singleton enforcement uses at least:
- a pre-spawn launch gate before fork/exec
- a daemon-side startup gate before serving state
- a static lint/CI gate that rejects daemon-spawn patterns in ordinary tests
- no test, tool, alternate socket path, or alternate
ATM_HOMEvalue is exempt from the singleton rule
Phase R operational defaults:
- graceful shutdown drain deadline:
5s - force-cancel deadline:
10stotal - daemon auto-start publish deadline:
10s(AUTO_START_PUBLISH_TIMEOUT) - same-host daemon request deadline:
3s - SQLite
busy_timeout:5000ms- authoritative since
R.5; supersedes the pre-R.51500msbaseline
- authoritative since
- ingest batch processing slice:
2s - doctor health query deadline:
3s
Required caps:
- max concurrent accepted connections:
64 - max per-connection inflight requests:
32 - ingest queue depth:
1024 - SQLite handle budget:
1..=4 - status-cache cap:
4096
Required runtime-control behavior:
- install the host runtime-control source before listeners accept
- Unix may use
SIGINT/SIGTERM/SIGHUP - Windows may use console-control or service-control equivalents
- the graceful-shutdown control path enters the same bounded drain/checkpoint sequence on every platform
- the reload control path triggers bounded rescan/reload without dropping singleton ownership
Phase R daemon implementation notes:
- per-connection inflight cap
32is documented now, but the current daemon still processes one request per accepted connection, so the inflight count is structurally1until framed multiplexing is introduced - bounded
SIGHUPconfig/roster reload now lands inR.18, including last-known-good preservation on invalid reload input
The daemon is not the test strategy.
The target daemon-runtime test architecture must keep:
- core service logic testable in-process
- transport/watch/runtime logic testable through fakes or harnesses
- daemon process spawning out of the core test path
- default correctness suites free of:
- daemon spawn
- socket publication timing
- retry sleeps
- environment mutation races
- auto-start side effects
Required test tiers:
FakeClientTransportfor deterministic CLI/composition tests- in-process loopback transport for request/handler integration
- a narrow daemon-runtime suite for true singleton/startup/shutdown/recovery requirements only
If a capability cannot be tested without real daemon spawning, that is treated as a design smell rather than the default approach.
The lock-release gate proved the file-based line is acceptable only as interim relief. The current SQLite/daemon architecture removes mailbox-lock dependence from ATM mail correctness by moving durable state ownership to SQLite and treating JSONL as compatibility ingress/egress only.
The migration to the current SQLite/daemon architecture followed five architectural stages:
- store and boundary foundation
- compatibility ingest/export
- ack/task migration
- read/clear cutover plus thin daemon runtime
- lock retirement and production gate
This ordering is intentional:
- durable truth moves first
- compatibility paths stay owned and explicit
- daemon runtime arrives only after service boundaries are proven
- lock retirement closes the phase after the daemon/runtime and store model are already in place