Skip to main content
Mibyan includes NeMo Relay as a normal runtime dependency on platforms for which Relay publishes a native wheel. The shared-metrics integration is built into Mibyan and does not require a Mibyan observability plugin. Mibyan remains importable without Relay on other native targets. Those targets use an explicit reduced-capability no-op host: Mibyan execution remains available, while Relay scopes, middleware, plugins, and subscribers are unavailable. The mibyan-agent[nemo-relay] extra remains as a no-op compatibility alias for existing installation commands.
[!WARNING] This removes the Mibyan observability/nemo_relay plugin. Existing users must remove observability/nemo_relay (or its legacy nemo_relay alias) from plugins.enabled and move exporter configuration into a Relay plugins.toml. mibyan_NEMO_RELAY_PLUGINS_TOML can select an explicit file, and mibyan update or mibyan migrate relay creates one when migrating legacy mibyan_NEMO_RELAY_ATOF_* and mibyan_NEMO_RELAY_ATIF_* settings. Those legacy variables no longer configure Relay exporters themselves.
On supported platforms, Mibyan requires NeMo Relay 0.9 for managed provider and tool calls.

Runtime Dependency and Data Boundary

Mibyan installs the platform-specific nemo-relay native wheel from the bounded >=0.9,<0.10 dependency range. The published package is built from the NVIDIA NeMo Relay repository. Unsupported platforms use the explicit no-op runtime described above rather than downloading a different implementation. When Relay managed execution is active, the provider request and response pass through that native module in the Mibyan process so configured interceptors can operate on the real call. This is separate from the shared-metrics data contract. Shared-metrics mode installs no rich-observability network exporter, and its subscriber accepts only the versioned, allowlisted projection described below. The opt-in package sender described in Appendix A is the only outbound path, it transmits nothing unless the user enables both enabled and send, and it sends whole packages rather than live spans. Enabling a separately configured rich-observability or dynamic plugin can create a different data path and requires its own policy review. Collection remains off unless Mibyan policy enables it:
This choice is read from the profile’s own config.yaml. A machine-managed configuration overlay cannot enable or disable shared metrics on the profile’s behalf. Mibyan uses Relay’s normal process-wide plugin discovery. Relay reads these files, lowest precedence first: mibyan_NEMO_RELAY_PLUGINS_TOML replaces the user file with an explicit file; the system file still applies above it. Repository-local configuration is ignored. If an explicitly selected file cannot be loaded, Mibyan reports the error and continues without Relay plugins rather than falling back to another configuration. Run mibyan doctor to see which files apply. Its NeMo Relay Plugins section lists each file Relay resolves, whether any plugin is enabled, and any problem Relay reports, without loading plugin code.

Session-Span Segmentation for Continuous Sessions

Relay exports a span when its scope closes. A continuous gateway session can remain open for days, so its session span remains open even though each turn span is exported normally. Optional segmentation rotates only the session scope at a turn boundary:
Both defaults preserve one session scope for the full session. Rotated spans retain the same session_id and add mibyan.session.segment plus mibyan.session.segment_reason (compaction or max_turns).

Working-Directory Scope Data

When Mibyan knows a session or task’s logical working directory, its mibyan.session and mibyan.turn start scopes include it as data.cwd in ATOF. A turn running in a task worktree can therefore differ from its owning session. Unknown directories are omitted, and scope-end data remains reserved for the outcome. The working directory is Relay scope input, so it is visible to every enabled Relay subscriber, not only ATOF. Paths can reveal usernames, repository names, or mount layouts. Relay does not filter events by working directory; if a path must not leave the host, use a trusted local collector or do not enable a remote exporter for that process.

Process-Wide Plugin Policy and Profile Isolation

Relay plugin configuration is a process-level deployment choice, not a Mibyan profile setting. The first hosted profile triggers lazy initialization, and every additional profile hosted by that Mibyan process shares the resulting static middleware, dynamic plugins, subscribers, exporters, and guardrail policy. After initialization succeeds, Mibyan logs the files it loaded:
Profile scopes still preserve causal isolation inside that shared policy. ATIF groups events by their top-level Agent scope, so simultaneous profile sessions produce separate trajectories rather than one mixed trajectory. ATOF and other global subscribers observe events from every hosted profile. Static and dynamic middleware likewise runs for managed calls from every profile. A worker plugin running in a separate worker process does not create a per-profile security boundary. One process-wide activation dispatches calls from all hosted profiles to that worker while preserving the invoking profile’s Relay scope stack. Native dynamic plugins are loaded into the Mibyan process and share the same policy boundary. Run profiles in separate Mibyan processes when they require different trust levels, plugin credentials, exporter destinations, or guardrail policies. This process-wide plugin contract does not change each profile’s independent shared-metrics consent, local SQLite state, or ATIF trajectory grouping. Mibyan core owns one Relay host and one isolated Relay session scope per Mibyan session. Core lifecycle producers use agent.relay_runtime to obtain the shared session handle or run Relay scope, LLM, tool, and mark APIs in that session context. New product marks do not require Mibyan plugin registration. Shared-metrics marks must still contain only fields approved by the versioned allowlist; the hard dependency does not change the collection or privacy policy.

Current Slices

The current vertical slices record pseudonymous profile activity, logical model calls, top-level task runs, tool and approval outcomes, and skill lifecycle and reuse:
Mibyan sends an empty LLMRequest into the metrics-owned lifecycle. This does not describe the separate managed-execution call through the native runtime documented above. The terminal metrics event contains the model identifier and provider route that Mibyan used for the logical call, such as nvidia/nemotron-3-ultra through openrouter. These identifiers are lowercased and structurally bounded, but they are not normalized through a checked-in model catalog. Pricing and model-family classification belong to the metrics backend. Prompts, responses, endpoints, error text, session IDs, task IDs, and request IDs are not included in the metrics event or package. New calls use mibyan.model_route.count. Since package schema v3 each route row also carries call_role (primary or auxiliary), outcome (success, failed, cancelled) and error_class: the error classifier’s own FailoverReason value (rate_limit, auth, context_overflow, …) for the last failed attempt of that logical call, or none. A success row with a non-none class is a call that recovered after that error. The previous mibyan.model_call.count contract remains readable only so pending local counters created by older builds can be exported without losing data. The first consented session start emits an empty mibyan.client.active Relay mark. The profile-scoped subscriber creates a random UUID install identity and uses a transactional compare-and-set to record at most one client-active counter in any rolling 24-hour window. The metric has no dimensions; Mibyan version, OS family, architecture, and install method remain bounded package resources. Concurrent Mibyan processes share the SQLite latch, so simultaneous starts cannot double-count one install. A later session or task can attempt the mark again, but the subscriber suppresses it until the rolling window expires. Each task run is a Relay Function scope named mibyan.task_run, parented to the owning Mibyan session. The start counter contains only bounded execution surface and entrypoint values plus, for gateway tasks, the built-in messaging platform (telegram, discord, slack, …; platforms Mibyan ships under plugins/platforms/ by name, a plugin-catalog/ platform by its catalog entry name only when the installer’s own record proves a catalog install, every other plugin platform plugin, every other surface none). The terminal counter (mibyan.task_run.finished) contains the start fields plus bounded outcome, end reason, termination status, and a failure_class for failed tasks: the provider FailoverReason when the turn died on a classified API error, otherwise a local class (empty_response, context_compression, repeated_errors, exception, other, …). The same end event feeds mibyan.task_run.duration with execution surface, outcome, duration bucket and provider-retry count bucket. Package v2 carried duration, retries and per-task model/tool call counts on the terminal row itself, which made almost every task its own row; call counts per turn live on mibyan.task_cost.count. Raw exit reasons never leave the machine. Retries are additional provider attempts for the same Mibyan API request ID; they do not inflate the logical model-call count. Tool calls are deduplicated by their Mibyan tool-call ID after a terminal tool result is observed. The outer AIAgent execution boundary closes the task for normal returns, early returns, exceptions, and cancellations. Active task ownership follows the task ID if Mibyan rotates its conversation session during context compression. Each tool invocation is represented by a Relay tool lifecycle named mibyan.tool_call. The terminal counter contains only bounded tool category, outcome and approval outcome; the same event feeds mibyan.tool_call.latency with tool category, latency bucket and explicit retry-count bucket (package v2 carried latency and retries on the terminal row, one row per few calls). Mibyan derives the category from the toolset already declared in its runtime registry; custom and unrecognized toolsets collapse to other rather than exporting tool or plugin names. The same terminal event also feeds mibyan.tool.usage.count with tool_name, outcome and error_class. tool_name is exported only for tools declared in the repository’s static toolsets.TOOLSETS (toolsets.BUILTIN_TOOL_NAMES, captured before any runtime custom toolset is created); MCP tools report mcp and every plugin or custom tool reports plugin. error_class maps Mibyan’s own error_type values (tool_error, timeout, interrupted, invalid_arguments, blocked, contract_violation); any other value, such as an exception class name, collapses to exception. Mibyan does not infer retries from repeated tool names or adjacent calls; when the hook does not provide an explicit retry relationship, the retry bucket is unknown. Approval decisions are emitted as mibyan.tool_approval marks and recorded as attributed to a tool call or explicitly unattributed. Non-built-in tool names, call IDs, arguments, results, commands, descriptions, and error text are not included in shared-metrics events or packages. A started tool that is still open when its task terminates is closed as failed, timed out, or cancelled and remains in the task’s tool-count bucket. Successful skill mutations emit mibyan.skill.lifecycle marks with only a bounded action and provenance. Successful loads emit mibyan.skill.load marks with bounded provenance, first-use or reuse state, reuse-after-patch state, a use-count bucket and skill_name: the skill’s name only when it is a skill Mibyan ships (skills/ or optional-skills/), otherwise custom. Mibyan derives reuse and patch-generation continuity transactionally in its existing skills/.usage.json state; local or agent-created skill names and exact counts or generations never enter Relay metrics events, SQLite dimensions, or packages. A use after a new patch is counted once as reused_after_patch; later uses remain ordinary reuse until another patch. Task-outcome attribution after a patch remains deferred until its window and multi-skill semantics are defined. Once per rolling 24 hours, the first activation also emits a mibyan.install.snapshot mark describing how the profile is configured: the memory provider (a bundled provider name, builtin, or plugin), bucketed counts of MCP servers, enabled plugins, installed skills, enabled cron jobs, profiles and connected messaging platforms, the main provider id, the terminal backend (local, docker, ssh, … or other), the display language (a shipped locale or other) and install_age_bucket: how long ago the profile’s first-ever session started. Install age is what lets the backend tell a new user from an existing one who just opted in. Server, plugin, skill, job and profile names are never read into the event. The same compare-and-set latch as mibyan.client.active keeps it to one row per install per day, and the producer checks the latch before walking the skills tree. The snapshot also carries six version-lag, channel and hardware fields, all read offline (no network call, no subprocess):
  • release_channel (stable, main, dev, unknown): a packaged build’s baked channel (canary builds of main read main), a source install’s channel record, else the checkout’s branch (main for main/master, dev for any other branch). The git remote URL and branch names are never read into the event.
  • version_age_bucket (lt_7d … gte_90d, unknown): age of the installed version, from its own commit date in the install stamp or checkout.
  • behind_bucket (0, 1, 2, 3_to_5, 6_to_10, gte_11, unknown): commits or releases behind, only from the update check’s cached result for this exact revision and under 7 days old; otherwise unknown.
  • ram_bucket (lt_8g … gte_128g, unknown): installed memory rounded to its nominal size (the OS total scaled by 1.1 for firmware reservations).
  • gpu_class (nvidia, amd, intel, apple_silicon, none, unknown): the highest-priority GPU vendor from /proc/driver/nvidia or DRM PCI vendor ids on Linux, the display-adapter registry class on Windows, native arm64 on macOS. Never a model name, driver version or VRAM size.
  • local_model_provider_used (yes/no): whether the main model or any auxiliary task runs on a local or self-hosted server (a local provider id such as Ollama/LM Studio/llama.cpp, or a loopback/private-network base URL). The URL itself stays local.

Decision-data metrics

These answer product questions the activity counters cannot: what makes people stay, where new users drop off, which surfaces and models carry real usage, and which extensions are worth investing in. Every dimension is a closed enum, a bucket, a provider/model identifier (as on model routes) or a public name Nous itself ships. Replies the relay connector carries report the platform the conversation lives on (the inbound’s platform, else the platform the connector fronts when it fronts exactly one), never relay; relay remains only when neither is known. A turn whose inbound the connector did not stamp is still a gateway message (execution_surface=gateway, task/session platform=relay). mibyan.platform.health for the relay connector stays relay: its one socket fronts several platforms, so a connect or drop belongs to none of them alone.

Desktop app: what gets used, what gets in the way, what gets turned off

Recorded by the Desktop app into the focused profile’s store, only while that profile’s collection switch is on. With it off the app keeps no local record (switching it off deletes what was kept) and sends nothing. The app keeps one local record per gateway connection and profile, shared by that profile’s windows; after a profile switch nothing is kept or sent until the new profile’s switch has been read, and the switch is re-read whenever a window regains focus (so an opt-out from the CLI or another window takes effect there). There is no rating prompt or other new UI; each fact comes from an interaction the app already has. Every value is a closed id defined in the app’s code (area, action, notice, flow, toggle, step), a published config key, or a bucket. Message text, toast text, session/bot/profile names, paths and setting values never leave. Sessions are summarized when they close (finalize, reset or process exit); delegated child sessions are not counted separately. Milestones latch in the local database, so each fires once per install however many processes reach it.

Per-model quality, friction and context pressure

Provider and model follow the model-route rules: a provider Mibyan ships (built in, an in-tree plugins/model-providers/ profile or a public models.dev id) and its model id; custom endpoints, provider plugins installed under $mibyan_HOME/plugins/model-providers/ or from pip (names and aliases included), the local-server aliases of custom (ollama, local, vllm, llamacpp, llama-cpp, llama.cpp) and loopback servers read custom. A shipped provider whose endpoint is a loopback server (lmstudio, under any of its aliases) keeps its name, but its model reads custom. A model whose provider is unknown, or whose id is a URL, a file path or a network address (host:port, an IP address, localhost) or an AWS ARN (it carries the account id), reads custom. On Azure providers the model id is a deployment name its owner chose, so it passes only when it is a public model id (Mibyan’ model catalogs or the local models.dev cache, e.g. gpt-4o); acme-legal-prod reads custom. The local subscriber re-runs these rules on the provider/model fields of every mark and drops a row they would rewrite.

Agent-harness accuracy

These tune the agent loop itself. Mibyan’ own background review and curator loops never count; delegated subagents do (their tool calls, loops and replies are model behaviour too). Command text, file paths, tool arguments and reply text never leave — only the closed values below.

Efficiency: turn cost, waste, tool overhead and prompt-cache breaks

Provider and model follow the model-route rules above. A “user turn” is one user message through its final reply; Mibyan-owned work (background memory/skill review, the curator, delegated subagents’ own turns) is not a user turn. cache_break and tool_output_truncation describe model and tool behaviour, so delegated subagents count there; background review and the curator never do.

Engagement and implicit model satisfaction

Active time is accumulated locally per UTC day: the sum of the gaps between consecutive interactions (a user turn starting or ending on an interactive surface or a gateway message; unattended cron runs, which mibyan.cron.run counts, delegated children, background review, curator, batch and API-server / python embedding are excluded), each gap capped at 5 minutes. The day’s rows are recorded once the day closes, by the first interaction on a later day, in one database transaction, so a day is reported exactly once per profile however many processes see the rollover; they are dated to the day they describe. The primary model is the one that served the most of those user turns that day (none when none did), named by the model-route rules. Days active per week and return by model are derived server-side from these daily rows and the existing install_id: Mibyan keeps no weekly window and no identifier beyond install_id for them. active_profile_count_bucket counts the distinct profiles of the host with a user-owned turn (the interactions above) that UTC day. Every profile folds its turns into one host accumulator kept in the root (default) profile’s database, as opaque local hashes of each profile’s home that never leave it, so a profile counts once whichever process or multiplexed runtime served it. Only the root profile’s day row carries the count (it reports a day even when the root itself was idle, with 0 active minutes and surfaces); every other profile’s row reads 0. When the root profile has collection off, nothing is written to its database and the count is not reported.

Onboarding and feature signals

Local state is written under:
The database keeps transactional aggregate and package-outbox state. Package files are immutable delta documents that conform to a closed JSON schema and are written with atomic replacement as compact JSON (jq . pretty-prints one). Once the ingest has accepted or refused a package, the database keeps only its send state and drops its copy of the body; the file is the local history copy. Each package records the Mibyan version, OS family, architecture, and install method as bounded client resources. Unrecognized platform or installation values are exported as unknown; raw platform strings, hostnames, and paths are never included. Fully packaged aggregate rows and successfully exported package rows and files are retained locally for 30 days. Pending package rows and counters with unexported deltas are never pruned. Package schemas v1 and v2 remain unchanged for existing outbox files. New packages use v3, which also accepts the v2 field sets of mibyan.model_route.count, mibyan.tool_call.count and the task counters so counters recorded before an upgrade drain safely. Vocabularies derived from in-repo registries (tool names, platforms, memory providers, error classes) are bounded by pattern in the JSON schema; the authoritative allowlist is shared_metrics_contract.py. Each package contains an install_id generated as a random UUID. Despite the schema field name, its current scope is one mibyan_HOME, so it is more precisely a persistent pseudonymous profile identifier. It is not derived from hardware, account, host, path, or credential data. It remains stable across packages from that profile and can therefore link those local packages. Deleting $mibyan_HOME/telemetry/shared_metrics resets the identifier together with all aggregates and package files. Remote delivery is opt-in and off by default. Reusing the persistent local identifier remotely required a separate product and privacy decision covering consent, identity scope, reset behavior, retention, and deletion — that decision has been made.
Those decisions are recorded in Appendix A, and the exporter implementing them has shipped. Collection alone still transmits nothing: the sender runs only when telemetry.shared_metrics.send is also true. Each transmitted package carries the stable install_id as-is (product decision, 2026-08-27 — see A.2 for the record, including the superseded HMAC-pseudonym design).
The install identity is scoped to one mibyan_HOME. To reset it, stop Mibyan processes and remove $mibyan_HOME/telemetry/shared_metrics. This deliberately removes the old identity, aggregate database, and queued local packages together; the next consented session creates a new identity. Disabling shared metrics stops new collection but does not silently delete previously collected local state.

Smoke Test

Run a real Mibyan CLI turn against the deterministic local model server:
The script uses the installed nemo-relay dependency by default. Pass --relay-python ../nemo-relay/python only when testing a locally built Relay binding. The smoke has the local model request a real read_file tool call before its final response, then drives create, load, reuse, patch, edit, stale, archive, restore, and install skill transitions through the installed Relay binding. It verifies model, provider, task, tool, and skill counters in SQLite, validates all exported delta packages against the closed schema, verifies the pseudonymous client-active counter, and checks that prompt, response, tool-call ID, tool-result, and skill-name canaries are absent from the packages.

Appendix A: Remote Exporter Decisions (Phase 2)

Status: implemented. This appendix answers the product and privacy questions that “Current Slices” defers to a future remote exporter. It records what was decided and why, so the reasoning survives the implementation. Sending is off by default and requires both telemetry.shared_metrics.enabled and telemetry.shared_metrics.send. The exporter sends the package files already written under $mibyan_HOME/telemetry/shared_metrics/outbox/ to the Mibyan telemetry ingest service. That service validates only the envelope (schema_version plus a UUID package_id) and stores the body verbatim in S3. Transmission is a separate opt-in from collection, under a new config key:
  • send defaults to false. Collection alone never transmits.
  • send requires enabled. It does not imply it: a transmission flag must not silently switch on collection. send: true with enabled: false warns and does nothing.
  • Like enabled, send is profile-owned and is not overridden by managed-scope configuration.
Both keys are asked once per profile, with the same three answers everywhere (Send to Nous / Local only / No thanks): “No thanks” is the default in the terminal, so pressing Enter never opts anyone in. Esc in the terminal and the dashboard banner’s ✕ leave the question open, so it is asked again next time. Answering on any surface writes both keys to the profile’s config.yaml, and a profile that already carries either key is never asked again. A managed install is never offered. To change the answer later, use mibyan setup telemetry, mibyan tools, or Desktop’s Settings › Safety › Privacy & network. A package is only sent when its whole period falls inside a recorded consent window. Consent is stored as explicit intervals in the shared- metrics SQLite store (send_consent_windows): a window opens when send: true is first observed, is confirmed forward by every later observation, and closes — at the last confirmed moment, never at the wall clock — when send: false is observed. A single reconciler derives this table from the config on every process start, so wizard changes, hand-edits to config.yaml, and mid-pass revocations all take the same path, and no transition can be missed by any of them. Any package whose period predates the first window, falls between windows, or runs past the newest confirmed moment is excluded — the gate fails closed. A fresh package therefore waits at most one process start after its period completes before becoming eligible. The gate is on the period, not on the package’s creation time. One period is split across several packages created on different days: a day’s first package is written that day, and a tail package for the same period typically follows the next day. Gating on creation time would send a period’s tail while dropping its head, reporting a silently undercounted day. Gating on the period keeps consent forward-only and every transmitted period complete. Local history can be up to 30 days old, and that data was collected under a promise that nothing is uploaded. Honouring consent forward-only costs at most 30 days of backlog we never had permission to send.

A.2 Identity scope — the stable install_id is transmitted as-is

Decision record. The original design of this exporter (and revisions 1–8 of this appendix) transmitted a keyed pseudonym instead of the identifier: HMAC-SHA256(key = locally-held rotating salt, message = install_id), with the salt rotating every 30 days. On 2026-08-27, before the feature shipped (zero consented users, zero production transmissions), the product owner decided the analytical need is a stable cross-window identity — retention curves, longitudinal install behaviour — which rotation by design destroys. The pseudonymization layer was removed in full rather than weakened in place. What is transmitted now:
  • Each package carries install_id verbatim: the persistent, profile-scoped random UUID described above.
  • It is generated locally (uuid4), contains no hardware, account, user, or machine-derived information, and identifies a profile, not a person.
  • It is stable until the user deletes the shared-metrics directory, which regenerates it (see A.4).
Consequences stated plainly rather than papered over:
  • Packages from one profile correlate indefinitely, not per-window. Long-term linkability of one install’s daily envelope sequence is now the designed behaviour, not a residue.
  • The A.3 residue analysis of the old design (stable resource tuple + contiguous periods bridging rotation windows) is moot — there is no window boundary left to bridge.
  • The setup wizard’s consent language states this identity model explicitly; it was updated in the same change that removed the derivation, so no consent was ever collected under the old wording in any shipped build.
Byte-identical resends still hold. The transmitted id is recorded on the row (sent_install_id) when the package is first prepared, and the wire body is always rebuilt from that recorded value, so a retry rebuilds identical bytes. The contract requires this: resending a package_id with different content is undefined behaviour. (With a stable id the recorded copy is no longer load-bearing against rotation — it remains as the audit column and as cheap insurance against any future change to identity semantics.)

A.3 Rotation — removed (decision record)

Salt rotation was deleted together with the derivation (product decision, 2026-08-27). This section is retained as a record of what the earlier design did and why the removal was accepted:
  • Rotation existed to bound long-term linkability: one identity per 30-day window, unrelated identities across windows.
  • The documented residue (see git history for the full analysis): the envelope’s stable, low-entropy resource tuple plus contiguous daily periods could plausibly bridge windows for rare configurations anyway, so the boundary was a cost-raiser, not a wall.
  • The product need that killed it: cross-window continuity is precisely what retention analysis requires. A boundary that mostly inconveniences honest analysis while only raising costs for a determined correlator was judged the wrong trade once stable identity became a requirement.
There is no salt in the store, no rotation schedule, and no derived identifier anywhere in the pipeline.

A.4 Reset behavior

Removing $mibyan_HOME/telemetry/shared_metrics still resets local identity, aggregates, and package files, exactly as documented above. Two honest qualifications now apply:
  • Reset regenerates install_id, so subsequent packages transmit a new identity. Local reset does give a new remote identity.
  • Reset cannot unsend. Packages already transmitted remain in the ingest service’s storage under the identifier they were sent with. There is no read-back or delete API in the v1 contract.
Setting send: false stops transmission immediately: consent is re-read before every package, so a pass already in flight stops after the package it is currently sending rather than draining its whole batch. It does not delete previously transmitted packages, and it does not stop local collection. Turning sending off also closes the consent window — at the last moment consent was actually observed, not at the wall clock. Packages whose periods fall between one window and the next are never transmitted, even if sending is later re-enabled, and this holds for any number of on/off cycles, across hand-edits with no process running, and under a clock that jumps in either direction (window opens are clamped above every timestamp already in the store; observation marks advance by a bounded step per call, so one glitched forward sample cannot drag the confirmation horizon years ahead; a close never lands after the closing observation’s own clock). Unlike the earlier single moving opt-in date, closing and reopening does NOT discard the still-undelivered backlog from a previous consented window — those packages stay inside their own interval and remain eligible. One deliberate upgrade-path consequence: packages exported under the pre-interval consent model (before send_consent_windows existed) predate the first recorded window and are therefore never transmitted after an upgrade. This is the fail-closed direction — re-importing the old moving day-stamp to release them would re-import the semantics five review rounds showed to be unsound — and it costs at most the undelivered backlog, never collected data.

A.5 Retention

  • Local: unchanged — 30 days for successfully exported history, and pending deltas are kept until exported. Send state does not extend local retention: a package that could never be sent is still pruned at 30 days. Unbounded local growth against a permanently unreachable endpoint is a worse failure than losing metrics from an install that has been broken for a month.
  • Remote: raw packages are retained in S3 without expiry in production and for 30 days in staging.

A.6 Deletion

There is no remote deletion path in the v1 contract, and this appendix does not invent one. What a user can do: If a deletion-on-request obligation is ever taken on, the lookup path is now direct: the user’s install_id (readable from their local store) is the key their data is stored under. Building the service-side delete API remains a new product decision, not an implementation detail.

A.7 What the outbox directory is

Recorded because it was misread once during Phase 2 planning, in a way that would have deleted user data. The directory is local history, not a send-queue. package_outbox is the SQLite table; its exported_at column means “written to disk”, not “sent”. Files are immutable and pruned by age alone. The ingest contract says senders should delete a package from their outbox on 202. The exporter does not do this. Deleting on acknowledgement would repurpose the user’s 30-day local history as a transmission queue and destroy state they were promised. Send state lives in new columns on the package_outbox table instead; the files are untouched by transmission.

A.8 Scope note

The install_id field inside the package body is transmitted as the generator wrote it (rewritten from the row’s frozen sent_install_id, which records the same value). No other payload field changes, nothing is added, and the service treats the whole body as opaque. Payload schema evolution therefore stays a sender-side concern, as before.