agentmux_srv/backend/storage/migrations.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SQL schema setup for Store, FileStore, and the saga log.
5//!
6//! `objects.db` uses a **flat schema**: `run_object_schema` defines the
7//! final table set directly in one idempotent `CREATE TABLE IF NOT EXISTS`
8//! batch. It replaced an 11-step incremental migration chain
9//! (`run_forge_v1` … `run_forge_v11`) — see
10//! `docs/specs/SPEC_SCHEMA_FLATTENING_2026_05_19.md`. The chain was pure
11//! historical accretion: per-version data dirs mean every new version is
12//! born with a fresh `objects.db` and ran the whole chain top-to-bottom
13//! anyway, so the intermediate states were never reachable in production.
14//!
15//! `filestore.db` and `sagas.db` were already single-DDL stores; they keep
16//! their existing schema functions and gain only the `user_version`
17//! tripwire (`check_schema_compat` + `stamp_version`).
18
19use rusqlite::Connection;
20use tracing::warn;
21
22use super::error::StoreError;
23
24/// `user_version` stamped into `~/.agentmux/shared/store.db` after
25/// `run_shared_store_schema`. Versioned independently from `objects.db`.
26/// v1 — initial: identity accounts/bundles/bindings/links, memory
27/// bundles, drone definitions, muxbus credentials
28/// v2 — db_cron_jobs: persistent scheduled injection jobs
29/// v3 — Phase 4a of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md: rename
30/// db_identity_accounts -> db_accounts, db_memory_bundles -> db_bundles
31/// (SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md).
32/// v4 — db_agent_credentials: per-agent M2M Cognito client_id/secret +
33/// cached access token, one row per locally-registered agent_id.
34/// Lets cloud_subscriber use a credential bound to a specific agent
35/// instead of the single shared per-account MUXBUS_TOKEN for every
36/// /reactive/* call.
37/// v5 — db_cron_jobs.max_age_secs: optional hard expiry bound (seconds since
38/// created_at) alongside the existing max_fires bound. NULL = no expiry
39/// (existing rows unaffected). See
40/// docs/specs/SPEC_AGENT_POLLING_AND_WAKEUP_HARDENING_2026_08_04.md Phase 0.
41/// v6 — db_agent_native_memory: durable, location-consistent mirror of
42/// each agent's native memory files, keyed by (agent_id, filename).
43/// See docs/specs/SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md.
44/// v7 — db_bundles.instructions_by_provider: JSON object of
45/// {provider_id: content} instruction variants, additive to the
46/// existing flat `instructions` column. ABF v0.2 §2.2, see
47/// docs/specs/SPEC_ABF_V0_2_PROVIDER_AWARE_COMPONENTS_AND_NATIVE_MEMORY_2026_08_10.md.
48/// v8 — db_agent_native_memory_versions: append-only version history for
49/// native memory content, one row per `agent:memory:write_file`
50/// call (plus reverts and, later, out-of-band-write detection) —
51/// the mirror in v6 only ever holds the current value, with no way
52/// to see what a file contained before its last write. See
53/// docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md.
54/// v9 — db_bundles.is_system: AgentMux-controlled, highest-priority
55/// Global Bundle tier — see OBJECT_SCHEMA_VERSION's v27 doc
56/// comment (objects.db, above run_object_schema) for the full
57/// design; this store just needs schema parity.
58/// v10 — db_bundle_versions: append-only version history for Global
59/// Memory entry content — see OBJECT_SCHEMA_VERSION's v35 doc
60/// comment for the full rationale
61/// (SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md); this store
62/// just needs schema parity, same as v9 above.
63/// v11 — db_cron_jobs.target_uid: the target agent's UID (`db_agents.id`)
64/// beside the slug column, resolved once at create time and
65/// dual-written; nothing reads it yet, a job still fires by
66/// `target`. Identity M1b of
67/// SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §9.1 —
68/// spec §5.4: non-interactive ingress never resolves a name at fire
69/// time, so the UID has to be captured while a human or model is
70/// present. Empty when the name did not resolve; never guessed.
71/// v12 — db_cron_jobs.created_by_uid and db_bundle_versions.written_by_uid:
72/// the actor's UID beside each actor-name column, taken from the
73/// request's `Caller` (its `X-Agent-Token`), never from a body field.
74/// Empty for an Unattributed request; never guessed. Dual-written,
75/// not yet read. Identity M4c-1 of
76/// SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.9.
77pub const SHARED_STORE_SCHEMA_VERSION: i64 = 12;
78
79/// `user_version` value stamped into `objects.db` after `run_object_schema`.
80/// The flat schema reset the counter to 1 (the pre-flatten chain never set
81/// `user_version`, so legacy files read 0). Bumped per additive migration:
82/// v1 — flat schema baseline
83/// v2 — db_agent_definitions.updated_at
84/// v3 — db_agent_definitions.user_hidden (Phase 2 hide templates,
85/// SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md Q2 Decision Y)
86/// v4 — db_agents consolidation table (Phase 3a: every definition/
87/// instance write dual-writes here). Reads moved over
88/// piecemeal afterwards — the instance side in Phase 3b (#3080),
89/// every `agent_def_*` method in Phase 3d (#3092, v31) — and both
90/// legacy tables were dropped in v32.
91/// v5 — db_agents.last_block_id (Phase 3c; latest launch's block, so the
92/// consolidated read can find the session snapshot without joining
93/// db_agent_instances)
94/// v6 — container_image / container_volumes / container_name on both
95/// db_agent_definitions and db_agents (Phase 0 of
96/// SPEC_CONTAINER_PANE_SUPPORT_2026_06_11.md; host agents default
97/// to '' / '[]' / '')
98/// v7 — db_muxbus_credentials: global singleton for MuxBus cloud
99/// Cognito PKCE tokens (access, refresh, id) + expiry + user email
100/// v8 — db_memory_bundles.is_global: global-tier flag for Armory
101/// bundles injected into every agent's CLAUDE.md at launch
102/// v9 — db_memory_bundles.sort_order: explicit ordering for the Armory
103/// global bundles (controls CLAUDE.md injection order). Existing
104/// rows default to 0; the Armory Global section assigns positions via reorder.
105/// v10 — db_skills, db_mcp_servers, db_agent_skills_ref, db_agent_mcp_ref:
106/// standalone MCP Server and Skill primitives with per-agent ref tables
107/// (v1 composable model, SPEC_V1_MCP_SKILLS_PRIMITIVES_2026_06_30.md).
108/// v11 — Phase 4a of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md: rename
109/// db_identity_accounts -> db_accounts, db_memory_bundles -> db_bundles
110/// (SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md).
111/// v12 — use_ambient_login on both db_agent_definitions and db_agents:
112/// explicit per-agent opt-in to the CLI's global (ambient) login when
113/// no oauth-class account resolves at spawn (layer 3 of
114/// SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md §2.3). Defaults
115/// to 0 (fail-by-default); the m0017 data migration grandfathers
116/// pre-existing linkless agents to 1.
117/// v13 — db_agent_credentials: per-agent M2M muxbus credential, mirroring
118/// db_muxbus_credentials' presence in both schemas — id_store falls
119/// back to this (per-channel) schema on installs that haven't yet
120/// applied 0011_shared_store_backfill, so cloud_subscriber's calls
121/// must not assume the shared-schema copy (v4 there) is the one in use.
122/// v14 — db_agent_native_memory: durable mirror of each agent's native
123/// memory files, keyed by (agent_id, filename). Same both-schemas
124/// duplication as db_agent_credentials, for the same id_store
125/// fallback reason. See
126/// docs/specs/SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md.
127/// v15 — model_vendor_base_url on both db_agent_definitions and db_agents:
128/// redirects a harness (CLI) at a non-default model vendor backend
129/// (e.g. ANTHROPIC_BASE_URL for a claude-provider agent) — the data-
130/// model side of formalizing harness vs. model-vendor as distinct
131/// concepts. Defaults to '' (use the harness's default vendor).
132/// Only settable via `agent.define`, validated against the resolved
133/// provider's `ProviderConfig::base_url_env_var`.
134/// v16 — db_bundles.instructions_by_provider: JSON object of
135/// {provider_id: content} instruction variants, additive to the
136/// existing flat `instructions` column (which keeps meaning
137/// "default"). ABF v0.2 storage half of provider-scoped
138/// instructions — see
139/// docs/specs/SPEC_ABF_V0_2_PROVIDER_AWARE_COMPONENTS_AND_NATIVE_MEMORY_2026_08_10.md
140/// §2.2. Defaults to '{}' for existing rows; delivering a selected
141/// variant into a running agent is explicitly out of scope for this
142/// version (per that spec's non-goals) — this column only makes the
143/// data storable/exportable/importable.
144/// v17 — auto_continue_enabled on both db_agent_definitions and db_agents:
145/// per-agent opt-in letting a Warden Supervisor watcher agent
146/// auto-continue this agent's session on turn-end (subject to a
147/// server-side consecutive-nudge ceiling). Defaults to 0 (opt-in
148/// required — fail-by-default, same posture as use_ambient_login).
149/// See docs/analysis/ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md.
150/// v18 — db_agent_jekt_keys: per-agent HMAC-SHA256 signing key for
151/// host-tier jekt sender verification. One row per agent_id, minted
152/// on first use and injected into that agent's own MCP server
153/// process env (AGENTMUX_JEKT_KEY) at spawn — never into any other
154/// agent's env, so a signature can only be produced by the agent it
155/// claims to be from. See
156/// docs/specs/SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2.
157/// v19 — db_agent_definitions.memory_id: the agent's own dedicated ABF
158/// bundle, set once at creation and never updated thereafter
159/// (agent_def_update's SET clause deliberately omits it, same
160/// immutable-after-creation treatment as is_seeded/slug/parent_id).
161/// Distinct from db_agent_instances.memory_id (a specific launch's
162/// bundle, can still diverge on purpose). Mirrored into db_agents as
163/// a SEPARATE default_memory_id column, not db_agents.memory_id —
164/// that column is instance-only by existing convention (see
165/// db_agents' CREATE TABLE comment). Defaults to '' for existing
166/// rows; the m0021 migration
167/// backfills every unbound definition to a freshly-provisioned
168/// bundle. See ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md §3.1.
169/// v20 — db_agent_lan_keys: per-agent Ed25519 keypair for LAN-tier jekt
170/// sender verification — mirrors v18, asymmetric instead of HMAC
171/// (LAN is multi-party: a receiving peer must verify without being
172/// able to forge, which a shared secret can't provide). public_key
173/// is distributed to LAN peers on demand (not secret); private_key
174/// is injected into that one agent's own MCP process env
175/// (AGENTMUX_LAN_KEY) at spawn, same never-over-RPC guarantee as
176/// v18. See docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.1.
177/// v21 — db_lan_peer_pubkey_pins: trust-on-first-use pin of a REMOTE
178/// agent_id's LAN public key, as first observed from mDNS-discovered
179/// peers. reagentx P0 on the LAN signing PR: peer discovery itself
180/// is unauthenticated (any device can broadcast an mDNS instance and
181/// register an agent under an existing agent's name), so trusting
182/// "whichever peer answers first" for a pubkey lookup lets an
183/// attacker's self-minted key be accepted as authoritative for a
184/// victim's agent_id. Pinning the first-seen key and rejecting any
185/// later mismatch (rather than silently accepting the newest
186/// answer) is the standard SSH-host-key mitigation for exactly this
187/// "no PKI available" situation — closes the always-spoofable case,
188/// narrows it to a race on the very first lookup ever performed for
189/// that agent_id. See
190/// docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2.
191/// v22 — db_background_tasks: durable record of a declared long-running
192/// (`run_in_background: true`) task attached to a block, so its
193/// liveness survives past `DockSnapshotCache`'s 1-hour eviction and
194/// a session reconnect/reload — the ephemeral client-transcript-
195/// derived signal chain had no durable source of truth at all. Per-
196/// channel (block ids are only meaningful within their own
197/// channel), not the shared store. Renumbered from an earlier v20
198/// to v22 when merged alongside the LAN-tier jekt signing PR, which
199/// independently claimed v20/v21 for an unrelated table pair — see
200/// docs/status/STATUS_ATTACHED_TASK_AXIS_AND_DEV_LOOP_2026_08_15.md,
201/// issue #2492.
202/// v23 — db_bundle_skills_ref, db_bundle_mcp_ref: bundle-level references
203/// to the standalone skill/MCP-server primitives, mirroring the v10
204/// agent-level ref tables but keyed by bundle_id — composable model
205/// v2, step 1 of docs/specs/SPEC_BUNDLE_AS_CONTAINER_V2_2026_08_17.md
206/// (GH issue #2024 item 3). A bundle's existing mcp_servers/skills
207/// JSON columns are untouched by this — still the .abf export/import
208/// format — these ref tables are the new live-resolution path.
209/// Deliberately NO FK to db_bundles (unlike the agent-level ref
210/// tables' FK to db_agent_definitions) — see this table's own doc
211/// comment at its CREATE TABLE for why.
212/// v24 — db_agent_native_memory_versions: append-only version history for
213/// native memory content, mirroring the v14 table's own dual-
214/// schema duplication (id_store per-channel fallback). See
215/// docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md.
216/// v25 — db_agent_groups: saved, named target sets for the fleet-control
217/// feature (broadcast/bulk-stop across many agent panes at once —
218/// docs/specs/SPEC_MULTI_AGENT_FLEET_CONTROL_2026_08_20.md). A
219/// group is just a durable list of block ids under a name — the
220/// Ansible-inventory-group lesson from that spec's research: a
221/// saved group, not raw ephemeral selection, is the abstraction
222/// that scales as a fleet grows. Per-channel (block ids are only
223/// meaningful within their own channel), not the shared store.
224/// Renumbered from an earlier v24 — merged alongside the native-
225/// memory-version-history PR, which independently claimed v24 for
226/// an unrelated table (see that entry above for the same class of
227/// collision this codebase has hit before, e.g. v20→v22).
228/// v26 — db_agent_definitions.conversation_visibility (TEXT, default
229/// 'private') + db_conversation_trust_grants: `muxspect` Phase B/C's
230/// per-agent disclosure policy for an incoming cross-tier
231/// `transcript_request` jekt (LAN/WAN conversation visibility —
232/// docs/specs/SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md,
233/// jekt tier rules confirmed in
234/// docs/specs/SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md).
235/// One of "private" (default, auto-deny)/"trusted_peers" (auto-
236/// approve an allow-listed requester)/"ask" (force human
237/// escalation). Also dual-written to `db_agents` (mirrors
238/// `auto_continue_enabled`'s treatment exactly — a simple per-agent
239/// opt-in-style setting, unlike `memory_id`/`model_vendor_base_url`,
240/// which use a differently-named column there). Deliberately NOT
241/// added to `DefinitionRecordV1`'s cross-channel wire format —
242/// channel-local only, same precedent as those two fields; a
243/// cross-channel-reopened agent starts back at the safe "private"
244/// default. `db_conversation_trust_grants` mirrors
245/// `db_lan_peer_pubkey_pins`'s exact shape (own module,
246/// get/set-style methods, case-insensitive agent_id lookups).
247/// v27 — db_bundles.is_system: an AgentMux-controlled, highest-priority
248/// Global Bundle tier (INTEGER, default 0). A system row is always
249/// also is_global=1 (enforced in code, not a CHECK constraint —
250/// see `bundle_upsert_system`). Writable only through the
251/// dedicated `upsertsystemmemory`/`deletesystemmemory` RPCs and
252/// `Store::bundle_upsert_system`/`_delete_system` — the
253/// generic `bundle_upsert`/`_delete`/`_reorder` all refuse
254/// to touch an is_system=1 row. Injected first in
255/// `format_global_bundle_block`'s output, wrapped in explicit
256/// override wording, ahead of every ordinary Global Bundle
257/// section. See docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md.
258/// v28 — db_agent_activity_summaries: one-shot, on-demand Haiku-generated
259/// activity summary per `definition_id`, used as the AgentPicker's
260/// "My Agents" conversation-preview fallback when a row has no
261/// structured `output.state.json` snapshot to preview (legacy rows
262/// predating snapshot persistence — see `has_snapshot` in
263/// `listrecentsessions`, `agent_handlers/session.rs`). Generated
264/// from the instance's raw `"output"` filestore file (present even
265/// when the newer structured snapshot isn't) via the same shared
266/// Ambient Model Call gateway `dispatch_name`/`term:ambient_summary`
267/// already use (`invoke_haiku`,
268/// `docs/specs/SPEC_AMBIENT_MODEL_CALLS_FRAMEWORK_2026_07_03.md`).
269/// Generated lazily (first picker load that needs it, not on every
270/// turn) and cached forever once non-empty — mirrors
271/// `SubAgent.display_name`'s cache-once posture, not
272/// `term:ambient_summary`'s per-turn refresh. Renumbered from an
273/// earlier v27 — merged alongside the Global Bundle system-tier PR
274/// (#2782), which independently claimed v27 for an unrelated
275/// column (same collision class as this file's own v20→v22 and
276/// v24→v25 history, see those entries above). See
277/// docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md §5a.
278/// v29 — db_agents.session_id / status / started_at / ended_at: the
279/// agent's LATEST launch state, the four `db_agent_instances`
280/// columns the consolidated row never got (Phase 3a deliberately
281/// left them "on the block", but the recent-sessions picker, the
282/// pane close/reopen continuity write-back and the status-filtered
283/// instance list all kept reading them from the legacy table — the
284/// reason it could not be dropped). One row per agent ⇒ one launch
285/// state per agent; a continuation moves it to the new block/session.
286/// Backfilled from the legacy rows by
287/// `m0025_agents_launch_state_backfill` (latest instance per
288/// projection key), kept current by the instance dual-write, and
289/// the read flip in `SPEC_AGENT_ARCHITECTURE_2026_05_27.md` Phase
290/// 3b retires the legacy reads on top of it. No unique index on
291/// `db_agents(slug)` is added: template-launch projections copy
292/// their template's slug, so uniqueness stays a definition-level
293/// rule enforced by the slug collision scan in `agent_def_insert`.
294/// v30 — `db_agent_content` / `db_agent_skills` / `db_agent_history` /
295/// `db_agent_identity_links` / `db_agent_skills_ref` /
296/// `db_agent_mcp_ref`: `agent_id`'s foreign key now targets
297/// `db_agents(id)` instead of `db_agent_definitions(id)`. Every
298/// existing row already satisfies the new target (its `agent_id` is
299/// a definition id, and every definition has a `db_agents` mirror
300/// at the same id) — what changes is that a `db_agents` row with NO
301/// definition counterpart (a template launch) can now hold these
302/// directly too, closing the gap `listrecentsessions`' template
303/// fallback (Phase 3b, #3080) exists for. SQLite has no in-place FK
304/// redefinition, so an existing store needs its six tables rebuilt;
305/// `m0028_agent_child_tables_repoint_fk` does that (rename,
306/// recreate, copy, drop, `PRAGMA foreign_key_check` before commit).
307/// A fresh install gets the new FK directly from the CREATE TABLE
308/// statements below — nothing to rebuild there. Phase 3c
309/// (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md`).
310/// v31 — No DDL change. Every `agent_def_*` write now lands on
311/// `db_agents` only — `db_agent_definitions` is no longer kept in
312/// sync (Phase 3d, #3092). Bumped anyway (codex P1 on #3092):
313/// `check_schema_compat` is a downgrade guard as much as a schema
314/// guard, and this release changes what a NEWER binary's writes
315/// mean to an OLDER one just as much as a column would. Without the
316/// bump, rolling back to the immediately preceding binary after
317/// this version has run would silently accept a store where new
318/// agents exist only in `db_agents` — that binary's `agent_def_get`/
319/// `update`/`delete` still target `db_agent_definitions` and would
320/// 404 on them, or replay stale legacy values back into `db_agents`
321/// on edit. Stamping 31 makes that downgrade refuse to open instead.
322/// v32 — `db_agent_definitions` and `db_agent_instances` are dropped.
323/// Nothing has read or written either table since v31 (agent-concept
324/// consolidation's definition and instance flips, #3080/#3092); this
325/// is the final phase (Phase 3e,
326/// `SPEC_AGENT_ARCHITECTURE_2026_05_27.md`) closing out the tracking
327/// spec. `db_agent_instances`' `CREATE TABLE`/indexes and
328/// `db_agent_definitions`' `CREATE TABLE` are gone from this function
329/// — leaving either would silently recreate the tables (empty) on
330/// the very next `Store::open()` after the drop migration runs,
331/// since migrations execute before this function does on every
332/// boot. `db_agent_definitions`' own `ALTER TABLE ADD COLUMN`
333/// entries, by contrast, are KEPT — gated on the table still
334/// existing (see that block's own comment below) — because several
335/// pending migrations (m0007, m0025, m0026, m0028) call
336/// `Store::open()` internally, running this function, before m0029
337/// (numbered last) ever drops the table in the SAME upgrade pass; a
338/// store old enough to be missing one of those columns would fail
339/// those migrations' own reads with "no such column" otherwise
340/// (Codex P1 on #3096). An existing store's tables are actually
341/// removed by `m0029_drop_legacy_agent_tables`; a fresh install
342/// never has them to begin with, so the gate is a no-op for it.
343/// `adopt_legacy_table_names`'s
344/// `db_forge_agents` → `db_agent_definitions` rename entry is left
345/// in place deliberately: an install old enough to still carry
346/// `db_forge_agents` reaches it during an EARLIER migration's own
347/// `Store::open()` call (every migration in this chain lays down
348/// schema that way), so the rename still fires before
349/// `m0029` runs later in the same upgrade pass and correctly drops
350/// what the rename just produced — no special-casing needed, and no
351/// data is stranded under the old name.
352/// v33 — `db_agent_project_instructions`: what each agent was last observed
353/// to read as project instructions from its working directory, keyed
354/// `(agent_id, path)`. Phase 3 of
355/// `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`.
356///
357/// Records a content hash and an observation time, never the content:
358/// this is a tracking table, not a mirror. The distinction from
359/// `db_agent_native_memory` above is deliberate — that one stores
360/// content because it is a durable copy of files AgentMux may have to
361/// serve when the originals are unreachable, whereas these files
362/// belong to the repository, are always readable from it, and are
363/// never written by AgentMux. Storing their content would create a
364/// second copy of somebody else's file with no one to keep it honest.
365///
366/// Purely additive: nothing reads it at launch, and an agent with no
367/// row simply has no prior observation to compare against.
368/// v34 — `db_agent_skills_ref` / `db_agent_mcp_ref` / `db_bundle_skills_ref`
369/// / `db_bundle_mcp_ref` drop their `skill_id`/`mcp_id` FK to this
370/// store's LOCAL `db_skills`/`db_mcp_servers`. Phase 2 of
371/// `SPEC_DURABLE_BINDINGS_2026_09_10.md` §5.4 (Codex P1, PR #3182):
372/// once catalog writes redirect to `identity_store`, this channel's
373/// local catalog copies stop gaining new rows, so binding a
374/// post-redirect skill/server would otherwise hit a hard `FOREIGN KEY
375/// constraint failed` (`PRAGMA foreign_keys=ON` in production, see
376/// `Store::configure_and_migrate`). The `agent_id → db_agents` FK on
377/// the agent-level pair is kept verbatim — only the catalog-pointing
378/// FK is dropped. Same shape `db_bundle_skills_ref`/`db_bundle_mcp_ref`
379/// already used for `db_bundles` (see those tables' own doc comment
380/// below): existence moves to the application layer
381/// (`managed_bind_agent`/`managed_bind_bundle`'s new `catalog: &Store`
382/// check). SQLite has no `ALTER TABLE ... DROP CONSTRAINT`, so an
383/// existing store's four tables are rebuilt by
384/// `m0032_drop_catalog_fk_from_ref_tables`; a fresh install gets the
385/// trimmed FK directly from the CREATE TABLE statements below.
386/// v35 — db_bundle_versions: append-only version history for Global
387/// Memory entry content (`name` + `instructions`), one row per
388/// write through the new agent-facing Global Memory API
389/// (`docs/specs/SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md`
390/// Phase 0) — mirrors `db_agent_native_memory_versions`'s shape
391/// exactly, keyed by a single `bundle_id` instead of that table's
392/// composite `(agent_id, filename)`, since a Bundle's identity is
393/// already a single id. Global Memory (`db_bundles`) previously had
394/// no audit trail at all — only `updated_at`, no way to see what
395/// changed, when, or by which agent, unlike native memory which has
396/// had this since v24. Opened specifically because Global Memory is
397/// about to gain a write path an agent (not just a human at the
398/// Armory UI) can reach. `written_by` is a TRUSTED identity column
399/// (an agent's real `AGENTMUX_AGENT_ID`, or `"armory-ui"`),
400/// deliberately separate from the caller-describable `source`/
401/// `source_detail` pair — an agent could otherwise claim
402/// `source: "human"` with nothing to contradict it (codex P2, PR
403/// #3237, caught before this table ever shipped).
404/// v36 — db_agent_wan_keys: per-agent Ed25519 keypair for general
405/// agent-to-agent WAN-tier jekt signing (issue #2586's second half).
406/// Mirrors v20's shape; a SEPARATE keypair from db_agent_lan_keys by
407/// design, not a reuse — LAN public keys are permanently TOFU-pinned
408/// by peers (v21), which is why they cannot rotate
409/// (SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md §4), whereas WAN
410/// keys are served from muxbus's authoritative ownership registry and
411/// are meant to. One key cannot have both lifecycles. `key_version`
412/// is reserved for the publish-sync and rotation phases (that spec's
413/// §3.2/§3.6) and is not yet read by anything.
414/// v37 — db_agent_tokens: per-agent local identity token, keyed by the
415/// agent's UID (`db_agents.id`) — identity M1 of
416/// `SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md` (§6, §9.1).
417/// Minted on first spawn (`agent_token_ensure`), injected into that
418/// one agent's process env as `AGENTMUX_AGENT_TOKEN`, revoked on
419/// delete via `purge_agent_dependents`. Unlike v18's jekt key and the
420/// registry entry files, which are keyed by display name and so are
421/// shared by two same-named agents (spec §6.3), this is keyed by a
422/// `PRIMARY KEY` that is a UUID for every non-template row. Not read
423/// by anything until M4 — mint-and-carry only, revertible by ignoring
424/// the field. See `storage/agent_tokens.rs`.
425/// v38 — db_agent_key_tombstones: the lowercased names (slug, display
426/// name, instance name) of every deleted agent — identity M4a of
427/// `SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md` §6.5.4.
428/// Signing keys are keyed by name, so a later agent that takes a
429/// deleted agent's name would inherit its keys; M4d copies name-keyed
430/// keys to UIDs and must never copy a tombstoned name's. Written from
431/// M4a on, before any copy exists, so the record is complete when M4d
432/// reads it. Written only — nothing reads it until M4d.
433/// v39 — db_bundle_versions.written_by_uid: the writer's UID beside the
434/// `written_by` name, taken from the request's `Caller` (its
435/// `X-Agent-Token`), never from a body field; empty for an
436/// Unattributed write, the Armory UI and the seeders. Dual-written,
437/// not yet read. Not only parity with the shared store: when that
438/// store is unavailable `id_store` falls back to this one and writes
439/// here. Identity M4c-1 of
440/// `SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md` §6.5.9.
441/// v40 — db_jekt_held: a jekt to a known agent of this channel that no
442/// delivery tier could take (the agent was not running anywhere), held
443/// for up to 24 h and replayed when it registers, with the trust
444/// verdicts it was accepted with as explicit columns.
445/// `SPEC_DURABLE_JEKT_DELIVERY_2026_09_24.md` Phase 1.
446/// v41 — db_background_tasks.owner_tool_use_id: the `tool_use_id` of the
447/// Agent call whose subagent launched this background task (the
448/// `parent_tool_use_id` on the stream line that issued the Bash call);
449/// NULL for the agent's own tasks. Joins a task to its subagent, whose
450/// `meta.json` records the same id. Phase 2 of
451/// `SPEC_BACKGROUND_TASK_STRUCTURED_FEED_AND_SWARM_OWNERSHIP_2026_09_27.md`.
452/// v42 — db_agents.last_runtime: the Runtime menu choices (permission mode,
453/// model, effort) the user last made for this agent, as a JSON object,
454/// '' when none. Lets Continue / Reattach / a fresh open start where
455/// the user left off instead of on the definition's defaults. Written
456/// only when the user picked something. Item 2 of
457/// `SPEC_RUNTIME_MENU_REMAINING_GAPS_2026_10_01.md`.
458/// v43 — db_viewer_devices: the devices paired with this channel's viewer
459/// listener, each with the SHA-256 of its viewer token (never the
460/// token), its name, its optional public key and when it was paired
461/// and last seen; plus db_agents.hide_from_devices, an agent's opt-out
462/// from the viewer routes. agentmux-mobile's
463/// SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07 §13.2, §4.3. See
464/// `storage/viewer_devices.rs`.
465/// v44 — db_mcp_server_reach: per catalog MCP server, whether it reaches
466/// every agent (`all_agents`) and whether container agents get it
467/// too (`in_containers`). A table of its own beside `db_mcp_servers`,
468/// so nothing that copies catalog rows changes. Both default 0: a
469/// catalog server reaches only the agents it is bound to.
470/// `SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` §2.1.
471pub const OBJECT_SCHEMA_VERSION: i64 = 44;
472/// `user_version` value stamped into `filestore.db`.
473pub const FILESTORE_SCHEMA_VERSION: i64 = 1;
474/// `user_version` value stamped into `sagas.db`.
475pub const SAGA_LOG_SCHEMA_VERSION: i64 = 1;
476
477/// Object type table names matching the `db_<otype>` convention.
478const WSTORE_OTYPES: &[&str] = &[
479 "client",
480 "window",
481 "workspace",
482 "tab",
483 "layout",
484 "block",
485 "temp",
486];
487
488/// Legacy `objects.db` table names retired by the de-forge rename, paired
489/// with their replacement. `adopt_legacy_table_names` renames any of these
490/// it finds — the single surviving piece of the old migration chain (it
491/// also subsumes the v11 `db_memories` rename).
492const LEGACY_TABLE_RENAMES: &[(&str, &str)] = &[
493 ("db_forge_agents", "db_agent_definitions"),
494 ("db_forge_content", "db_agent_content"),
495 ("db_forge_skills", "db_agent_skills"),
496 ("db_forge_history", "db_agent_history"),
497 ("db_forge_agent_identities", "db_agent_identity_links"),
498 ("db_memories", "db_memory_bundles"),
499 // Phase 4 of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md — the
500 // "bundle" naming collision fix (SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md).
501 // Order matters: this entry runs AFTER the `db_memories` rename above
502 // in the same pass, so a database old enough to still have `db_memories`
503 // chains straight through to `db_bundles` in one migration run instead
504 // of stopping at the intermediate name.
505 ("db_identity_accounts", "db_accounts"),
506 ("db_memory_bundles", "db_bundles"),
507];
508
509/// Legacy index names that must be dropped after their table is renamed —
510/// `ALTER TABLE … RENAME` keeps indexes attached but under their old names,
511/// which would collide with the flat DDL's `CREATE INDEX`. The flat DDL
512/// recreates each under the new name.
513const LEGACY_INDEX_DROPS: &[&str] = &[
514 "idx_forge_agents_slug",
515 "idx_forge_history_agent_date",
516 "idx_forge_agent_identities_account",
517 "idx_memories_is_blank",
518 // Phase 4 rename (see LEGACY_TABLE_RENAMES above) — objects.db names,
519 // then the shared store.db names (`idx_ss_*` prefix, see
520 // run_shared_store_schema).
521 "idx_identity_accounts_provider",
522 "idx_memory_bundles_is_blank",
523 "idx_ss_identity_accounts_provider",
524 "idx_ss_memory_bundles_is_blank",
525];
526
527/// Tables retained by the old chain only for a downgrade path the flatten
528/// abandons. Dropped from any legacy DB by the adopt step; never created
529/// by the flat schema. `db_workflow_*` data was already copied into
530/// `db_drone_*` by the old v10 migration, so dropping loses nothing.
531///
532/// `db_identities` (the pre-v11 name), `db_identity_bundles`, and
533/// `db_identity_bindings` were dropped outright rather than renamed
534/// forward in Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md —
535/// `db_agent_identity_links`/`db_accounts` is the sole credential-
536/// resolution path (`identity/resolver/inject.rs::resolve_bindings_for_instance`),
537/// confirmed via the already-applied `m0013`/`m0014` backfill migrations
538/// (see SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md §6/§8a).
539const DEAD_TABLE_DROPS: &[&str] = &[
540 "db_workflow_definitions",
541 "db_workflow_runs",
542 "db_v10_migrated_legacy_defs",
543 "db_v10_migrated_legacy_runs",
544 "db_identities",
545 "db_identity_bundles",
546 "db_identity_bindings",
547];
548
549/// Initialize (or re-validate) the full `objects.db` schema.
550///
551/// Idempotent — safe on every srv startup. Steps:
552///
553/// 1. `adopt_legacy_table_names` — renames any pre-flatten forge/bundle
554/// tables found (protects dev databases created before the flatten;
555/// see the spec §3/§7) and drops the dead workflow/sentinel tables.
556/// 2. The flat `CREATE TABLE IF NOT EXISTS` batch — the canonical schema.
557/// 3. Seeds the blank Bundle singleton row.
558///
559/// A database stuck at a pre-v11 intermediate schema cannot be fully
560/// adopted (its tables predate later columns). The adopt step still
561/// renames what it finds; the first query referencing a missing column
562/// then fails loudly with `no such column` — a hard error, not silent
563/// empty state, with the data preserved on disk. Per-version data dirs
564/// make that case unreachable for released builds.
565pub fn run_object_schema(conn: &Connection) -> Result<(), StoreError> {
566 adopt_legacy_table_names(conn)?;
567
568 // ---- Generic StoreObj object tables ----
569 for otype in WSTORE_OTYPES {
570 conn.execute_batch(&format!(
571 "CREATE TABLE IF NOT EXISTS db_{otype} (
572 oid TEXT PRIMARY KEY,
573 version INTEGER NOT NULL DEFAULT 1,
574 data TEXT NOT NULL
575 );"
576 ))?;
577 }
578
579 // ---- Agent + identity + memory + drone schema ----
580 conn.execute_batch(
581 "CREATE TABLE IF NOT EXISTS db_agent_content (
582 agent_id TEXT NOT NULL,
583 content_type TEXT NOT NULL,
584 content TEXT NOT NULL DEFAULT '',
585 updated_at INTEGER NOT NULL DEFAULT 0,
586 PRIMARY KEY (agent_id, content_type),
587 -- v30: targets db_agents(id), not db_agent_definitions(id) — see
588 -- OBJECT_SCHEMA_VERSION's v30 doc comment above.
589 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
590 );
591
592 CREATE TABLE IF NOT EXISTS db_agent_skills (
593 id TEXT PRIMARY KEY,
594 agent_id TEXT NOT NULL,
595 name TEXT NOT NULL,
596 trigger TEXT NOT NULL DEFAULT '',
597 skill_type TEXT NOT NULL DEFAULT 'prompt',
598 description TEXT NOT NULL DEFAULT '',
599 content TEXT NOT NULL DEFAULT '',
600 created_at INTEGER NOT NULL DEFAULT 0,
601 -- v30: see OBJECT_SCHEMA_VERSION's v30 doc comment above.
602 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
603 );
604
605 CREATE TABLE IF NOT EXISTS db_agent_history (
606 id INTEGER PRIMARY KEY AUTOINCREMENT,
607 agent_id TEXT NOT NULL,
608 session_date TEXT NOT NULL,
609 entry TEXT NOT NULL,
610 timestamp INTEGER NOT NULL DEFAULT 0,
611 -- v30: see OBJECT_SCHEMA_VERSION's v30 doc comment above.
612 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
613 );
614 CREATE INDEX IF NOT EXISTS idx_agent_history_agent_date
615 ON db_agent_history(agent_id, session_date);
616
617 CREATE TABLE IF NOT EXISTS db_accounts (
618 id TEXT PRIMARY KEY,
619 name TEXT NOT NULL,
620 provider TEXT NOT NULL,
621 kind TEXT NOT NULL,
622 display_name TEXT NOT NULL DEFAULT '',
623 secret_ref TEXT NOT NULL,
624 context TEXT NOT NULL DEFAULT '{}',
625 status TEXT NOT NULL DEFAULT 'unknown',
626 created_at INTEGER NOT NULL DEFAULT 0,
627 updated_at INTEGER NOT NULL DEFAULT 0
628 );
629 CREATE INDEX IF NOT EXISTS idx_accounts_provider
630 ON db_accounts(provider);
631
632 CREATE TABLE IF NOT EXISTS db_agent_identity_links (
633 agent_id TEXT NOT NULL,
634 account_id TEXT NOT NULL,
635 provider TEXT NOT NULL,
636 PRIMARY KEY (agent_id, provider),
637 -- v30: see OBJECT_SCHEMA_VERSION's v30 doc comment above.
638 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE,
639 FOREIGN KEY (account_id) REFERENCES db_accounts(id) ON DELETE CASCADE
640 );
641 CREATE INDEX IF NOT EXISTS idx_agent_identity_links_account
642 ON db_agent_identity_links(account_id);
643
644 CREATE TABLE IF NOT EXISTS db_bundles (
645 id TEXT PRIMARY KEY,
646 name TEXT NOT NULL UNIQUE,
647 description TEXT NOT NULL DEFAULT '',
648 is_blank INTEGER NOT NULL DEFAULT 0,
649 is_global INTEGER NOT NULL DEFAULT 0,
650 provider TEXT NOT NULL DEFAULT '',
651 model TEXT NOT NULL DEFAULT '',
652 instructions TEXT NOT NULL DEFAULT '',
653 instructions_by_provider TEXT NOT NULL DEFAULT '{}',
654 context_files TEXT NOT NULL DEFAULT '[]',
655 mcp_servers TEXT NOT NULL DEFAULT '[]',
656 skills TEXT NOT NULL DEFAULT '[]',
657 sort_order INTEGER NOT NULL DEFAULT 0,
658 created_at INTEGER NOT NULL DEFAULT 0,
659 updated_at INTEGER NOT NULL DEFAULT 0,
660 is_system INTEGER NOT NULL DEFAULT 0
661 );
662 CREATE INDEX IF NOT EXISTS idx_bundles_is_blank
663 ON db_bundles(is_blank);
664
665 -- Phase 3a consolidation: `db_agents` collapses the legacy
666 -- definitions + instances tables into one table per
667 -- `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
668 -- WRITE-ONLY in 3a: every old-table mutation dual-writes here, but
669 -- every read still hits the old tables. Phase 3b migrates readers,
670 -- Phase 3c drops the old tables. Column names align with the live
671 -- `db_agent_definitions` shape (provider, working_directory, …) —
672 -- NOT the inline draft in the spec (provider_id, cmd, cmd_args, …) —
673 -- because the existing storage layer never grew the cmd-template
674 -- columns the spec sketched; carrying the names we actually have
675 -- avoids inventing data we don't store. See the PR body for the
676 -- field-by-field mapping.
677 CREATE TABLE IF NOT EXISTS db_agents (
678 id TEXT PRIMARY KEY,
679 name TEXT NOT NULL,
680 icon TEXT NOT NULL DEFAULT '',
681 description TEXT NOT NULL DEFAULT '',
682
683 -- Template vs user agent
684 is_template INTEGER NOT NULL DEFAULT 0,
685 parent_template_id TEXT NOT NULL DEFAULT '',
686
687 -- Provider/cmd config (was on definition; named to match the
688 -- live `db_agent_definitions` columns).
689 provider TEXT NOT NULL,
690 provider_flags TEXT NOT NULL DEFAULT '',
691 shell TEXT NOT NULL DEFAULT '',
692 environment TEXT NOT NULL DEFAULT '',
693 agent_type TEXT NOT NULL DEFAULT 'standalone',
694 agent_bus_id TEXT NOT NULL DEFAULT '',
695 accounts TEXT NOT NULL DEFAULT '',
696 auto_start INTEGER NOT NULL DEFAULT 0,
697 restart_on_crash INTEGER NOT NULL DEFAULT 0,
698 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
699 slug TEXT NOT NULL DEFAULT '',
700 branch_label TEXT NOT NULL DEFAULT '',
701
702 -- Bindings (was on instance — only meaningful when is_template=0).
703 -- For template rows these stay empty.
704 identity_id TEXT NOT NULL DEFAULT '',
705 memory_id TEXT NOT NULL DEFAULT '',
706 working_directory TEXT NOT NULL DEFAULT '',
707 github_context TEXT NOT NULL DEFAULT '',
708 instance_name TEXT NOT NULL DEFAULT '',
709
710 -- Latest launch's block: pointer to the most-recent session's
711 -- block so the consolidated read can locate the conversation
712 -- snapshot without joining db_agent_instances. Was the only
713 -- per-launch field db_agents retained until v29 added the
714 -- four launch-state columns right below it.
715 last_block_id TEXT NOT NULL DEFAULT '',
716
717 -- Latest launch's state (v29, consolidation Phase 3b/3c). The
718 -- original 3a design left status/session_id/started_at/ended_at
719 -- to the block; in practice the recent-sessions picker, the
720 -- pane close/reopen continuity guarantee
721 -- (SPEC_PANE_CLOSE_REOPEN_CONTINUITY_GUARANTEE_2026_07_27.md §4.1)
722 -- and the status-filtered instance list all still read them from
723 -- db_agent_instances, which is what kept that table alive. One
724 -- row per agent means one launch state per agent: a continuation
725 -- moves these to the new block/session rather than adding a row.
726 session_id TEXT NOT NULL DEFAULT '',
727 status TEXT NOT NULL DEFAULT '',
728 started_at INTEGER NOT NULL DEFAULT 0,
729 ended_at INTEGER NOT NULL DEFAULT 0,
730
731 -- Provenance
732 created_at INTEGER NOT NULL DEFAULT 0,
733 updated_at INTEGER NOT NULL DEFAULT 0,
734 is_seeded INTEGER NOT NULL DEFAULT 0,
735 user_hidden INTEGER NOT NULL DEFAULT 0,
736
737 -- Container support (Schema v6 / Phase 0).
738 -- Empty for host agents; populated by ContainerManager.
739 container_image TEXT NOT NULL DEFAULT '',
740 container_volumes TEXT NOT NULL DEFAULT '[]',
741 container_name TEXT NOT NULL DEFAULT '',
742
743 -- Explicit per-agent opt-in to the CLI's global (ambient) login
744 -- when no oauth-class account resolves at spawn (schema v12,
745 -- SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md §2.3).
746 use_ambient_login INTEGER NOT NULL DEFAULT 0,
747
748 -- Redirects this agent's harness at a non-default model vendor
749 -- backend (schema v15) — see db_agent_definitions' column doc.
750 model_vendor_base_url TEXT NOT NULL DEFAULT '',
751
752 -- Warden Supervisor auto-continue opt-in (schema v17) — see
753 -- db_agent_definitions' column doc.
754 auto_continue_enabled INTEGER NOT NULL DEFAULT 0,
755
756 -- The DEFINITION's own dedicated ABF bundle (schema v19) —
757 -- mirrors db_agent_definitions.memory_id. Deliberately a
758 -- DIFFERENT column from memory_id above: that one is
759 -- instance-scoped (only meaningful when is_template=0,
760 -- written exclusively by the instance dual-write helpers) and
761 -- this migration doesn't touch its semantics. default_memory_id
762 -- is what agent_def_list's db_agents-backed read actually
763 -- surfaces as AgentDefinition.memory_id (aliased in the SELECT
764 -- below) -- readers that already consult db_agents for listing
765 -- need this to see the definition-level binding at all.
766 default_memory_id TEXT NOT NULL DEFAULT ''
767 );
768 CREATE INDEX IF NOT EXISTS idx_agents_is_template
769 ON db_agents(is_template);
770 CREATE INDEX IF NOT EXISTS idx_agents_parent_template_id
771 ON db_agents(parent_template_id);
772 CREATE INDEX IF NOT EXISTS idx_agents_is_seeded
773 ON db_agents(is_seeded);
774
775 CREATE TABLE IF NOT EXISTS db_drone_definitions (
776 id TEXT PRIMARY KEY,
777 name TEXT NOT NULL,
778 description TEXT NOT NULL DEFAULT '',
779 graph TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
780 viewport TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
781 created_at INTEGER NOT NULL DEFAULT 0,
782 updated_at INTEGER NOT NULL DEFAULT 0
783 );
784 CREATE INDEX IF NOT EXISTS idx_drone_definitions_updated
785 ON db_drone_definitions(updated_at DESC);
786
787 CREATE TABLE IF NOT EXISTS db_drone_runs (
788 id TEXT PRIMARY KEY,
789 drone_id TEXT NOT NULL,
790 status TEXT NOT NULL DEFAULT 'running',
791 started_at INTEGER NOT NULL DEFAULT 0,
792 ended_at INTEGER NOT NULL DEFAULT 0,
793 block_states TEXT NOT NULL DEFAULT '{}',
794 output TEXT NOT NULL DEFAULT '',
795 error TEXT NOT NULL DEFAULT '',
796 FOREIGN KEY (drone_id) REFERENCES db_drone_definitions(id) ON DELETE CASCADE
797 );
798 CREATE INDEX IF NOT EXISTS idx_drone_runs_drone_started
799 ON db_drone_runs(drone_id, started_at DESC);
800 CREATE INDEX IF NOT EXISTS idx_drone_runs_status
801 ON db_drone_runs(status);
802
803 -- v10: Standalone skill and MCP server primitives (composable model).
804 CREATE TABLE IF NOT EXISTS db_skills (
805 id TEXT PRIMARY KEY,
806 name TEXT NOT NULL,
807 trigger TEXT NOT NULL DEFAULT '',
808 skill_type TEXT NOT NULL DEFAULT 'prompt',
809 description TEXT NOT NULL DEFAULT '',
810 content TEXT NOT NULL DEFAULT '',
811 is_global INTEGER NOT NULL DEFAULT 0,
812 created_at INTEGER NOT NULL DEFAULT 0,
813 updated_at INTEGER NOT NULL DEFAULT 0
814 );
815 CREATE INDEX IF NOT EXISTS idx_skills_is_global ON db_skills(is_global);
816
817 CREATE TABLE IF NOT EXISTS db_mcp_servers (
818 id TEXT PRIMARY KEY,
819 name TEXT NOT NULL,
820 transport TEXT NOT NULL DEFAULT 'stdio',
821 config TEXT NOT NULL DEFAULT '{}',
822 is_global INTEGER NOT NULL DEFAULT 0,
823 created_at INTEGER NOT NULL DEFAULT 0,
824 updated_at INTEGER NOT NULL DEFAULT 0
825 );
826 CREATE INDEX IF NOT EXISTS idx_mcp_servers_is_global ON db_mcp_servers(is_global);
827 -- v44: who a catalog server reaches beyond its
828 -- binds — see OBJECT_SCHEMA_VERSION's v44 doc comment.
829 CREATE TABLE IF NOT EXISTS db_mcp_server_reach (
830 mcp_id TEXT PRIMARY KEY,
831 all_agents INTEGER NOT NULL DEFAULT 0,
832 in_containers INTEGER NOT NULL DEFAULT 0,
833 updated_at INTEGER NOT NULL DEFAULT 0
834 );
835
836 -- v34: skill_id's FK to this store's LOCAL db_skills is gone — see
837 -- OBJECT_SCHEMA_VERSION's v34 doc comment above. agent_id's FK to
838 -- db_agents (v30) is unchanged.
839 CREATE TABLE IF NOT EXISTS db_agent_skills_ref (
840 agent_id TEXT NOT NULL,
841 skill_id TEXT NOT NULL,
842 PRIMARY KEY (agent_id, skill_id),
843 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
844 );
845
846 -- v34: mcp_id's FK to this store's LOCAL db_mcp_servers is gone —
847 -- see OBJECT_SCHEMA_VERSION's v34 doc comment above. agent_id's FK
848 -- to db_agents (v30) is unchanged.
849 CREATE TABLE IF NOT EXISTS db_agent_mcp_ref (
850 agent_id TEXT NOT NULL,
851 mcp_id TEXT NOT NULL,
852 PRIMARY KEY (agent_id, mcp_id),
853 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
854 );
855
856 -- v23: Bundle-level MCP/skill references (composable model v2,
857 -- docs/specs/SPEC_BUNDLE_AS_CONTAINER_V2_2026_08_17.md) — same shape
858 -- as the agent-level ref tables above, keyed by bundle_id instead of
859 -- agent_id. A bundle's existing `mcp_servers`/`skills` JSON columns
860 -- stay untouched (still consumed by .abf export/import); these ref
861 -- tables are the new live-resolution path, wired into launch-time
862 -- config generation alongside the agent-level refs.
863 --
864 -- reagentx P0 review on PR #2639: NO foreign key to db_bundles here,
865 -- unlike the agent-level ref tables' FK to db_agents (v30: was
866 -- db_agent_definitions — see OBJECT_SCHEMA_VERSION's v30 doc comment).
867 -- Bundles are authoritatively written through `id_store` (the
868 -- shared store in a normal production install — see
869 -- `bundle.rs::register_bundle_upsert`), not `mstore`/objects.db,
870 -- where this table lives (it must live here to FK to
871 -- db_mcp_servers/db_skills, which used to be mstore-local too (now
872 -- authoritatively identity_store as of Phase 2 — see
873 -- OBJECT_SCHEMA_VERSION's v34 doc comment). mstore's own copy of
874 -- db_bundles is a schema-compatible but essentially always-
875 -- empty local mirror in that case — an FK against it would make
876 -- every real bind fail. Same no-FK-to-a-table-living-in-the-wrong-
877 -- store reasoning as `db_agent_identity_links.account_id`'s
878 -- deliberate lack of an account FK (see that table's own doc
879 -- comment). Existence is checked at the application layer instead
880 -- — see `Store::bundle_skill_bind`'s `id_store` parameter.
881 --
882 -- v34: skill_id's FK to db_skills is ALSO gone now (it was
883 -- mstore-local before v34; see OBJECT_SCHEMA_VERSION's v34 doc
884 -- comment) — this table has never had a bundle_id FK, so it is now
885 -- FK-free entirely, existence checked purely at the application
886 -- layer for both columns.
887 CREATE TABLE IF NOT EXISTS db_bundle_skills_ref (
888 bundle_id TEXT NOT NULL,
889 skill_id TEXT NOT NULL,
890 PRIMARY KEY (bundle_id, skill_id)
891 );
892
893 -- v34: mcp_id's FK to db_mcp_servers is gone — see the
894 -- db_bundle_skills_ref comment above; same reasoning, MCP side.
895 -- No longer written or read: a bundle carries no MCP servers, and
896 -- m0036_drop_bundle_mcp emptied it. Still created, because an older
897 -- build sharing the channel expects it.
898 CREATE TABLE IF NOT EXISTS db_bundle_mcp_ref (
899 bundle_id TEXT NOT NULL,
900 mcp_id TEXT NOT NULL,
901 PRIMARY KEY (bundle_id, mcp_id)
902 );
903
904 -- v7: MuxBus cloud connectivity — global singleton PKCE token store.
905 CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
906 id TEXT PRIMARY KEY DEFAULT 'global',
907 cognito_domain TEXT NOT NULL DEFAULT '',
908 client_id TEXT NOT NULL DEFAULT '',
909 access_token TEXT NOT NULL DEFAULT '',
910 refresh_token TEXT NOT NULL DEFAULT '',
911 id_token TEXT NOT NULL DEFAULT '',
912 expires_at INTEGER NOT NULL DEFAULT 0,
913 user_email TEXT NOT NULL DEFAULT '',
914 user_sub TEXT NOT NULL DEFAULT ''
915 );
916
917 -- v13: per-agent M2M muxbus credential — see db_agent_credentials in
918 -- run_shared_store_schema's doc comment for the full rationale.
919 CREATE TABLE IF NOT EXISTS db_agent_credentials (
920 agent_id TEXT PRIMARY KEY,
921 client_id TEXT NOT NULL DEFAULT '',
922 client_secret TEXT NOT NULL DEFAULT '',
923 token_endpoint TEXT NOT NULL DEFAULT '',
924 access_token TEXT NOT NULL DEFAULT '',
925 expires_at INTEGER NOT NULL DEFAULT 0,
926 created_at INTEGER NOT NULL DEFAULT 0
927 );
928
929 -- v14: per-agent native-memory durable mirror — see
930 -- db_agent_native_memory in run_shared_store_schema's doc comment for
931 -- the full rationale (SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md).
932 -- No FK to db_agent_definitions: this table is duplicated into the
933 -- shared store below (same pattern as db_agent_credentials, for
934 -- id_store's per-channel fallback before 0011_shared_store_backfill),
935 -- and SQLite can't enforce a FK across separate database files.
936 -- Orphan rows after an agent's deleted are inert: every RPC handler
937 -- that would read this table first 404s on agent_def_get, so a
938 -- dangling mirror row is never reached, let alone surfaced.
939 CREATE TABLE IF NOT EXISTS db_agent_native_memory (
940 agent_id TEXT NOT NULL,
941 filename TEXT NOT NULL,
942 content TEXT NOT NULL,
943 metadata_type TEXT NOT NULL DEFAULT '',
944 size_bytes INTEGER NOT NULL DEFAULT 0,
945 updated_at INTEGER NOT NULL DEFAULT 0,
946 last_seen_path TEXT NOT NULL DEFAULT '',
947 last_seen_mtime_ms INTEGER NOT NULL DEFAULT 0,
948 PRIMARY KEY (agent_id, filename)
949 );
950
951 -- v33: what each agent was last observed to READ as project
952 -- instructions. Hash and timestamp only, never content — these files
953 -- belong to the repository and are always readable from it, unlike
954 -- db_agent_native_memory above, which mirrors content precisely
955 -- because the originals can become unreachable. See
956 -- SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md Phase 3.
957 CREATE TABLE IF NOT EXISTS db_agent_project_instructions (
958 agent_id TEXT NOT NULL,
959 path TEXT NOT NULL,
960 content_hash TEXT NOT NULL DEFAULT '',
961 size_bytes INTEGER NOT NULL DEFAULT 0,
962 owner TEXT NOT NULL DEFAULT 'foreign',
963 existed INTEGER NOT NULL DEFAULT 0,
964 observed_at INTEGER NOT NULL DEFAULT 0,
965 PRIMARY KEY (agent_id, path)
966 );
967
968 -- v24: append-only version history for native memory content — see
969 -- db_agent_native_memory_versions in run_shared_store_schema's doc
970 -- comment for the full rationale
971 -- (SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md).
972 -- Duplicated here for the same id_store-per-channel-fallback reason
973 -- as db_agent_native_memory itself, immediately above.
974 CREATE TABLE IF NOT EXISTS db_agent_native_memory_versions (
975 id TEXT PRIMARY KEY,
976 agent_id TEXT NOT NULL,
977 filename TEXT NOT NULL,
978 content TEXT NOT NULL,
979 content_hash TEXT NOT NULL,
980 parent_version_id TEXT,
981 source TEXT NOT NULL DEFAULT 'agent_inferred',
982 source_detail TEXT NOT NULL DEFAULT '{}',
983 session_id TEXT NOT NULL DEFAULT '',
984 created_at INTEGER NOT NULL DEFAULT 0
985 );
986 CREATE INDEX IF NOT EXISTS idx_native_memory_versions_lookup
987 ON db_agent_native_memory_versions(agent_id, filename, created_at);
988
989 -- v35: append-only version history for Global Memory entry content
990 -- — see OBJECT_SCHEMA_VERSION's own doc comment above for the full
991 -- rationale (SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md).
992 -- Single `bundle_id` key, not a composite one — a Bundle's identity
993 -- is already a single id, unlike native memory's (agent_id, filename).
994 CREATE TABLE IF NOT EXISTS db_bundle_versions (
995 id TEXT PRIMARY KEY,
996 bundle_id TEXT NOT NULL,
997 name TEXT NOT NULL,
998 instructions TEXT NOT NULL,
999 content_hash TEXT NOT NULL,
1000 parent_version_id TEXT,
1001 source TEXT NOT NULL DEFAULT 'agent_inferred',
1002 source_detail TEXT NOT NULL DEFAULT '{}',
1003 written_by TEXT NOT NULL DEFAULT '',
1004 created_at INTEGER NOT NULL DEFAULT 0,
1005 -- v39 (identity M4c-1): the writer's UID from the request's
1006 -- Caller, beside the written_by name. Empty when unattributed.
1007 written_by_uid TEXT NOT NULL DEFAULT ''
1008 );
1009 CREATE INDEX IF NOT EXISTS idx_bundle_versions_lookup
1010 ON db_bundle_versions(bundle_id, created_at);
1011
1012 -- v18: per-agent HMAC-SHA256 signing key for host-tier jekt sender
1013 -- verification (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2).
1014 -- hmac_key is base64-encoded, 32 random bytes, minted on first use
1015 -- (agent_jekt_key_ensure). As of
1016 -- SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md, a key past
1017 -- JEKT_KEY_TTL_SECS (24h) old is rotated the next time
1018 -- agent_jekt_key_ensure runs for it (that agent's next spawn) — not
1019 -- invalidated in place, so no expires_at column is needed; TTL is
1020 -- computed from created_at at read time. Local to this instance's
1021 -- data dir only — not synced anywhere, not the same secret as any
1022 -- Armory/GitHub credential.
1023 CREATE TABLE IF NOT EXISTS db_agent_jekt_keys (
1024 agent_id TEXT PRIMARY KEY,
1025 hmac_key TEXT NOT NULL,
1026 created_at INTEGER NOT NULL DEFAULT 0
1027 );
1028
1029 -- v20: per-agent Ed25519 keypair for LAN-tier jekt sender
1030 -- verification (SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.1).
1031 -- public_key is not secret (distributed to LAN peers on demand);
1032 -- private_key is base64, 32-byte seed, minted on first use
1033 -- (agent_lan_key_ensure) and never rotated automatically — and,
1034 -- unlike db_agent_jekt_keys, deliberately NOT given a TTL by
1035 -- SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md §4: rotating a LAN
1036 -- key would desync it from any peer's permanent
1037 -- db_lan_peer_pubkey_pins trust-on-first-use pin of the OLD key,
1038 -- turning every future signature into an unconditional forced-
1039 -- sensitive 'mismatch' for that peer with no re-pin mechanism to
1040 -- recover. Same local-to-this-instance-only guarantee as
1041 -- db_agent_jekt_keys otherwise.
1042 CREATE TABLE IF NOT EXISTS db_agent_lan_keys (
1043 agent_id TEXT PRIMARY KEY,
1044 public_key TEXT NOT NULL,
1045 private_key TEXT NOT NULL,
1046 created_at INTEGER NOT NULL DEFAULT 0
1047 );
1048
1049 -- v36: per-agent Ed25519 keypair for general agent-to-agent WAN-tier
1050 -- jekt signing.
1051 -- Same local-to-this-instance guarantee as db_agent_jekt_keys and
1052 -- db_agent_lan_keys: the private half leaves only by being injected
1053 -- into that ONE agent's own MCP process env (AGENTMUX_WAN_KEY) at
1054 -- spawn, never over any RPC. The public half is not secret — it is
1055 -- what gets published to muxbus's agent-ownership registry so a
1056 -- WAN counterpart can verify this agent's signatures.
1057 --
1058 -- Deliberately NOT a reuse of db_agent_lan_keys: see the v36 doc
1059 -- comment above for why the two tiers' keys must have independent
1060 -- lifecycles. `key_version` starts at 1 and is reserved for the
1061 -- version-synchronised publish (§3.2) and rotation (§3.6) phases;
1062 -- nothing reads it yet.
1063 CREATE TABLE IF NOT EXISTS db_agent_wan_keys (
1064 agent_id TEXT PRIMARY KEY,
1065 public_key TEXT NOT NULL,
1066 private_key TEXT NOT NULL,
1067 key_version INTEGER NOT NULL DEFAULT 1,
1068 created_at INTEGER NOT NULL DEFAULT 0
1069 );
1070
1071 -- v37: per-agent local identity token, keyed by the agent's UID
1072 -- (db_agents.id) rather than by display name -- identity M1,
1073 -- SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6/§9.1.
1074 -- Minted on first spawn, injected as AGENTMUX_AGENT_TOKEN into that
1075 -- one agent's process, revoked by purge_agent_dependents on delete.
1076 -- Not read by anything until M4; see storage/agent_tokens.rs.
1077 -- No FOREIGN KEY on purpose: a token must be mintable in the same
1078 -- critical section as the row it belongs to without ordering the
1079 -- two inserts, and purge_agent_dependents already owns cleanup.
1080 CREATE TABLE IF NOT EXISTS db_agent_tokens (
1081 agent_id TEXT PRIMARY KEY,
1082 token TEXT NOT NULL,
1083 created_at INTEGER NOT NULL DEFAULT 0
1084 );
1085
1086 -- v38: names of deleted agents, lowercased -- identity M4a,
1087 -- SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.4. A
1088 -- name-keyed signing key under a tombstoned name is never carried to
1089 -- a new agent's UID (M4d); the reuser gets a fresh keypair. Keyed by
1090 -- name because that is what the legacy key tables are keyed by.
1091 CREATE TABLE IF NOT EXISTS db_agent_key_tombstones (
1092 name TEXT PRIMARY KEY,
1093 deleted_uid TEXT NOT NULL,
1094 deleted_at INTEGER NOT NULL DEFAULT 0
1095 );
1096
1097 -- Identity M4d-2: each agent's LAN and WAN signing keypairs filed
1098 -- under its UID (db_agents.id), copied from the name-keyed tables on
1099 -- ownership evidence or minted fresh (storage/agent_uid_keys.rs,
1100 -- SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.10).
1101 -- copied_from is the name a key was copied from, '' when minted.
1102 -- Purged by UID in purge_agent_dependents. Additive, applied on
1103 -- every open, so no version bump.
1104 CREATE TABLE IF NOT EXISTS db_agent_lan_keys_by_uid (
1105 uid TEXT PRIMARY KEY,
1106 public_key TEXT NOT NULL,
1107 private_key TEXT NOT NULL,
1108 created_at INTEGER NOT NULL DEFAULT 0,
1109 copied_from TEXT NOT NULL DEFAULT ''
1110 );
1111 CREATE TABLE IF NOT EXISTS db_agent_wan_keys_by_uid (
1112 uid TEXT PRIMARY KEY,
1113 public_key TEXT NOT NULL,
1114 private_key TEXT NOT NULL,
1115 created_at INTEGER NOT NULL DEFAULT 0,
1116 copied_from TEXT NOT NULL DEFAULT ''
1117 );
1118 -- Identity M4d-6: a remote agent's UID key, pinned per (name, uid) on
1119 -- first sight beside a matching name pin (storage/lan_peer_uid_pins.rs).
1120 -- Additive, applied on every open, so no version bump.
1121 CREATE TABLE IF NOT EXISTS db_lan_peer_uid_pins (
1122 agent_id TEXT NOT NULL,
1123 uid TEXT NOT NULL,
1124 public_key TEXT NOT NULL,
1125 first_seen_at INTEGER NOT NULL DEFAULT 0,
1126 PRIMARY KEY (agent_id, uid)
1127 );
1128
1129 -- Identity M4d-1: every display / instance name an agent has had.
1130 -- Keys are filed under names (and their fallback ids), and every
1131 -- write path that renames an agent — rename, the continuation fold,
1132 -- anything later — leaves keys under the old name, so the purge must
1133 -- still find them. Recorded by trigger so no write path can miss it;
1134 -- never capped; deleted with the row. Additive, applied on every
1135 -- open, so no version bump.
1136 CREATE TABLE IF NOT EXISTS db_agent_former_names (
1137 agent_id TEXT NOT NULL,
1138 name TEXT NOT NULL,
1139 recorded_at INTEGER NOT NULL DEFAULT 0,
1140 PRIMARY KEY (agent_id, name),
1141 FOREIGN KEY (agent_id) REFERENCES db_agents(id) ON DELETE CASCADE
1142 );
1143 CREATE TRIGGER IF NOT EXISTS trg_agents_former_name
1144 AFTER UPDATE OF name ON db_agents
1145 WHEN old.is_template = 0 AND old.name <> new.name AND trim(old.name) <> ''
1146 BEGIN
1147 INSERT OR IGNORE INTO db_agent_former_names (agent_id, name, recorded_at)
1148 VALUES (old.id, old.name, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
1149 END;
1150 CREATE TRIGGER IF NOT EXISTS trg_agents_former_instance_name
1151 AFTER UPDATE OF instance_name ON db_agents
1152 WHEN old.is_template = 0
1153 AND COALESCE(old.instance_name, '') <> COALESCE(new.instance_name, '')
1154 AND trim(COALESCE(old.instance_name, '')) <> ''
1155 BEGIN
1156 INSERT OR IGNORE INTO db_agent_former_names (agent_id, name, recorded_at)
1157 VALUES (old.id, old.instance_name, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
1158 END;
1159
1160 -- v40: jekts held for an absent agent of this channel
1161 -- (SPEC_DURABLE_JEKT_DELIVERY_2026_09_24.md). Verdict columns are
1162 -- NULL (unchecked), 0 (failed) or 1 (verified).
1163 CREATE TABLE IF NOT EXISTS db_jekt_held (
1164 request_id TEXT PRIMARY KEY,
1165 target_uid TEXT NOT NULL,
1166 target_agent TEXT NOT NULL DEFAULT '',
1167 source_agent TEXT NOT NULL DEFAULT '',
1168 audit_source_uid TEXT NOT NULL DEFAULT '',
1169 message TEXT NOT NULL,
1170 priority TEXT NOT NULL DEFAULT '',
1171 jekt_tier TEXT NOT NULL DEFAULT '',
1172 delivery_tier TEXT NOT NULL DEFAULT '',
1173 sig_verified INTEGER,
1174 reagent_verified INTEGER,
1175 lan_verified INTEGER,
1176 channel_verified INTEGER,
1177 is_transcript_request INTEGER NOT NULL DEFAULT 0,
1178 transcript_request_escalate_forced INTEGER NOT NULL DEFAULT 0,
1179 sent_at_ms INTEGER NOT NULL,
1180 expires_at_ms INTEGER NOT NULL,
1181 attempts INTEGER NOT NULL DEFAULT 0,
1182 last_error TEXT NOT NULL DEFAULT ''
1183 );
1184 CREATE INDEX IF NOT EXISTS idx_jekt_held_target ON db_jekt_held (target_uid, sent_at_ms);
1185
1186 -- v43: devices paired with the viewer listener. See
1187 -- OBJECT_SCHEMA_VERSION's v43 doc comment above.
1188 CREATE TABLE IF NOT EXISTS db_viewer_devices (
1189 device_id TEXT PRIMARY KEY,
1190 token_hash TEXT NOT NULL UNIQUE,
1191 device_name TEXT NOT NULL,
1192 device_key TEXT NOT NULL DEFAULT '',
1193 created_ms INTEGER NOT NULL,
1194 last_seen_ms INTEGER NOT NULL DEFAULT 0
1195 );
1196
1197 -- The bundles picked for an agent, in order; its own bundle
1198 -- (db_agents.default_memory_id) is not listed here
1199 -- (SPEC_RENAME_KNOWLEDGE_TO_MEMORY_2026_10_06.md §3.6). No FK: a
1200 -- bundle lives in the identity store, and an agent's rows go with
1201 -- it in purge_agent_dependents. Additive, applied on every open,
1202 -- so no version bump.
1203 CREATE TABLE IF NOT EXISTS db_agent_bundles (
1204 agent_id TEXT NOT NULL,
1205 bundle_id TEXT NOT NULL,
1206 position INTEGER NOT NULL,
1207 PRIMARY KEY (agent_id, bundle_id)
1208 );
1209
1210 -- A widget package's `storage` (SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §6.3), JSON values; additive, no version bump.
1211 CREATE TABLE IF NOT EXISTS db_widget_storage (widget_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (widget_id, key));
1212
1213 -- v21: trust-on-first-use pin of a remote agent_id's LAN public key
1214 -- (SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2, reagentx P0).
1215 -- Distinct from db_agent_lan_keys (this instance's OWN agents'
1216 -- keypairs, private half included) — this table holds only public
1217 -- keys OBSERVED from LAN peers for agent_ids this instance does not
1218 -- itself host, pinned on first sight so a later, different key
1219 -- claiming the same agent_id is treated as a mismatch rather than
1220 -- silently trusted.
1221 CREATE TABLE IF NOT EXISTS db_lan_peer_pubkey_pins (
1222 agent_id TEXT PRIMARY KEY,
1223 public_key TEXT NOT NULL,
1224 first_seen_at INTEGER NOT NULL DEFAULT 0
1225 );
1226
1227 -- v22: durable declared-long-running-task registry — see
1228 -- OBJECT_SCHEMA_VERSION's v22 doc comment above. `id` mirrors the
1229 -- frontend's dock node_id (usually the originating tool_use_id) so
1230 -- rows join cleanly with DockSnapshotCache/ActivityDock data.
1231 -- `status` is one of 'running' | 'done' | 'error' | 'stopped',
1232 -- matching the vocabulary tool-adapter.ts already uses for a
1233 -- background launch's terminal states.
1234 CREATE TABLE IF NOT EXISTS db_background_tasks (
1235 id TEXT PRIMARY KEY,
1236 block_id TEXT NOT NULL,
1237 label TEXT NOT NULL,
1238 pid INTEGER,
1239 started_at_ms INTEGER NOT NULL,
1240 status TEXT NOT NULL DEFAULT 'running',
1241 last_seen_ms INTEGER NOT NULL,
1242 ended_at_ms INTEGER
1243 );
1244 CREATE INDEX IF NOT EXISTS idx_background_tasks_block
1245 ON db_background_tasks(block_id);
1246 CREATE INDEX IF NOT EXISTS idx_background_tasks_status
1247 ON db_background_tasks(status);
1248
1249 -- v25: saved fleet-control target groups — see OBJECT_SCHEMA_VERSION's
1250 -- v25 doc comment above. member_ids is a JSON array of block ids
1251 -- (Swarm's native per-agent selection unit); resolved to concrete
1252 -- ids by the frontend before either fleet.broadcast or
1253 -- fleet.bulk-stop executes, so a group's membership can't drift
1254 -- between when a bulk action is confirmed and when it runs.
1255 CREATE TABLE IF NOT EXISTS db_agent_groups (
1256 id TEXT PRIMARY KEY,
1257 name TEXT NOT NULL,
1258 member_ids TEXT NOT NULL DEFAULT '[]',
1259 created_at INTEGER NOT NULL DEFAULT 0
1260 );
1261
1262 -- v26: allowlist for a responding agent's 'trusted_peers'
1263 -- conversation_visibility mode — see OBJECT_SCHEMA_VERSION's v26
1264 -- doc comment above. Mirrors db_lan_peer_pubkey_pins's exact shape
1265 -- (own module, get/set-style methods, case-insensitive agent_id
1266 -- lookups). `agent_id` is the RESPONDING agent (whose transcript
1267 -- may be disclosed); `granted_peer_agent_id` is the requester it
1268 -- trusts; `tier` records which delivery tier the grant applies to
1269 -- ('lan'/'wan') since a grant for one tier's cryptographic
1270 -- identity guarantee should not be silently assumed to hold for a
1271 -- weaker one.
1272 CREATE TABLE IF NOT EXISTS db_conversation_trust_grants (
1273 agent_id TEXT NOT NULL,
1274 granted_peer_agent_id TEXT NOT NULL,
1275 tier TEXT NOT NULL,
1276 granted_at INTEGER NOT NULL DEFAULT 0,
1277 PRIMARY KEY (agent_id, granted_peer_agent_id, tier)
1278 );
1279
1280 -- v28: one-shot, on-demand activity summary per definition — see
1281 -- OBJECT_SCHEMA_VERSION's v28 doc comment above. `summary` is empty
1282 -- only transiently (a row is only ever inserted once generation
1283 -- succeeds); there is no 'pending' state stored here — an absent
1284 -- row IS the pending state.
1285 CREATE TABLE IF NOT EXISTS db_agent_activity_summaries (
1286 definition_id TEXT PRIMARY KEY,
1287 summary TEXT NOT NULL DEFAULT '',
1288 updated_at INTEGER NOT NULL DEFAULT 0
1289 );",
1290 )?;
1291
1292 // ---- Additive column migrations (schema v2+) ----
1293 // The flat CREATE batch above covers fresh databases. These ALTERs
1294 // carry an existing database (e.g. a developer's dev DB that persists
1295 // across builds) forward. Idempotent: the "duplicate column" error is
1296 // swallowed. New additive columns append here + bump OBJECT_SCHEMA_VERSION.
1297 //
1298 // db_agent_definitions' own v2/v3+ ALTERs (updated_at, user_hidden,
1299 // container_image/volumes/name, use_ambient_login, model_vendor_base_url,
1300 // auto_continue_enabled, memory_id, conversation_visibility) are run
1301 // separately below, conditionally on the table still existing — see that
1302 // block's own comment for why they can't simply be deleted even though
1303 // the table itself is dropped as of v32 (OBJECT_SCHEMA_VERSION's v32 doc
1304 // comment).
1305 // v5: db_agents.last_block_id — most-recent launch's block (Phase 3c).
1306 // Defaults to '' for existing rows; the dual-write populates it on
1307 // the next launch/continuation. Read side (3b.1b) treats '' as
1308 // "no snapshot" (same as the current empty-block_id fallback).
1309 // v6: Container support (Phase 0 of SPEC_CONTAINER_PANE_SUPPORT_2026_06_11.md).
1310 // container_image / container_volumes / container_name on both tables.
1311 // Host-agent rows default to '', '[]', '' respectively — ContainerManager
1312 // populates them on first container spawn.
1313 // v7: create db_muxbus_credentials if it doesn't exist yet (existing DBs
1314 // won't have it since it's a new table, not an added column).
1315 // v10: standalone skill + MCP server tables (new tables, handled via
1316 // CREATE TABLE IF NOT EXISTS in the flat batch above; no ALTER needed).
1317 conn.execute_batch(
1318 "CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
1319 id TEXT PRIMARY KEY DEFAULT 'global',
1320 cognito_domain TEXT NOT NULL DEFAULT '',
1321 client_id TEXT NOT NULL DEFAULT '',
1322 access_token TEXT NOT NULL DEFAULT '',
1323 refresh_token TEXT NOT NULL DEFAULT '',
1324 id_token TEXT NOT NULL DEFAULT '',
1325 expires_at INTEGER NOT NULL DEFAULT 0,
1326 user_email TEXT NOT NULL DEFAULT '',
1327 user_sub TEXT NOT NULL DEFAULT ''
1328 )",
1329 )?;
1330
1331 // db_agent_definitions' own additive columns, conditional on the table
1332 // still existing. Removing these outright (rather than gating them) broke
1333 // a real upgrade path: several pending migrations (m0007, m0025, m0026,
1334 // m0028) call Store::open() internally as part of their own up() logic —
1335 // which runs THIS function, under the CURRENT binary's code — before
1336 // m0029 (numbered last) ever drops the table in the same pass. A store
1337 // old enough to still be missing one of these columns (e.g. a genuine
1338 // pre-v2 install jumping straight to the latest binary) would hit a live
1339 // "no such column" error from m0007's consolidate pass or m0025/m0028's
1340 // frozen_repair_def_gaps/project_template_launch_row — both of which
1341 // SELECT several of these columns from db_agent_definitions — well
1342 // before m0029 ever runs. Gating on table existence keeps this a no-op
1343 // once the table is gone (fresh v32+ installs never create it at all;
1344 // upgraded stores lose it only after m0029 runs later in the same pass)
1345 // without reintroducing an unconditional dependency on a table this
1346 // release otherwise no longer declares. Found in review of PR #3096
1347 // (Codex P1).
1348 let legacy_defs_exist: bool = conn.query_row(
1349 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='db_agent_definitions'",
1350 [],
1351 |row| row.get::<_, i64>(0),
1352 )? == 1;
1353 if legacy_defs_exist {
1354 for stmt in &[
1355 "ALTER TABLE db_agent_definitions ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0",
1356 "ALTER TABLE db_agent_definitions ADD COLUMN user_hidden INTEGER NOT NULL DEFAULT 0",
1357 "ALTER TABLE db_agent_definitions ADD COLUMN container_image TEXT NOT NULL DEFAULT ''",
1358 "ALTER TABLE db_agent_definitions ADD COLUMN container_volumes TEXT NOT NULL DEFAULT '[]'",
1359 "ALTER TABLE db_agent_definitions ADD COLUMN container_name TEXT NOT NULL DEFAULT ''",
1360 "ALTER TABLE db_agent_definitions ADD COLUMN use_ambient_login INTEGER NOT NULL DEFAULT 0",
1361 "ALTER TABLE db_agent_definitions ADD COLUMN model_vendor_base_url TEXT NOT NULL DEFAULT ''",
1362 "ALTER TABLE db_agent_definitions ADD COLUMN auto_continue_enabled INTEGER NOT NULL DEFAULT 0",
1363 "ALTER TABLE db_agent_definitions ADD COLUMN memory_id TEXT NOT NULL DEFAULT ''",
1364 "ALTER TABLE db_agent_definitions ADD COLUMN conversation_visibility TEXT NOT NULL DEFAULT 'private'",
1365 ] {
1366 if let Err(e) = conn.execute_batch(stmt) {
1367 let msg = e.to_string();
1368 if !msg.contains("duplicate column") {
1369 return Err(e.into());
1370 }
1371 }
1372 }
1373 }
1374
1375 for stmt in &[
1376 "ALTER TABLE db_agents ADD COLUMN last_block_id TEXT NOT NULL DEFAULT ''",
1377 "ALTER TABLE db_agents ADD COLUMN container_image TEXT NOT NULL DEFAULT ''",
1378 "ALTER TABLE db_agents ADD COLUMN container_volumes TEXT NOT NULL DEFAULT '[]'",
1379 "ALTER TABLE db_agents ADD COLUMN container_name TEXT NOT NULL DEFAULT ''",
1380 // NOTE: targets db_bundles, not db_memory_bundles — adopt_legacy_table_names
1381 // (called at the top of this function) has already renamed the table
1382 // by the time this runs, on every DB that goes through the Phase 4
1383 // rename. See SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md.
1384 "ALTER TABLE db_bundles ADD COLUMN is_global INTEGER NOT NULL DEFAULT 0",
1385 "ALTER TABLE db_bundles ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
1386 // v12: explicit per-agent ambient-login opt-in (fail-by-default spawn
1387 // gating — SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md §2.3).
1388 "ALTER TABLE db_agents ADD COLUMN use_ambient_login INTEGER NOT NULL DEFAULT 0",
1389 // v15: model vendor override — redirects a harness at a non-default
1390 // backend (e.g. ANTHROPIC_BASE_URL). Formalizes harness vs. model
1391 // vendor as distinct concepts; see ProviderConfig::base_url_env_var.
1392 "ALTER TABLE db_agents ADD COLUMN model_vendor_base_url TEXT NOT NULL DEFAULT ''",
1393 // v16: provider-scoped bundle instructions (ABF v0.2 §2.2) — JSON
1394 // object of {provider_id: content} variants, additive to the
1395 // existing flat `instructions` column.
1396 "ALTER TABLE db_bundles ADD COLUMN instructions_by_provider TEXT NOT NULL DEFAULT '{}'",
1397 // v17: Warden Supervisor auto-continue opt-in (fail-by-default,
1398 // same posture as use_ambient_login) — see
1399 // ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md.
1400 "ALTER TABLE db_agents ADD COLUMN auto_continue_enabled INTEGER NOT NULL DEFAULT 0",
1401 // v19: the agent's own dedicated ABF bundle, set once at creation.
1402 // db_agents gets a SEPARATE default_memory_id column (not the
1403 // existing instance-scoped memory_id) — see its own CREATE TABLE
1404 // comment for why, and OBJECT_SCHEMA_VERSION's v19 entry.
1405 "ALTER TABLE db_agents ADD COLUMN default_memory_id TEXT NOT NULL DEFAULT ''",
1406 // v26: muxspect Phase B/C per-agent conversation-disclosure policy —
1407 // see OBJECT_SCHEMA_VERSION's v26 doc comment above. Dual-written
1408 // to db_agents under the SAME column name (a simple opt-in-style
1409 // setting, like auto_continue_enabled above — not a differently-
1410 // named-on-db_agents case like memory_id/default_memory_id).
1411 "ALTER TABLE db_agents ADD COLUMN conversation_visibility TEXT NOT NULL DEFAULT 'private'",
1412 // v27: AgentMux-controlled, highest-priority Global Bundle tier —
1413 // see OBJECT_SCHEMA_VERSION's v27 doc comment above.
1414 "ALTER TABLE db_bundles ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0",
1415 // v29: the agent's latest launch state on db_agents — the four
1416 // db_agent_instances columns the consolidated row still lacked. See
1417 // OBJECT_SCHEMA_VERSION's v29 doc comment above and the column doc in
1418 // db_agents' CREATE TABLE. Backfilled from db_agent_instances by
1419 // m0025_agents_launch_state_backfill; kept current by the instance
1420 // dual-write until the legacy table is dropped.
1421 "ALTER TABLE db_agents ADD COLUMN session_id TEXT NOT NULL DEFAULT ''",
1422 "ALTER TABLE db_agents ADD COLUMN status TEXT NOT NULL DEFAULT ''",
1423 "ALTER TABLE db_agents ADD COLUMN started_at INTEGER NOT NULL DEFAULT 0",
1424 "ALTER TABLE db_agents ADD COLUMN ended_at INTEGER NOT NULL DEFAULT 0",
1425 // v39: identity M4c-1 — the Global Memory writer's UID beside its
1426 // name. See OBJECT_SCHEMA_VERSION's v39 doc comment above.
1427 "ALTER TABLE db_bundle_versions ADD COLUMN written_by_uid TEXT NOT NULL DEFAULT ''",
1428 // v41: the subagent that owns a background task. See
1429 // OBJECT_SCHEMA_VERSION's v41 doc comment above.
1430 "ALTER TABLE db_background_tasks ADD COLUMN owner_tool_use_id TEXT",
1431 // v42: the Runtime menu choices the user last made for the agent. See
1432 // OBJECT_SCHEMA_VERSION's v42 doc comment above.
1433 "ALTER TABLE db_agents ADD COLUMN last_runtime TEXT NOT NULL DEFAULT ''",
1434 // v43: an agent hidden from paired devices. See
1435 // OBJECT_SCHEMA_VERSION's v43 doc comment above.
1436 "ALTER TABLE db_agents ADD COLUMN hide_from_devices INTEGER NOT NULL DEFAULT 0",
1437 ] {
1438 if let Err(e) = conn.execute_batch(stmt) {
1439 let msg = e.to_string();
1440 if !msg.contains("duplicate column") {
1441 return Err(e.into());
1442 }
1443 }
1444 }
1445
1446 // ---- Seed blank Bundle singleton ----
1447 // The launch UI renders this as the default option in its Bundle
1448 // dropdown. Fixed id so tests + dev seed data can hard-code
1449 // references.
1450 conn.execute_batch(
1451 "INSERT OR IGNORE INTO db_bundles
1452 (id, name, description, is_blank, created_at, updated_at)
1453 VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
1454 )?;
1455
1456 // Channel-scoped migration tracking (parallel to the global db_migrations
1457 // in store.db). MigrationScope::Channel migrations record completion here
1458 // so each channel tracks its own migration state independently.
1459 conn.execute_batch(
1460 "CREATE TABLE IF NOT EXISTS db_migrations (
1461 id TEXT PRIMARY KEY,
1462 applied_at TEXT NOT NULL,
1463 duration_ms INTEGER NOT NULL DEFAULT 0,
1464 scope TEXT NOT NULL DEFAULT 'channel'
1465 );",
1466 )?;
1467
1468 Ok(())
1469}
1470
1471/// Rename any pre-flatten `objects.db` tables to their de-forged names and
1472/// drop the dead workflow/sentinel tables. Idempotent — on a fresh or
1473/// already-flat database every check is a no-op.
1474///
1475/// This is the single surviving fragment of the old v1–v11 chain: it
1476/// exists only to carry a developer's pre-flatten `objects.db` (always at
1477/// the post-v11 schema, since v11 is merged) forward without data loss.
1478/// SQLite ≥ 3.25 auto-updates foreign-key references in child tables when
1479/// a parent table is renamed, so the agent/identity cascades survive.
1480fn adopt_legacy_table_names(conn: &Connection) -> Result<(), StoreError> {
1481 for (legacy, current) in LEGACY_TABLE_RENAMES {
1482 let legacy_exists: i64 = conn.query_row(
1483 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1484 [legacy],
1485 |row| row.get(0),
1486 )?;
1487 if legacy_exists == 0 {
1488 continue;
1489 }
1490 let current_exists: i64 = conn.query_row(
1491 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1492 [current],
1493 |row| row.get(0),
1494 )?;
1495 if current_exists == 1 {
1496 // Both present — only reachable on a deliberate
1497 // downgrade-roundtrip dev DB (flat build → pre-flatten build,
1498 // which re-creates the legacy name → flat build again). The
1499 // flatten abandons the downgrade path, but the legacy table
1500 // may hold rows the downgraded build wrote. Do NOT drop it —
1501 // that would be silent data loss (the bug class behind PR
1502 // #933's Codex P1). Leave it on disk and warn loudly so the
1503 // developer can recover or delete it manually.
1504 warn!(
1505 legacy_table = *legacy,
1506 current_table = *current,
1507 "objects.db has both a legacy table and its de-forged \
1508 replacement — this only happens after a downgrade to a \
1509 pre-flatten build; the legacy table is left untouched \
1510 for manual recovery and is otherwise unused",
1511 );
1512 } else {
1513 conn.execute_batch(&format!("ALTER TABLE {legacy} RENAME TO {current};"))?;
1514 }
1515 }
1516
1517 // Drop indexes orphaned by the renames — the flat DDL recreates them
1518 // under the new names.
1519 for idx in LEGACY_INDEX_DROPS {
1520 conn.execute_batch(&format!("DROP INDEX IF EXISTS {idx};"))?;
1521 }
1522
1523 // Drop tables retained only for the abandoned downgrade path.
1524 for table in DEAD_TABLE_DROPS {
1525 conn.execute_batch(&format!("DROP TABLE IF EXISTS {table};"))?;
1526 }
1527
1528 Ok(())
1529}
1530
1531/// Initialize (or re-validate) the `~/.agentmux/shared/store.db` schema.
1532///
1533/// Contains only the durable user content that must survive across
1534/// channels/versions: identity accounts, agent→account links, memory
1535/// bundles, drone definitions, and muxbus credentials. Session state
1536/// (`db_block`, `db_tab`, etc.) and drone run history stay in the
1537/// per-channel `objects.db`.
1538///
1539/// `db_agent_identity_links` drops the FK to `db_agent_definitions` here
1540/// (cross-DB FK enforcement is impossible in SQLite); application code
1541/// enforces referential integrity instead.
1542///
1543/// Idempotent — safe to call on every startup.
1544pub fn run_shared_store_schema(conn: &Connection) -> Result<(), StoreError> {
1545 // Rename any legacy table names (including the Phase 4 accounts/bundles
1546 // rename — LEGACY_TABLE_RENAMES entries that don't apply to this
1547 // connection's tables are safely skipped) before the idempotent
1548 // CREATE TABLE IF NOT EXISTS batch below, same as run_object_schema.
1549 adopt_legacy_table_names(conn)?;
1550
1551 conn.execute_batch(
1552 "CREATE TABLE IF NOT EXISTS db_accounts (
1553 id TEXT PRIMARY KEY,
1554 name TEXT NOT NULL,
1555 provider TEXT NOT NULL,
1556 kind TEXT NOT NULL,
1557 display_name TEXT NOT NULL DEFAULT '',
1558 secret_ref TEXT NOT NULL,
1559 context TEXT NOT NULL DEFAULT '{}',
1560 status TEXT NOT NULL DEFAULT 'unknown',
1561 created_at INTEGER NOT NULL DEFAULT 0,
1562 updated_at INTEGER NOT NULL DEFAULT 0
1563 );
1564 CREATE INDEX IF NOT EXISTS idx_ss_accounts_provider
1565 ON db_accounts(provider);
1566
1567 CREATE TABLE IF NOT EXISTS db_agent_identity_links (
1568 agent_id TEXT NOT NULL,
1569 account_id TEXT NOT NULL,
1570 provider TEXT NOT NULL,
1571 PRIMARY KEY (agent_id, provider),
1572 FOREIGN KEY (account_id) REFERENCES db_accounts(id) ON DELETE CASCADE
1573 );
1574 CREATE INDEX IF NOT EXISTS idx_ss_agent_identity_links_account
1575 ON db_agent_identity_links(account_id);
1576
1577 CREATE TABLE IF NOT EXISTS db_bundles (
1578 id TEXT PRIMARY KEY,
1579 name TEXT NOT NULL UNIQUE,
1580 description TEXT NOT NULL DEFAULT '',
1581 is_blank INTEGER NOT NULL DEFAULT 0,
1582 is_global INTEGER NOT NULL DEFAULT 0,
1583 provider TEXT NOT NULL DEFAULT '',
1584 model TEXT NOT NULL DEFAULT '',
1585 instructions TEXT NOT NULL DEFAULT '',
1586 instructions_by_provider TEXT NOT NULL DEFAULT '{}',
1587 context_files TEXT NOT NULL DEFAULT '[]',
1588 mcp_servers TEXT NOT NULL DEFAULT '[]',
1589 skills TEXT NOT NULL DEFAULT '[]',
1590 sort_order INTEGER NOT NULL DEFAULT 0,
1591 created_at INTEGER NOT NULL DEFAULT 0,
1592 updated_at INTEGER NOT NULL DEFAULT 0,
1593 is_system INTEGER NOT NULL DEFAULT 0
1594 );
1595 CREATE INDEX IF NOT EXISTS idx_ss_bundles_is_blank
1596 ON db_bundles(is_blank);
1597
1598 CREATE TABLE IF NOT EXISTS db_drone_definitions (
1599 id TEXT PRIMARY KEY,
1600 name TEXT NOT NULL,
1601 description TEXT NOT NULL DEFAULT '',
1602 graph TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
1603 viewport TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
1604 created_at INTEGER NOT NULL DEFAULT 0,
1605 updated_at INTEGER NOT NULL DEFAULT 0
1606 );
1607 CREATE INDEX IF NOT EXISTS idx_ss_drone_definitions_updated
1608 ON db_drone_definitions(updated_at DESC);
1609
1610 CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
1611 id TEXT PRIMARY KEY DEFAULT 'global',
1612 cognito_domain TEXT NOT NULL DEFAULT '',
1613 client_id TEXT NOT NULL DEFAULT '',
1614 access_token TEXT NOT NULL DEFAULT '',
1615 refresh_token TEXT NOT NULL DEFAULT '',
1616 id_token TEXT NOT NULL DEFAULT '',
1617 expires_at INTEGER NOT NULL DEFAULT 0,
1618 user_email TEXT NOT NULL DEFAULT '',
1619 user_sub TEXT NOT NULL DEFAULT ''
1620 );
1621
1622 CREATE TABLE IF NOT EXISTS db_migrations (
1623 id TEXT PRIMARY KEY,
1624 applied_at TEXT NOT NULL,
1625 duration_ms INTEGER NOT NULL DEFAULT 0,
1626 scope TEXT NOT NULL DEFAULT 'global'
1627 );
1628
1629 CREATE TABLE IF NOT EXISTS db_cron_jobs (
1630 id TEXT PRIMARY KEY,
1631 name TEXT NOT NULL,
1632 expression TEXT NOT NULL,
1633 prompt TEXT NOT NULL,
1634 target TEXT NOT NULL,
1635 created_by TEXT NOT NULL DEFAULT '',
1636 enabled INTEGER NOT NULL DEFAULT 1,
1637 last_fired INTEGER,
1638 fire_count INTEGER NOT NULL DEFAULT 0,
1639 max_fires INTEGER,
1640 created_at INTEGER NOT NULL DEFAULT 0,
1641 max_age_secs INTEGER,
1642 -- v11 (identity M1b): the target's UID (db_agents.id) beside the
1643 -- slug, resolved ONCE at create time (spec §5.4), dual-written,
1644 -- not yet read -- a job still fires by `target`. Empty when the
1645 -- name did not resolve; never guessed.
1646 target_uid TEXT NOT NULL DEFAULT '',
1647 -- v12 (identity M4c-1): the creator's UID from the request's
1648 -- Caller, beside the created_by name. Empty when unattributed.
1649 created_by_uid TEXT NOT NULL DEFAULT ''
1650 );
1651 CREATE INDEX IF NOT EXISTS idx_ss_cron_jobs_enabled
1652 ON db_cron_jobs(enabled);
1653
1654 -- v4: per-agent M2M credential for muxbus Tier-4 binding (see
1655 -- SHARED_STORE_SCHEMA_VERSION's doc comment above). client_secret is
1656 -- a Cognito app client secret, not a user password — same trust
1657 -- tier as MUXBUS_TOKEN in db_muxbus_credentials above, stored
1658 -- unencrypted in the same shared store.db for the same reason.
1659 CREATE TABLE IF NOT EXISTS db_agent_credentials (
1660 agent_id TEXT PRIMARY KEY,
1661 client_id TEXT NOT NULL DEFAULT '',
1662 client_secret TEXT NOT NULL DEFAULT '',
1663 token_endpoint TEXT NOT NULL DEFAULT '',
1664 access_token TEXT NOT NULL DEFAULT '',
1665 expires_at INTEGER NOT NULL DEFAULT 0,
1666 created_at INTEGER NOT NULL DEFAULT 0
1667 );
1668
1669 -- v6: durable mirror of each agent's native (Claude Code) memory
1670 -- files, keyed by the stable AgentDefinition.id rather than any live
1671 -- filesystem path — the live path is channel-relative by design
1672 -- (per-build-channel filesystem isolation), so the same agent opened
1673 -- from two channels resolves two different on-disk memory dirs. This
1674 -- table is what makes native memory durable and location-consistent
1675 -- across channels: agent:memory:list/read_file upsert into it on
1676 -- every read, and merge the live-FS view with it in the response —
1677 -- see docs/specs/SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md.
1678 -- Duplicated into run_object_schema (same both-schemas pattern as
1679 -- db_agent_credentials above) for id_store's per-channel fallback
1680 -- before 0011_shared_store_backfill has run.
1681 CREATE TABLE IF NOT EXISTS db_agent_native_memory (
1682 agent_id TEXT NOT NULL,
1683 filename TEXT NOT NULL,
1684 content TEXT NOT NULL,
1685 metadata_type TEXT NOT NULL DEFAULT '',
1686 size_bytes INTEGER NOT NULL DEFAULT 0,
1687 updated_at INTEGER NOT NULL DEFAULT 0,
1688 last_seen_path TEXT NOT NULL DEFAULT '',
1689 last_seen_mtime_ms INTEGER NOT NULL DEFAULT 0,
1690 PRIMARY KEY (agent_id, filename)
1691 );
1692
1693 -- v8: append-only version history for native memory content — one
1694 -- row per agent:memory:write_file call (plus reverts), so a prior
1695 -- value is never lost the way the mirror above loses it on every
1696 -- overwrite. See docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md.
1697 CREATE TABLE IF NOT EXISTS db_agent_native_memory_versions (
1698 id TEXT PRIMARY KEY,
1699 agent_id TEXT NOT NULL,
1700 filename TEXT NOT NULL,
1701 content TEXT NOT NULL,
1702 content_hash TEXT NOT NULL,
1703 parent_version_id TEXT,
1704 source TEXT NOT NULL DEFAULT 'agent_inferred',
1705 source_detail TEXT NOT NULL DEFAULT '{}',
1706 session_id TEXT NOT NULL DEFAULT '',
1707 created_at INTEGER NOT NULL DEFAULT 0
1708 );
1709 CREATE INDEX IF NOT EXISTS idx_ss_native_memory_versions_lookup
1710 ON db_agent_native_memory_versions(agent_id, filename, created_at);
1711
1712 -- v10: append-only version history for Global Memory entry content
1713 -- — see SHARED_STORE_SCHEMA_VERSION's own doc comment for the full
1714 -- rationale (SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md).
1715 CREATE TABLE IF NOT EXISTS db_bundle_versions (
1716 id TEXT PRIMARY KEY,
1717 bundle_id TEXT NOT NULL,
1718 name TEXT NOT NULL,
1719 instructions TEXT NOT NULL,
1720 content_hash TEXT NOT NULL,
1721 parent_version_id TEXT,
1722 source TEXT NOT NULL DEFAULT 'agent_inferred',
1723 source_detail TEXT NOT NULL DEFAULT '{}',
1724 written_by TEXT NOT NULL DEFAULT '',
1725 created_at INTEGER NOT NULL DEFAULT 0,
1726 -- v12 (identity M4c-1): the writer's UID from the request's
1727 -- Caller, beside the written_by name. Empty when unattributed.
1728 written_by_uid TEXT NOT NULL DEFAULT ''
1729 );
1730 CREATE INDEX IF NOT EXISTS idx_ss_bundle_versions_lookup
1731 ON db_bundle_versions(bundle_id, created_at);",
1732 )?;
1733
1734 // Seed the blank Bundle singleton — same fixed id as objects.db so
1735 // cross-version reads never see a missing blank row.
1736 conn.execute_batch(
1737 "INSERT OR IGNORE INTO db_bundles
1738 (id, name, description, is_blank, created_at, updated_at)
1739 VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
1740 )?;
1741
1742 // ---- Additive column migrations (schema v5+) ----
1743 // Same idempotent pattern as run_object_schema's ALTER-TABLE loop below:
1744 // the flat CREATE batch above covers fresh DBs; these carry an existing
1745 // shared store forward. "duplicate column" is swallowed so this is safe
1746 // to run on every startup regardless of whether the column already exists.
1747 //
1748 // v5: db_cron_jobs.max_age_secs — optional hard expiry bound (seconds
1749 // since created_at), alongside the existing max_fires bound. NULL
1750 // for all existing rows = no behavior change for jobs created before
1751 // this column existed.
1752 for stmt in &[
1753 "ALTER TABLE db_cron_jobs ADD COLUMN max_age_secs INTEGER",
1754 // v7: provider-scoped bundle instructions (ABF v0.2 §2.2).
1755 "ALTER TABLE db_bundles ADD COLUMN instructions_by_provider TEXT NOT NULL DEFAULT '{}'",
1756 // v9: AgentMux-controlled, highest-priority Global Bundle tier —
1757 // see OBJECT_SCHEMA_VERSION's v27 doc comment above.
1758 "ALTER TABLE db_bundles ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0",
1759 // v11: identity M1b — the cron target's UID beside its slug.
1760 "ALTER TABLE db_cron_jobs ADD COLUMN target_uid TEXT NOT NULL DEFAULT ''",
1761 // v12: identity M4c-1 — the actor's UID beside each actor name.
1762 "ALTER TABLE db_cron_jobs ADD COLUMN created_by_uid TEXT NOT NULL DEFAULT ''",
1763 "ALTER TABLE db_bundle_versions ADD COLUMN written_by_uid TEXT NOT NULL DEFAULT ''",
1764 ] {
1765 if let Err(e) = conn.execute_batch(stmt) {
1766 let msg = e.to_string();
1767 if !msg.contains("duplicate column") {
1768 return Err(e.into());
1769 }
1770 }
1771 }
1772
1773 Ok(())
1774}
1775
1776/// `user_version` stamped into the new, permanently-global
1777/// `~/.agentmux/shared/identity-store.db` (distinct file from
1778/// `store.db` — see [`run_identity_store_schema`]'s own doc comment for
1779/// why). Independent counter from `SHARED_STORE_SCHEMA_VERSION`.
1780/// v1 — initial: agent→account links, bundles, drone
1781/// definitions, muxbus credentials, per-agent M2M credentials,
1782/// native memory mirror, cron jobs. Everything
1783/// `run_shared_store_schema` already held EXCEPT `db_accounts` —
1784/// see docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md §3.1.
1785/// v2 — db_accounts, as a FALLBACK MIRROR only (reagentx P0 review on
1786/// PR #2632): the link resolves via this store now, but the
1787/// account row it points to could still only exist in the
1788/// per-channel-isolatable `id_store`, which is empty on a fresh
1789/// channel — the reported bug wasn't actually fixed end to end
1790/// without this. `id_store` remains the PRIMARY, authoritative,
1791/// write path (so Armory's disposable-test-account isolation is
1792/// unchanged); this copy is consulted only when `id_store` doesn't
1793/// have the row. See `identity::resolver::inject`'s account
1794/// resolution for the read-with-fallback + write-back-to-source-store
1795/// logic this enables.
1796/// v3 — db_agent_native_memory_versions, for schema parity with the v1
1797/// native memory mirror this store already carries. Same as that
1798/// table, this store's copy is not an actively-written duplicate
1799/// (id_store/store.db remains the authoritative write path — see
1800/// v2's note above) — added here only so a future always-global
1801/// read path (mirroring what v2 did for db_accounts) is not blocked
1802/// on a missing table. See
1803/// docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md.
1804/// v4 — db_bundles.is_system, for schema parity with OBJECT_SCHEMA_VERSION
1805/// v27 / SHARED_STORE_SCHEMA_VERSION v9 (same column, same reasoning
1806/// as v3 above — this store's `db_bundles` copy is not an actively-
1807/// written duplicate either). Real ALTER TABLE ADD COLUMN added to
1808/// `run_identity_store_schema` in the same change, so this counter
1809/// MUST bump alongside it — an unbumped counter would leave an
1810/// existing identity-store.db stamped at the old user_version
1811/// forever, silently defeating check_schema_compat/stamp_version's
1812/// forward-compat lock for this one store (reagent P1, PR #2782).
1813/// See docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md.
1814/// v5: `db_work_queue` — the Muxqueue backlog
1815/// (`docs/reports/REPORT_UNIVERSAL_AGENT_WORK_QUEUE_2026_09_01.md`).
1816/// A NEW table, so no ALTER is involved, but the counter still bumps:
1817/// an identity-store.db stamped v4 that a v5 binary opens must be
1818/// recognized as older, not "already current" (same reagent P1 on
1819/// PR #2782 reasoning recorded for v4 above).
1820///
1821/// Deliberately in THIS store, not the per-channel one. A queue whose
1822/// rows are per-channel means "any agent IN THIS CHANNEL can pick it
1823/// up", which is close to useless given every local/dev/portable build
1824/// is its own channel — see the report's §6.2, and
1825/// `docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md`
1826/// for what the per-channel/global seam has already cost once.
1827/// v6: `db_skills`, `db_mcp_servers` — Phase 2 of
1828/// `SPEC_DURABLE_BINDINGS_2026_09_10.md`. These become the
1829/// AUTHORITATIVE catalogs as of this version; the per-channel copies
1830/// in `run_object_schema` keep their declarations (never removed —
1831/// see that spec's §5.1 and §8 item 3) and continue to exist only as
1832/// the same best-effort degraded-mode fallback
1833/// `bootstrap/stores.rs` already substitutes for every other
1834/// identity-store table (`mstore` in
1835/// place of `identity_store` when the identity store can't be
1836/// resolved, created, or opened). Deliberately NOT declared with the
1837/// ref tables (`db_agent_skills_ref`, `db_bundle_skills_ref`, and
1838/// the MCP equivalents) — those stay channel-scoped until Phase 3/6
1839/// promotes them; adding them here now, unpaired with a live writer,
1840/// would be schema for a phase that hasn't shipped.
1841/// v7: partial UNIQUE indexes on `db_skills(name, skill_type)` and
1842/// `db_mcp_servers(name)`, each `WHERE is_global = 1` — global rows
1843/// only; two different owners' private rows sharing a name is still
1844/// fine. Closes a real race in
1845/// `m0031_carry_skills_and_mcp_servers_to_identity_store`: two
1846/// channels' migrations can run concurrently (multiple AgentMux
1847/// instances is an explicitly supported scenario), both read "no
1848/// matching global row yet" before either has inserted, and both
1849/// proceed to insert under different ids — the promised first-wins
1850/// cross-channel dedup silently fails without a DB-level constraint
1851/// to arbitrate the race (Codex P1, PR #3181). The migration's
1852/// insert now handles the resulting `UNIQUE constraint failed` by
1853/// re-querying for whichever row actually won.
1854/// v8 — `idx_ids_skills_global_name` replaces `idx_ids_skills_global_name_type`:
1855/// narrows the skill global-uniqueness index from `(name, skill_type)`
1856/// to `name` alone, matching the invariant `skill_upsert_unique_global`'s
1857/// own dup-check has always enforced (`WHERE name = ?1 AND id <> ?2
1858/// AND is_global = 1` — no `skill_type` filter). Codex P2, PR #3182,
1859/// `SPEC_DURABLE_BINDINGS_2026_09_10.md` §5.4: the v7 index was
1860/// narrower than the invariant it was meant to back, so a
1861/// same-name-different-`skill_type` race between two channels could
1862/// insert two rows the application itself considers duplicates. MCP
1863/// servers don't have this mismatch — `idx_ids_mcp_servers_global_name`
1864/// is already name-only — so this bump is skills-only.
1865/// `m0033_narrow_skill_global_uniqueness_index` defensively collapses
1866/// any existing same-name/different-type pair `m0031`'s own
1867/// `(name, skill_type)`-keyed dedup may already have produced, to the
1868/// earlier-`created_at` row, before the new index is created.
1869/// v9 — db_bundle_versions, for schema parity with OBJECT_SCHEMA_VERSION
1870/// v35 / SHARED_STORE_SCHEMA_VERSION v10 (same table, same reasoning
1871/// as v3 above — this store's copy is not an actively-written
1872/// duplicate either). See
1873/// docs/specs/SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md.
1874/// v10 — db_work_queue.target_agent_uid / claimed_by_uid, and
1875/// db_cron_jobs.target_uid (parity with SHARED_STORE_SCHEMA_VERSION
1876/// v11): the UID (`db_agents.id`) beside each slug column, dual-written
1877/// and not yet read. Identity M1b of
1878/// SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §9.1.
1879/// `target_agent_uid` is resolved ONCE at enqueue (spec §5.4);
1880/// `claimed_by_uid` is CARRIED from the claimer's own
1881/// `AGENTMUX_AGENT_UID` (M1a, #3548), falling back to resolving its
1882/// `agent_id` with the fallback counted (spec §9.2). Empty when
1883/// unknown; never guessed.
1884/// v11 — db_work_queue.created_by_uid, and db_cron_jobs.created_by_uid /
1885/// db_bundle_versions.written_by_uid (parity with
1886/// SHARED_STORE_SCHEMA_VERSION v12): the actor's UID beside each
1887/// actor-name column, taken from the request's `Caller` (its
1888/// `X-Agent-Token`), never from a body field. Empty for an
1889/// Unattributed request; never guessed. Dual-written, not yet read.
1890/// Identity M4c-1 of
1891/// SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.9.
1892///
1893/// Added WITHOUT a bump: `db_mcp_server_reach` (OBJECT_SCHEMA_VERSION v44
1894/// has the same table). The catalog's reach switches live with the catalog,
1895/// here, so they survive an upgrade where an agent bind
1896/// (`db_agent_mcp_ref`, in `objects.db`) does not. This store is one file
1897/// shared by every AgentMux on the computer, installed and dev builds alike,
1898/// and `check_schema_compat` makes any build that speaks an older version
1899/// refuse it. So a bump here locks every older AgentMux on the computer out
1900/// as soon as one newer build starts: a dev build of a branch with a bump
1901/// did exactly that on 2026-10-11. A table an older build never reads needs
1902/// no lock: an older build ignores it, a newer one creates it if missing.
1903/// Bump only for a change an older build would misread or corrupt.
1904/// `SPEC_CONNECTORS_MCP_WHAT_AGENTS_RUN_2026_10_10.md` §2.1.
1905pub const IDENTITY_STORE_SCHEMA_VERSION: i64 = 11;
1906
1907/// Initialize (or re-validate) the `~/.agentmux/shared/identity-store.db`
1908/// schema — the permanently-global store introduced by
1909/// `docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md` to fix
1910/// conversation-continuity-across-a-version-switch
1911/// (`docs/specs/REPORT_HISTORY_CONTINUITY_ACROSS_VERSION_UPGRADE_2026_08_17.md`).
1912///
1913/// This is a **near-superset** of [`run_shared_store_schema`]'s tables
1914/// (v2): every one of them, INCLUDING `db_accounts` as of v2 — but
1915/// `db_accounts` here is a read-through FALLBACK MIRROR only, not the
1916/// authoritative write path. Unlike agent→account links, bundles,
1917/// drone definitions, muxbus credentials, and native memory,
1918/// `db_accounts` has one genuine, intentional isolation need (Armory's
1919/// disposable delete-account testing flow): a real per-account
1920/// creation-time scope split (SPEC §3.2) hasn't shipped yet, so
1921/// `id_store`/`resolve_shared_store_path()` stays the primary read/write
1922/// path for accounts, preserving Armory's isolation exactly as before.
1923/// This store's copy exists ONLY so a link resolved here (always global)
1924/// doesn't dead-end on an account row that's stuck in a fresh, per-
1925/// channel-isolated `id_store` — see `identity::resolver::inject`'s
1926/// account resolution (falls back to this store's `identity_get` only
1927/// when `id_store`'s own lookup misses) and
1928/// `migrations::m0022_identity_store_links_backfill` (backfills this
1929/// mirror the same way it backfills links). `db_agent_identity_links.account_id`
1930/// still has NO foreign key here (same reasoning `run_shared_store_schema`
1931/// already uses to drop its FK to `db_agent_definitions`: cross-database
1932/// FK enforcement is impossible in SQLite, and this table's `db_accounts`
1933/// is itself just a mirror, not guaranteed complete — application code
1934/// enforces referential integrity, tolerating a miss, instead).
1935///
1936/// A dedicated store (not a subset of the existing shared store reached
1937/// via a new always-global path) is deliberate, not just a naming choice:
1938/// it makes "can this table ever be reached from a per-channel-isolated
1939/// codepath" a structural fact (a totally separate file/connection)
1940/// rather than a convention someone could violate by calling
1941/// `resolve_shared_store_path()` for a new call site later — the exact
1942/// failure mode `docs/specs/CHECKLIST_AGENT_DATA_SCOPE_ROUTING_2026_08_17.md`
1943/// items 2-4 already document as having bitten this codebase more than
1944/// once.
1945///
1946/// Idempotent — safe to call on every startup.
1947pub fn run_identity_store_schema(conn: &Connection) -> Result<(), StoreError> {
1948 conn.execute_batch(
1949 "CREATE TABLE IF NOT EXISTS db_agent_identity_links (
1950 agent_id TEXT NOT NULL,
1951 account_id TEXT NOT NULL,
1952 provider TEXT NOT NULL,
1953 PRIMARY KEY (agent_id, provider)
1954 );
1955 CREATE INDEX IF NOT EXISTS idx_ids_agent_identity_links_account
1956 ON db_agent_identity_links(account_id);
1957
1958 -- v2: fallback mirror only — see this function's own doc comment
1959 -- above. id_store stays the authoritative read/write path.
1960 CREATE TABLE IF NOT EXISTS db_accounts (
1961 id TEXT PRIMARY KEY,
1962 name TEXT NOT NULL,
1963 provider TEXT NOT NULL,
1964 kind TEXT NOT NULL,
1965 display_name TEXT NOT NULL DEFAULT '',
1966 secret_ref TEXT NOT NULL,
1967 context TEXT NOT NULL DEFAULT '{}',
1968 status TEXT NOT NULL DEFAULT 'unknown',
1969 created_at INTEGER NOT NULL DEFAULT 0,
1970 updated_at INTEGER NOT NULL DEFAULT 0
1971 );
1972 CREATE INDEX IF NOT EXISTS idx_ids_accounts_provider
1973 ON db_accounts(provider);
1974
1975 CREATE TABLE IF NOT EXISTS db_bundles (
1976 id TEXT PRIMARY KEY,
1977 name TEXT NOT NULL UNIQUE,
1978 description TEXT NOT NULL DEFAULT '',
1979 is_blank INTEGER NOT NULL DEFAULT 0,
1980 is_global INTEGER NOT NULL DEFAULT 0,
1981 provider TEXT NOT NULL DEFAULT '',
1982 model TEXT NOT NULL DEFAULT '',
1983 instructions TEXT NOT NULL DEFAULT '',
1984 instructions_by_provider TEXT NOT NULL DEFAULT '{}',
1985 context_files TEXT NOT NULL DEFAULT '[]',
1986 mcp_servers TEXT NOT NULL DEFAULT '[]',
1987 skills TEXT NOT NULL DEFAULT '[]',
1988 sort_order INTEGER NOT NULL DEFAULT 0,
1989 created_at INTEGER NOT NULL DEFAULT 0,
1990 updated_at INTEGER NOT NULL DEFAULT 0,
1991 is_system INTEGER NOT NULL DEFAULT 0
1992 );
1993 CREATE INDEX IF NOT EXISTS idx_ids_bundles_is_blank
1994 ON db_bundles(is_blank);
1995
1996 CREATE TABLE IF NOT EXISTS db_drone_definitions (
1997 id TEXT PRIMARY KEY,
1998 name TEXT NOT NULL,
1999 description TEXT NOT NULL DEFAULT '',
2000 graph TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
2001 viewport TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
2002 created_at INTEGER NOT NULL DEFAULT 0,
2003 updated_at INTEGER NOT NULL DEFAULT 0
2004 );
2005 CREATE INDEX IF NOT EXISTS idx_ids_drone_definitions_updated
2006 ON db_drone_definitions(updated_at DESC);
2007
2008 CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
2009 id TEXT PRIMARY KEY DEFAULT 'global',
2010 cognito_domain TEXT NOT NULL DEFAULT '',
2011 client_id TEXT NOT NULL DEFAULT '',
2012 access_token TEXT NOT NULL DEFAULT '',
2013 refresh_token TEXT NOT NULL DEFAULT '',
2014 id_token TEXT NOT NULL DEFAULT '',
2015 expires_at INTEGER NOT NULL DEFAULT 0,
2016 user_email TEXT NOT NULL DEFAULT '',
2017 user_sub TEXT NOT NULL DEFAULT ''
2018 );
2019
2020 CREATE TABLE IF NOT EXISTS db_migrations (
2021 id TEXT PRIMARY KEY,
2022 applied_at TEXT NOT NULL,
2023 duration_ms INTEGER NOT NULL DEFAULT 0,
2024 scope TEXT NOT NULL DEFAULT 'global'
2025 );
2026
2027 CREATE TABLE IF NOT EXISTS db_cron_jobs (
2028 id TEXT PRIMARY KEY,
2029 name TEXT NOT NULL,
2030 expression TEXT NOT NULL,
2031 prompt TEXT NOT NULL,
2032 target TEXT NOT NULL,
2033 created_by TEXT NOT NULL DEFAULT '',
2034 enabled INTEGER NOT NULL DEFAULT 1,
2035 last_fired INTEGER,
2036 fire_count INTEGER NOT NULL DEFAULT 0,
2037 max_fires INTEGER,
2038 created_at INTEGER NOT NULL DEFAULT 0,
2039 max_age_secs INTEGER,
2040 -- v10 (identity M1b): UID beside the slug, resolved at create
2041 -- time, dual-written, not yet read. Parity with the shared-store
2042 -- copy of this table, which is the one the handlers use today.
2043 target_uid TEXT NOT NULL DEFAULT '',
2044 -- v11 (identity M4c-1): the creator's UID, parity with the
2045 -- shared-store copy.
2046 created_by_uid TEXT NOT NULL DEFAULT ''
2047 );
2048 CREATE INDEX IF NOT EXISTS idx_ids_cron_jobs_enabled
2049 ON db_cron_jobs(enabled);
2050
2051 -- Muxqueue: the universal agent work queue. One row = one unit of work
2052 -- that ANY agent may claim. See
2053 -- docs/reports/REPORT_UNIVERSAL_AGENT_WORK_QUEUE_2026_09_01.md.
2054 --
2055 -- Deliberately placed directly after db_cron_jobs, because it is that
2056 -- table's sibling: cron is this row with a TIME trigger, this is the
2057 -- same row with a READINESS trigger. `not_before` makes the
2058 -- relationship literal.
2059 --
2060 -- It belongs in THIS schema (the always-global identity store) and not
2061 -- in run_shared_store_schema. A per-channel queue would only ever mean
2062 -- any agent IN THIS CHANNEL can pick it up, which is close to useless
2063 -- when every local/dev/portable build is its own channel -- see the
2064 -- report's section 6.2.
2065 --
2066 -- Editing note: this whole schema is ONE Rust string literal passed to
2067 -- execute_batch, so an UNESCAPED double quote anywhere in it --
2068 -- including inside an SQL comment like this one -- terminates that
2069 -- literal and breaks the build with errors that point at prose rather
2070 -- than at code. Escaped ones are fine and already appear above (see
2071 -- db_drone_definitions default JSON). Apostrophes are fine too: they
2072 -- are ordinary characters inside a Rust double-quoted string, and
2073 -- SQLite does not lex quotes inside a comment.
2074 CREATE TABLE IF NOT EXISTS db_work_queue (
2075 id TEXT PRIMARY KEY,
2076 title TEXT NOT NULL,
2077 payload TEXT NOT NULL,
2078 kind TEXT NOT NULL DEFAULT '',
2079 target_agent TEXT NOT NULL DEFAULT '',
2080 target_group TEXT NOT NULL DEFAULT '',
2081 priority INTEGER NOT NULL DEFAULT 0,
2082 state TEXT NOT NULL DEFAULT 'open',
2083 claimed_by TEXT NOT NULL DEFAULT '',
2084 claim_expires INTEGER,
2085 attempts INTEGER NOT NULL DEFAULT 0,
2086 max_attempts INTEGER NOT NULL DEFAULT 3,
2087 created_by TEXT NOT NULL DEFAULT '',
2088 created_at INTEGER NOT NULL DEFAULT 0,
2089 updated_at INTEGER NOT NULL DEFAULT 0,
2090 not_before INTEGER,
2091 result TEXT NOT NULL DEFAULT '',
2092 -- v10 (identity M1b): the UID (db_agents.id) beside each slug
2093 -- column, dual-written, not yet read. target_agent_uid is
2094 -- resolved ONCE at enqueue (spec §5.4: resolution happens at
2095 -- authoring time, when a human or model was present, never at
2096 -- claim time); claimed_by_uid is CARRIED from the claimer's own
2097 -- AGENTMUX_AGENT_UID. Empty when unknown -- never guessed.
2098 target_agent_uid TEXT NOT NULL DEFAULT '',
2099 claimed_by_uid TEXT NOT NULL DEFAULT '',
2100 -- v11 (identity M4c-1): the enqueuer's UID from the request's
2101 -- Caller, beside the created_by name. Empty when unattributed.
2102 created_by_uid TEXT NOT NULL DEFAULT ''
2103 );
2104 -- The claim query's exact predicate: state + readiness, ordered by
2105 -- priority then age. Without this the claim UPDATE degrades to a table
2106 -- scan under contention, which is when it matters most.
2107 CREATE INDEX IF NOT EXISTS idx_ids_work_queue_claimable
2108 ON db_work_queue(state, not_before, priority DESC, created_at);
2109 -- The reaper's predicate (expired leases).
2110 CREATE INDEX IF NOT EXISTS idx_ids_work_queue_claim_expires
2111 ON db_work_queue(claim_expires)
2112 WHERE claim_expires IS NOT NULL;
2113
2114 CREATE TABLE IF NOT EXISTS db_agent_credentials (
2115 agent_id TEXT PRIMARY KEY,
2116 client_id TEXT NOT NULL DEFAULT '',
2117 client_secret TEXT NOT NULL DEFAULT '',
2118 token_endpoint TEXT NOT NULL DEFAULT '',
2119 access_token TEXT NOT NULL DEFAULT '',
2120 expires_at INTEGER NOT NULL DEFAULT 0,
2121 created_at INTEGER NOT NULL DEFAULT 0
2122 );
2123
2124 CREATE TABLE IF NOT EXISTS db_agent_native_memory (
2125 agent_id TEXT NOT NULL,
2126 filename TEXT NOT NULL,
2127 content TEXT NOT NULL,
2128 metadata_type TEXT NOT NULL DEFAULT '',
2129 size_bytes INTEGER NOT NULL DEFAULT 0,
2130 updated_at INTEGER NOT NULL DEFAULT 0,
2131 last_seen_path TEXT NOT NULL DEFAULT '',
2132 last_seen_mtime_ms INTEGER NOT NULL DEFAULT 0,
2133 PRIMARY KEY (agent_id, filename)
2134 );
2135
2136 -- v3: schema parity with the v1 native memory mirror above — see
2137 -- IDENTITY_STORE_SCHEMA_VERSION's own doc comment for why this
2138 -- store's copy is not (yet) an actively-written duplicate.
2139 CREATE TABLE IF NOT EXISTS db_agent_native_memory_versions (
2140 id TEXT PRIMARY KEY,
2141 agent_id TEXT NOT NULL,
2142 filename TEXT NOT NULL,
2143 content TEXT NOT NULL,
2144 content_hash TEXT NOT NULL,
2145 parent_version_id TEXT,
2146 source TEXT NOT NULL DEFAULT 'agent_inferred',
2147 source_detail TEXT NOT NULL DEFAULT '{}',
2148 session_id TEXT NOT NULL DEFAULT '',
2149 created_at INTEGER NOT NULL DEFAULT 0
2150 );
2151 CREATE INDEX IF NOT EXISTS idx_ids_native_memory_versions_lookup
2152 ON db_agent_native_memory_versions(agent_id, filename, created_at);
2153
2154 -- v9: schema parity with the db_bundle_versions table above (own
2155 -- store) — see IDENTITY_STORE_SCHEMA_VERSION's own doc comment.
2156 CREATE TABLE IF NOT EXISTS db_bundle_versions (
2157 id TEXT PRIMARY KEY,
2158 bundle_id TEXT NOT NULL,
2159 name TEXT NOT NULL,
2160 instructions TEXT NOT NULL,
2161 content_hash TEXT NOT NULL,
2162 parent_version_id TEXT,
2163 source TEXT NOT NULL DEFAULT 'agent_inferred',
2164 source_detail TEXT NOT NULL DEFAULT '{}',
2165 written_by TEXT NOT NULL DEFAULT '',
2166 created_at INTEGER NOT NULL DEFAULT 0,
2167 -- v11 (identity M4c-1): parity with the shared-store copy.
2168 written_by_uid TEXT NOT NULL DEFAULT ''
2169 );
2170 CREATE INDEX IF NOT EXISTS idx_ids_bundle_versions_lookup
2171 ON db_bundle_versions(bundle_id, created_at);
2172
2173 -- v6: authoritative skill/MCP-server catalogs — see this constant's
2174 -- v6 doc comment above. Same column shapes as run_object_schema's
2175 -- copies; no ref tables here yet (Phase 3/6), so no FK targets exist
2176 -- to reference them from this store.
2177 CREATE TABLE IF NOT EXISTS db_skills (
2178 id TEXT PRIMARY KEY,
2179 name TEXT NOT NULL,
2180 trigger TEXT NOT NULL DEFAULT '',
2181 skill_type TEXT NOT NULL DEFAULT 'prompt',
2182 description TEXT NOT NULL DEFAULT '',
2183 content TEXT NOT NULL DEFAULT '',
2184 is_global INTEGER NOT NULL DEFAULT 0,
2185 created_at INTEGER NOT NULL DEFAULT 0,
2186 updated_at INTEGER NOT NULL DEFAULT 0
2187 );
2188 CREATE INDEX IF NOT EXISTS idx_ids_skills_is_global ON db_skills(is_global);
2189 -- v8: name-only, matching skill_upsert_unique_global's own dup-check
2190 -- — see this constant's v8 doc comment. Replaces
2191 -- idx_ids_skills_global_name_type (dropped just below for an
2192 -- existing store; a fresh store never creates it in the first
2193 -- place, since this whole batch runs as one execute_batch).
2194 CREATE UNIQUE INDEX IF NOT EXISTS idx_ids_skills_global_name
2195 ON db_skills(name) WHERE is_global = 1;
2196 DROP INDEX IF EXISTS idx_ids_skills_global_name_type;
2197
2198 CREATE TABLE IF NOT EXISTS db_mcp_servers (
2199 id TEXT PRIMARY KEY,
2200 name TEXT NOT NULL,
2201 transport TEXT NOT NULL DEFAULT 'stdio',
2202 config TEXT NOT NULL DEFAULT '{}',
2203 is_global INTEGER NOT NULL DEFAULT 0,
2204 created_at INTEGER NOT NULL DEFAULT 0,
2205 updated_at INTEGER NOT NULL DEFAULT 0
2206 );
2207 CREATE INDEX IF NOT EXISTS idx_ids_mcp_servers_is_global ON db_mcp_servers(is_global);
2208 CREATE UNIQUE INDEX IF NOT EXISTS idx_ids_mcp_servers_global_name
2209 ON db_mcp_servers(name) WHERE is_global = 1;
2210 -- Who a catalog server reaches beyond its binds. Added without a
2211 -- version bump — see IDENTITY_STORE_SCHEMA_VERSION's doc comment.
2212 CREATE TABLE IF NOT EXISTS db_mcp_server_reach (
2213 mcp_id TEXT PRIMARY KEY,
2214 all_agents INTEGER NOT NULL DEFAULT 0,
2215 in_containers INTEGER NOT NULL DEFAULT 0,
2216 updated_at INTEGER NOT NULL DEFAULT 0
2217 );",
2218 )?;
2219
2220 // Seed the blank Bundle singleton — same fixed id as objects.db/store.db
2221 // so cross-version reads never see a missing blank row.
2222 conn.execute_batch(
2223 "INSERT OR IGNORE INTO db_bundles
2224 (id, name, description, is_blank, created_at, updated_at)
2225 VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
2226 )?;
2227
2228 // Additive columns added after this store's tables were first created —
2229 // same idempotent pattern as run_shared_store_schema's own ALTER loop.
2230 for stmt in &[
2231 // db_bundles.is_system: see OBJECT_SCHEMA_VERSION's v27 doc comment.
2232 "ALTER TABLE db_bundles ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0",
2233 // v10: identity M1b — UID columns beside the slug columns, dual-written.
2234 "ALTER TABLE db_work_queue ADD COLUMN target_agent_uid TEXT NOT NULL DEFAULT ''",
2235 "ALTER TABLE db_work_queue ADD COLUMN claimed_by_uid TEXT NOT NULL DEFAULT ''",
2236 "ALTER TABLE db_cron_jobs ADD COLUMN target_uid TEXT NOT NULL DEFAULT ''",
2237 // v11: identity M4c-1 — the actor's UID beside each actor name.
2238 "ALTER TABLE db_work_queue ADD COLUMN created_by_uid TEXT NOT NULL DEFAULT ''",
2239 "ALTER TABLE db_cron_jobs ADD COLUMN created_by_uid TEXT NOT NULL DEFAULT ''",
2240 "ALTER TABLE db_bundle_versions ADD COLUMN written_by_uid TEXT NOT NULL DEFAULT ''",
2241 ] {
2242 if let Err(e) = conn.execute_batch(stmt) {
2243 let msg = e.to_string();
2244 if !msg.contains("duplicate column") {
2245 return Err(e.into());
2246 }
2247 }
2248 }
2249
2250 Ok(())
2251}
2252
2253/// Initialize the FileStore schema. Creates the wave_file and file_data
2254/// tables. Already a flat single-DDL store — unaffected by the
2255/// `objects.db` flattening.
2256pub fn run_filestore_migrations(conn: &Connection) -> Result<(), StoreError> {
2257 conn.execute_batch(
2258 "CREATE TABLE IF NOT EXISTS db_wave_file (
2259 zoneid TEXT NOT NULL,
2260 name TEXT NOT NULL,
2261 size INTEGER NOT NULL DEFAULT 0,
2262 createdts INTEGER NOT NULL DEFAULT 0,
2263 modts INTEGER NOT NULL DEFAULT 0,
2264 opts TEXT NOT NULL DEFAULT '{}',
2265 meta TEXT NOT NULL DEFAULT '{}',
2266 PRIMARY KEY (zoneid, name)
2267 );
2268
2269 CREATE TABLE IF NOT EXISTS db_file_data (
2270 zoneid TEXT NOT NULL,
2271 name TEXT NOT NULL,
2272 partidx INTEGER NOT NULL,
2273 data BLOB NOT NULL,
2274 PRIMARY KEY (zoneid, name, partidx)
2275 );",
2276 )?;
2277 // Line counter (Phase 5a-2, SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_
2278 // 2026_09_23.md §6.3.7; see `filestore/counter.rs`). Additive and
2279 // nullable, and deliberately WITHOUT a FILESTORE_SCHEMA_VERSION bump: the
2280 // global transcript store is shared by every srv instance on the machine,
2281 // and a bump would make older builds refuse to open it. Older builds
2282 // insert rows without these columns (NULL = not counted) and write
2283 // without maintaining them, which the counter detects. "duplicate
2284 // column" is expected when another instance added them first.
2285 for stmt in &[
2286 "ALTER TABLE db_wave_file ADD COLUMN gen TEXT",
2287 "ALTER TABLE db_wave_file ADD COLUMN lines INTEGER",
2288 "ALTER TABLE db_wave_file ADD COLUMN lines_size INTEGER",
2289 "ALTER TABLE db_wave_file ADD COLUMN lines_tail INTEGER",
2290 "ALTER TABLE db_wave_file ADD COLUMN lines_modts INTEGER",
2291 // Bumped (by the triggers below) by every write that changes bytes
2292 // already in the file — not by appends — and the value an epoch was
2293 // counted at. An epoch is valid only while they are equal.
2294 "ALTER TABLE db_wave_file ADD COLUMN rev INTEGER",
2295 "ALTER TABLE db_wave_file ADD COLUMN lines_rev INTEGER",
2296 // A random identity per row, set by the insert trigger below: a file
2297 // deleted and re-created is a different incarnation even if every
2298 // other column (and the creation millisecond) comes out the same.
2299 "ALTER TABLE db_wave_file ADD COLUMN incarnation BLOB",
2300 ] {
2301 if let Err(e) = conn.execute_batch(stmt) {
2302 if !e.to_string().contains("duplicate column") {
2303 return Err(e.into());
2304 }
2305 }
2306 }
2307 // `rev` is maintained by triggers, not by this code's writes, because
2308 // triggers are part of the database: they fire for every connection,
2309 // including older builds that know nothing about the counter. Any write
2310 // that changes existing bytes bumps it:
2311 // - a part deleted (write_file's replace, a delete);
2312 // - a part written over with bytes that aren't a pure extension of it
2313 // (REPLACE INTO / UPDATE). Appending extends the last part, which
2314 // keeps it a prefix, so appends — ours or an older build's — don't.
2315 // REPLACE INTO doesn't fire DELETE triggers (recursive_triggers is off,
2316 // SQLite's default; nothing here enables it), so an append can't reach
2317 // the delete trigger. A trigger on a deleted row's parts finds no row.
2318 conn.execute_batch(
2319 "-- Versioned name: CREATE TRIGGER IF NOT EXISTS never updates an
2320 -- existing definition, so a changed rule gets a new name and the
2321 -- old one is dropped.
2322 DROP TRIGGER IF EXISTS db_file_data_rev_insert;
2323 CREATE TRIGGER IF NOT EXISTS db_file_data_rev_insert_v2
2324 BEFORE INSERT ON db_file_data
2325 WHEN EXISTS (
2326 SELECT 1 FROM db_file_data d
2327 WHERE d.zoneid = NEW.zoneid AND d.name = NEW.name AND d.partidx = NEW.partidx
2328 AND substr(NEW.data, 1, length(d.data)) IS NOT d.data
2329 )
2330 -- A NEW part inside the range the file already claims: bytes the
2331 -- file declared (and a reader may have read as missing) are being
2332 -- filled in, not appended. Every writer inserts appended parts
2333 -- before raising `size`, so an append's new parts always start at
2334 -- or past it. Older builds' write_file raises `size` first and
2335 -- inserts after (Codex on #3631). 65536 is PART_DATA_SIZE.
2336 OR (
2337 NOT EXISTS (
2338 SELECT 1 FROM db_file_data d
2339 WHERE d.zoneid = NEW.zoneid AND d.name = NEW.name AND d.partidx = NEW.partidx
2340 )
2341 AND NEW.partidx * 65536 < (
2342 SELECT COALESCE(MAX(w.size), 0) FROM db_wave_file w
2343 WHERE w.zoneid = NEW.zoneid AND w.name = NEW.name
2344 )
2345 )
2346 BEGIN
2347 UPDATE db_wave_file SET rev = COALESCE(rev, 0) + 1
2348 WHERE zoneid = NEW.zoneid AND name = NEW.name;
2349 END;
2350
2351 CREATE TRIGGER IF NOT EXISTS db_file_data_rev_update
2352 BEFORE UPDATE OF data ON db_file_data
2353 WHEN substr(NEW.data, 1, length(OLD.data)) IS NOT OLD.data
2354 BEGIN
2355 UPDATE db_wave_file SET rev = COALESCE(rev, 0) + 1
2356 WHERE zoneid = OLD.zoneid AND name = OLD.name;
2357 END;
2358
2359 CREATE TRIGGER IF NOT EXISTS db_file_data_rev_delete
2360 AFTER DELETE ON db_file_data
2361 BEGIN
2362 UPDATE db_wave_file SET rev = COALESCE(rev, 0) + 1
2363 WHERE zoneid = OLD.zoneid AND name = OLD.name;
2364 END;
2365
2366 -- Every row, whoever inserts it (older builds included), gets 64
2367 -- random bits of identity. A delete + re-create in the same
2368 -- millisecond with the same size and bytes is still a different
2369 -- incarnation (Codex on #3631).
2370 CREATE TRIGGER IF NOT EXISTS db_wave_file_incarnation
2371 AFTER INSERT ON db_wave_file
2372 BEGIN
2373 UPDATE db_wave_file SET incarnation = randomblob(8)
2374 WHERE zoneid = NEW.zoneid AND name = NEW.name;
2375 END;
2376
2377 UPDATE db_wave_file SET incarnation = randomblob(8) WHERE incarnation IS NULL;",
2378 )?;
2379 Ok(())
2380}
2381
2382/// Initialize the saga durability schema (`saga` + `saga_step` tables and
2383/// their indexes). See `docs/specs/SPEC_SAGA_DURABILITY_2026-05-01.md` §2.2.
2384/// Already a flat single-DDL store.
2385pub fn run_saga_log_migrations(conn: &Connection) -> Result<(), StoreError> {
2386 conn.execute_batch(
2387 "CREATE TABLE IF NOT EXISTS saga (
2388 saga_id INTEGER PRIMARY KEY,
2389 name TEXT NOT NULL,
2390 state TEXT NOT NULL,
2391 started_at INTEGER NOT NULL,
2392 terminal_at INTEGER,
2393 failure_reason TEXT,
2394 input_json TEXT NOT NULL
2395 );
2396
2397 CREATE TABLE IF NOT EXISTS saga_step (
2398 saga_id INTEGER NOT NULL REFERENCES saga(saga_id),
2399 step_index INTEGER NOT NULL,
2400 name TEXT NOT NULL,
2401 state TEXT NOT NULL,
2402 cmd_json TEXT NOT NULL,
2403 output_json TEXT,
2404 started_at INTEGER NOT NULL,
2405 ended_at INTEGER,
2406 PRIMARY KEY (saga_id, step_index)
2407 );
2408
2409 CREATE INDEX IF NOT EXISTS saga_state_idx
2410 ON saga(state) WHERE state IN ('running', 'compensating');
2411 CREATE INDEX IF NOT EXISTS saga_terminal_idx
2412 ON saga(terminal_at);",
2413 )?;
2414 Ok(())
2415}
2416
2417/// `PRAGMA user_version` tripwire (AUDIT_SQLITE_SYSTEMS §8.5).
2418///
2419/// Compare the file's `user_version` to `current` and refuse to open
2420/// the database if it was written by a NEWER binary.
2421///
2422/// This is the forward-compat **safety lock** from the channels design
2423/// (`SPEC_DATA_CHANNELS_2026_05_24.md` §3.3). Within a channel,
2424/// multiple released AgentMux versions share one data dir; the lock
2425/// keeps an older binary from writing into a schema laid down by a
2426/// newer binary. Same discipline as Chrome's profile-too-new check
2427/// and Postgres's catalog version mismatch.
2428///
2429/// **Must be called BEFORE `run_*_schema` / `run_*_migrations`.** The
2430/// `run_*` functions include mutating steps (legacy-table renames,
2431/// seed inserts, `CREATE TABLE` for new tables the older binary
2432/// doesn't have), so if we ran them first and only then checked the
2433/// version, a downgraded binary could still alter a newer database
2434/// before the error fires — breaking the "reject without touching
2435/// disk" invariant the lock is meant to guarantee (codex P1 on PR
2436/// #1029).
2437///
2438/// Read-only: only `PRAGMA user_version` query, no writes.
2439/// [`stamp_version`] does the corresponding write AFTER migrations
2440/// complete successfully.
2441///
2442/// Recovery for the user when this returns `SchemaTooNew`: upgrade
2443/// AgentMux to a version ≥ `found`, or set
2444/// `AGENTMUX_CHANNEL=<other>` to land in a different channel dir.
2445/// The data on disk is preserved either way — this function never
2446/// modifies the database on the rejected path.
2447pub fn check_schema_compat(
2448 conn: &Connection,
2449 current: i64,
2450 db_label: &str,
2451) -> Result<(), StoreError> {
2452 let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
2453 if found > current {
2454 warn!(
2455 db = db_label,
2456 found, expected = current,
2457 "database user_version is newer than this build — refusing \
2458 to open. Upgrade AgentMux or switch channels.",
2459 );
2460 return Err(StoreError::SchemaTooNew {
2461 db: db_label.to_string(),
2462 found,
2463 expected: current,
2464 });
2465 }
2466 Ok(())
2467}
2468
2469/// Stamp the database's `user_version` PRAGMA to `current`. Called
2470/// AFTER `run_*_schema` succeeds, paired with a prior
2471/// [`check_schema_compat`] that gated the migrations on the
2472/// caller-binary speaking a compatible (or newer) schema version.
2473///
2474/// Splitting the read from the write makes the safety-lock order
2475/// explicit at every call site:
2476///
2477/// ```ignore
2478/// check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
2479/// run_object_schema(&conn)?;
2480/// stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
2481/// ```
2482pub fn stamp_version(conn: &Connection, current: i64) -> Result<(), StoreError> {
2483 conn.execute_batch(&format!("PRAGMA user_version = {current};"))?;
2484 Ok(())
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use super::*;
2490
2491 /// Every table the flat `objects.db` schema must contain.
2492 const EXPECTED_TABLES: &[&str] = &[
2493 "db_client",
2494 "db_window",
2495 "db_workspace",
2496 "db_tab",
2497 "db_layout",
2498 "db_block",
2499 "db_temp",
2500 "db_agent_content",
2501 "db_agent_skills",
2502 "db_agent_history",
2503 "db_accounts",
2504 "db_agent_identity_links",
2505 "db_bundles",
2506 "db_agents",
2507 "db_drone_definitions",
2508 "db_drone_runs",
2509 ];
2510
2511 fn table_exists(conn: &Connection, name: &str) -> bool {
2512 let count: i64 = conn
2513 .query_row(
2514 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
2515 [name],
2516 |row| row.get(0),
2517 )
2518 .unwrap();
2519 count == 1
2520 }
2521
2522 fn index_exists(conn: &Connection, name: &str) -> bool {
2523 let count: i64 = conn
2524 .query_row(
2525 "SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?1",
2526 [name],
2527 |row| row.get(0),
2528 )
2529 .unwrap();
2530 count == 1
2531 }
2532
2533 fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
2534 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})")).unwrap();
2535 let names: Vec<String> = stmt
2536 .query_map([], |row| row.get::<_, String>(1))
2537 .unwrap()
2538 .filter_map(Result::ok)
2539 .collect();
2540 names.iter().any(|n| n == column)
2541 }
2542
2543 #[test]
2544 fn test_object_schema_creates_all_tables_and_singletons() {
2545 let conn = Connection::open_in_memory().unwrap();
2546 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2547 run_object_schema(&conn).unwrap();
2548
2549 for table in EXPECTED_TABLES {
2550 assert!(table_exists(&conn, table), "{table} should exist");
2551 }
2552 // De-forged + bundle indexes.
2553 for idx in &[
2554 "idx_agent_history_agent_date",
2555 "idx_agent_identity_links_account",
2556 "idx_accounts_provider",
2557 "idx_bundles_is_blank",
2558 "idx_agents_is_template",
2559 "idx_agents_parent_template_id",
2560 "idx_agents_is_seeded",
2561 "idx_drone_definitions_updated",
2562 "idx_drone_runs_status",
2563 ] {
2564 assert!(index_exists(&conn, idx), "{idx} should exist");
2565 }
2566
2567 // Blank singleton seeded.
2568 let mem_blank: i64 = conn
2569 .query_row(
2570 "SELECT count(*) FROM db_bundles WHERE id='blank' AND is_blank=1",
2571 [],
2572 |row| row.get(0),
2573 )
2574 .unwrap();
2575 assert_eq!(mem_blank, 1, "blank Bundle singleton should be seeded");
2576 }
2577
2578 #[test]
2579 fn test_object_schema_idempotent() {
2580 let conn = Connection::open_in_memory().unwrap();
2581 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2582 run_object_schema(&conn).unwrap();
2583 run_object_schema(&conn).unwrap(); // second pass must not error
2584
2585 // Singleton stays unique.
2586 let mem_count: i64 = conn
2587 .query_row(
2588 "SELECT count(*) FROM db_bundles WHERE id='blank'",
2589 [],
2590 |r| r.get(0),
2591 )
2592 .unwrap();
2593 assert_eq!(mem_count, 1);
2594 }
2595
2596 #[test]
2597 fn test_object_schema_has_model_vendor_base_url_on_db_agents() {
2598 let conn = Connection::open_in_memory().unwrap();
2599 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2600 run_object_schema(&conn).unwrap();
2601 // Second pass: the ALTER TABLE array's duplicate-column guard must
2602 // not error when the column already exists (fresh installs get it
2603 // via CREATE TABLE; the ALTER is for upgrading pre-v15 databases).
2604 // Was "on_both_tables" — db_agent_definitions is gone as of v32, so
2605 // this is db_agents-only now (see OBJECT_SCHEMA_VERSION's v32 doc
2606 // comment).
2607 run_object_schema(&conn).unwrap();
2608
2609 assert!(column_exists(&conn, "db_agents", "model_vendor_base_url"));
2610 }
2611
2612 #[test]
2613 fn test_object_schema_has_auto_continue_enabled_on_db_agents() {
2614 let conn = Connection::open_in_memory().unwrap();
2615 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2616 run_object_schema(&conn).unwrap();
2617 // Second pass: the ALTER TABLE array's duplicate-column guard must
2618 // not error when the column already exists (fresh installs get it
2619 // via CREATE TABLE; the ALTER is for upgrading pre-v17 databases).
2620 // Was "on_both_tables" — see the v15 test just above for why.
2621 run_object_schema(&conn).unwrap();
2622
2623 assert!(column_exists(&conn, "db_agents", "auto_continue_enabled"));
2624 }
2625
2626 #[test]
2627 fn test_db_bundles_has_instructions_by_provider_in_both_schemas() {
2628 // ABF v0.2 §2.2 (v16): db_bundles lives in both objects.db (via
2629 // run_object_schema) and the shared store.db (via
2630 // run_shared_store_schema) — mirrors the db_agent_credentials /
2631 // db_agent_native_memory both-schemas precedent (v13/v14).
2632 let object_conn = Connection::open_in_memory().unwrap();
2633 object_conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2634 run_object_schema(&object_conn).unwrap();
2635 run_object_schema(&object_conn).unwrap(); // idempotent second pass
2636 assert!(column_exists(&object_conn, "db_bundles", "instructions_by_provider"));
2637
2638 let shared_conn = Connection::open_in_memory().unwrap();
2639 shared_conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2640 run_shared_store_schema(&shared_conn).unwrap();
2641 run_shared_store_schema(&shared_conn).unwrap();
2642 assert!(column_exists(&shared_conn, "db_bundles", "instructions_by_provider"));
2643 }
2644
2645 #[test]
2646 fn test_db_skills_and_mcp_servers_exist_in_both_object_and_identity_schemas() {
2647 // Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md (v6): the identity
2648 // store gains its own db_skills/db_mcp_servers, becoming
2649 // authoritative — but the channel-store copies (run_object_schema)
2650 // are NOT removed, per that spec's §5.1/§8 item 3. Pins both halves
2651 // of that claim, not just the new one: it would be easy to
2652 // accidentally satisfy only the identity-store half of this and
2653 // silently drop the channel-store fallback.
2654 let object_conn = Connection::open_in_memory().unwrap();
2655 run_object_schema(&object_conn).unwrap();
2656 assert!(table_exists(&object_conn, "db_skills"), "channel store must keep db_skills");
2657 assert!(table_exists(&object_conn, "db_mcp_servers"), "channel store must keep db_mcp_servers");
2658
2659 let identity_conn = Connection::open_in_memory().unwrap();
2660 run_identity_store_schema(&identity_conn).unwrap();
2661 run_identity_store_schema(&identity_conn).unwrap(); // idempotent second pass
2662 assert!(table_exists(&identity_conn, "db_skills"), "identity store must have db_skills");
2663 assert!(table_exists(&identity_conn, "db_mcp_servers"), "identity store must have db_mcp_servers");
2664 assert!(index_exists(&identity_conn, "idx_ids_skills_is_global"));
2665 assert!(index_exists(&identity_conn, "idx_ids_mcp_servers_is_global"));
2666
2667 // No ref tables here yet — those are Phase 3/6, not Phase 2. Schema
2668 // for a phase that hasn't shipped would be its own kind of drift.
2669 assert!(!table_exists(&identity_conn, "db_agent_skills_ref"));
2670 assert!(!table_exists(&identity_conn, "db_bundle_skills_ref"));
2671 assert!(!table_exists(&identity_conn, "db_agent_mcp_ref"));
2672 assert!(!table_exists(&identity_conn, "db_bundle_mcp_ref"));
2673 }
2674
2675 #[test]
2676 fn test_object_schema_omits_dead_tables() {
2677 let conn = Connection::open_in_memory().unwrap();
2678 run_object_schema(&conn).unwrap();
2679 for dead in DEAD_TABLE_DROPS {
2680 assert!(!table_exists(&conn, dead), "{dead} must not be created");
2681 }
2682 // Legacy forge names are never created either.
2683 for (legacy, _) in LEGACY_TABLE_RENAMES {
2684 assert!(
2685 !table_exists(&conn, legacy),
2686 "legacy {legacy} must not be created by the flat schema"
2687 );
2688 }
2689 }
2690
2691 #[test]
2692 fn test_adopt_legacy_renames_forge_tables() {
2693 // Simulate a pre-flatten (post-v11) dev DB: legacy forge table
2694 // names + a dead workflow table, with seeded rows.
2695 let conn = Connection::open_in_memory().unwrap();
2696 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2697 conn.execute_batch(
2698 "CREATE TABLE db_forge_agents (
2699 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
2700 icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
2701 description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
2702 shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
2703 auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
2704 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
2705 agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
2706 agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
2707 accounts TEXT NOT NULL DEFAULT '',
2708 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
2709 created_at INTEGER NOT NULL DEFAULT 0
2710 );
2711 CREATE UNIQUE INDEX idx_forge_agents_slug ON db_forge_agents(slug);
2712 INSERT INTO db_forge_agents (id, slug, name, provider)
2713 VALUES ('a1', 'coder', 'Coder', 'claude');
2714
2715 CREATE TABLE db_workflow_definitions (id TEXT PRIMARY KEY);",
2716 )
2717 .unwrap();
2718
2719 run_object_schema(&conn).unwrap();
2720
2721 // Renamed, data preserved.
2722 assert!(table_exists(&conn, "db_agent_definitions"));
2723 assert!(!table_exists(&conn, "db_forge_agents"));
2724 let name: String = conn
2725 .query_row(
2726 "SELECT name FROM db_agent_definitions WHERE id='a1'",
2727 [],
2728 |r| r.get(0),
2729 )
2730 .unwrap();
2731 assert_eq!(name, "Coder");
2732 // Old index dropped. No new index takes its place — db_agent_definitions
2733 // is dropped whole as of v32 (OBJECT_SCHEMA_VERSION's v32 doc comment),
2734 // so the flat schema no longer declares a unique index for it; the
2735 // renamed table exists only transiently, until the drop migration
2736 // that runs later in the same upgrade removes it.
2737 assert!(!index_exists(&conn, "idx_forge_agents_slug"));
2738 // Dead table dropped.
2739 assert!(!table_exists(&conn, "db_workflow_definitions"));
2740 }
2741
2742 #[test]
2743 fn test_adopt_legacy_is_noop_on_fresh_db() {
2744 let conn = Connection::open_in_memory().unwrap();
2745 run_object_schema(&conn).unwrap();
2746 // Re-running schema (which re-runs adopt) on the already-flat DB
2747 // leaves the de-forged tables intact and creates no legacy names.
2748 // db_agent_definitions itself is gone from the flat schema as of
2749 // v32, so a live de-forged table that's still declared (db_agents)
2750 // stands in for "the flat schema is present, nothing to adopt".
2751 run_object_schema(&conn).unwrap();
2752 assert!(table_exists(&conn, "db_agents"));
2753 assert!(!table_exists(&conn, "db_forge_agents"));
2754 }
2755
2756 #[test]
2757 fn test_adopt_legacy_both_tables_present_is_non_destructive() {
2758 // Downgrade-roundtrip: a flat DB (db_agent_definitions) where a
2759 // pre-flatten build later re-created db_forge_agents and wrote a
2760 // row. The adopt step must NOT drop the legacy table — silent
2761 // data loss is the bug class behind PR #933's Codex P1.
2762 let conn = Connection::open_in_memory().unwrap();
2763 run_object_schema(&conn).unwrap();
2764 // db_agent_definitions is gone from the flat schema as of v32, so
2765 // this fixture has to construct the "downgrade-era db already has
2766 // the de-forged table too" scenario by hand — minimal shape, this
2767 // test only cares about existence + data survival, not full column
2768 // parity.
2769 conn.execute_batch(
2770 "CREATE TABLE db_agent_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL);
2771 CREATE TABLE db_forge_agents (id TEXT PRIMARY KEY, name TEXT NOT NULL);
2772 INSERT INTO db_forge_agents (id, name) VALUES ('downgrade-era', 'Recover Me');",
2773 )
2774 .unwrap();
2775
2776 run_object_schema(&conn).unwrap();
2777
2778 // Legacy table left intact — data recoverable, not dropped.
2779 assert!(table_exists(&conn, "db_forge_agents"));
2780 let name: String = conn
2781 .query_row(
2782 "SELECT name FROM db_forge_agents WHERE id='downgrade-era'",
2783 [],
2784 |r| r.get(0),
2785 )
2786 .unwrap();
2787 assert_eq!(name, "Recover Me");
2788 // Flat table still present and authoritative.
2789 assert!(table_exists(&conn, "db_agent_definitions"));
2790 }
2791
2792 #[test]
2793 fn test_adopt_legacy_fk_cascade_survives_rename() {
2794 // A renamed parent must keep cascading into renamed children.
2795 let conn = Connection::open_in_memory().unwrap();
2796 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2797 conn.execute_batch(
2798 "CREATE TABLE db_forge_agents (
2799 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
2800 icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
2801 description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
2802 shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
2803 auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
2804 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
2805 agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
2806 agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
2807 accounts TEXT NOT NULL DEFAULT '',
2808 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
2809 created_at INTEGER NOT NULL DEFAULT 0
2810 );
2811 CREATE TABLE db_forge_content (
2812 agent_id TEXT NOT NULL, content_type TEXT NOT NULL,
2813 content TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0,
2814 PRIMARY KEY (agent_id, content_type),
2815 FOREIGN KEY (agent_id) REFERENCES db_forge_agents(id) ON DELETE CASCADE
2816 );
2817 INSERT INTO db_forge_agents (id, name, provider) VALUES ('a1', 'Coder', 'claude');
2818 INSERT INTO db_forge_content (agent_id, content_type, content)
2819 VALUES ('a1', 'soul', 'hello');",
2820 )
2821 .unwrap();
2822
2823 run_object_schema(&conn).unwrap();
2824
2825 conn.execute("DELETE FROM db_agent_definitions WHERE id='a1'", [])
2826 .unwrap();
2827 let remaining: i64 = conn
2828 .query_row(
2829 "SELECT count(*) FROM db_agent_content WHERE agent_id='a1'",
2830 [],
2831 |r| r.get(0),
2832 )
2833 .unwrap();
2834 assert_eq!(
2835 remaining, 0,
2836 "FK cascade must survive the forge→agent table rename"
2837 );
2838 }
2839
2840 #[test]
2841 fn legacy_column_added_to_a_still_present_db_agent_definitions_via_alter() {
2842 // Codex P1 on #3096: the flat schema no longer CREATEs
2843 // db_agent_definitions (dropped as of v32), but several pending
2844 // migrations (m0007, m0025, m0028) call Store::open() internally —
2845 // running run_object_schema — before m0029 (numbered last) actually
2846 // drops the table in the same upgrade pass. A store old enough to
2847 // still carry the table but missing a column those migrations read
2848 // (e.g. user_hidden) must still get it ALTERed in, or their own
2849 // reads fail with "no such column" before they ever reach m0029.
2850 let conn = Connection::open_in_memory().unwrap();
2851 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2852 conn.execute_batch(
2853 "CREATE TABLE db_agent_definitions (
2854 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '',
2855 name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '✦',
2856 provider TEXT NOT NULL,
2857 description TEXT NOT NULL DEFAULT '',
2858 working_directory TEXT NOT NULL DEFAULT '',
2859 shell TEXT NOT NULL DEFAULT '',
2860 provider_flags TEXT NOT NULL DEFAULT '',
2861 auto_start INTEGER NOT NULL DEFAULT 0,
2862 restart_on_crash INTEGER NOT NULL DEFAULT 0,
2863 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
2864 agent_type TEXT NOT NULL DEFAULT 'standalone',
2865 environment TEXT NOT NULL DEFAULT '',
2866 agent_bus_id TEXT NOT NULL DEFAULT '',
2867 is_seeded INTEGER NOT NULL DEFAULT 0,
2868 accounts TEXT NOT NULL DEFAULT '',
2869 parent_id TEXT NOT NULL DEFAULT '',
2870 branch_label TEXT NOT NULL DEFAULT '',
2871 created_at INTEGER NOT NULL DEFAULT 0
2872 );
2873 INSERT INTO db_agent_definitions (id, name, provider, is_seeded)
2874 VALUES ('pre-existing', 'Old Template', 'claude', 1);",
2875 )
2876 .unwrap();
2877
2878 run_object_schema(&conn).unwrap();
2879 // Idempotent — second pass must not error and must not
2880 // re-default existing rows.
2881 run_object_schema(&conn).unwrap();
2882
2883 let hidden: i64 = conn
2884 .query_row(
2885 "SELECT user_hidden FROM db_agent_definitions WHERE id='pre-existing'",
2886 [],
2887 |r| r.get(0),
2888 )
2889 .unwrap();
2890 assert_eq!(
2891 hidden, 0,
2892 "ALTER must default existing rows to 0 (visible), never to 1",
2893 );
2894 }
2895
2896 #[test]
2897 fn legacy_alter_is_skipped_once_db_agent_definitions_is_gone() {
2898 // The other half of the gate: once the table doesn't exist at all
2899 // (a fresh v32+ install, or an upgraded store after m0029 has
2900 // already dropped it), the conditional ALTER block must not even
2901 // attempt the statement — "no such table" is not "duplicate
2902 // column" and would not be swallowed.
2903 let conn = Connection::open_in_memory().unwrap();
2904 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
2905 run_object_schema(&conn).unwrap();
2906 assert!(!table_exists(&conn, "db_agent_definitions"));
2907 }
2908
2909 #[test]
2910 fn stamp_version_writes_pragma() {
2911 let conn = Connection::open_in_memory().unwrap();
2912 stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
2913 let v: i64 = conn
2914 .query_row("PRAGMA user_version", [], |r| r.get(0))
2915 .unwrap();
2916 assert_eq!(v, OBJECT_SCHEMA_VERSION);
2917 }
2918
2919 #[test]
2920 fn check_schema_compat_refuses_newer_db_without_writing() {
2921 // `SPEC_DATA_CHANNELS_2026_05_24.md` §3.3 safety lock — if the
2922 // DB on disk was stamped by a newer AgentMux binary, this one
2923 // MUST refuse to open it. The split into
2924 // check_schema_compat + stamp_version (codex P1 on #1029)
2925 // ensures the check runs BEFORE any migration side effects:
2926 // legacy-table rename + seed-insert in `run_object_schema` are
2927 // mutating, and if we ran them first we'd partially alter a
2928 // newer DB before the error fired.
2929 let conn = Connection::open_in_memory().unwrap();
2930 conn.execute_batch("PRAGMA user_version = 99;").unwrap();
2931 let err = check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")
2932 .expect_err("expected refusal");
2933 match err {
2934 StoreError::SchemaTooNew { db, found, expected } => {
2935 assert_eq!(db, "objects.db");
2936 assert_eq!(found, 99);
2937 assert_eq!(expected, OBJECT_SCHEMA_VERSION);
2938 }
2939 other => panic!("expected SchemaTooNew, got {other:?}"),
2940 }
2941 // Crucially, `check_schema_compat` made NO writes. The
2942 // `user_version` is still 99. (Before the split — when the
2943 // single function combined the check with the write — this
2944 // invariant held only by virtue of the early `return Err`
2945 // skipping the stamp; with the split the function is
2946 // structurally read-only, eliminating the risk that a
2947 // future refactor reintroduces a downgrade-corrupt path.)
2948 let v: i64 = conn
2949 .query_row("PRAGMA user_version", [], |r| r.get(0))
2950 .unwrap();
2951 assert_eq!(v, 99, "rejected DB must not have its user_version stamp overwritten");
2952 }
2953
2954 #[test]
2955 fn check_schema_compat_accepts_equal_or_lower_without_writing() {
2956 // check_schema_compat must NEVER write — it just gates
2957 // migrations. The on-disk `user_version` is untouched by it,
2958 // regardless of whether the verdict is accept or reject.
2959 // stamp_version is the only thing that writes, and only after
2960 // migrations succeed.
2961
2962 // Equal: check passes silently.
2963 let conn = Connection::open_in_memory().unwrap();
2964 conn.execute_batch(&format!("PRAGMA user_version = {};", OBJECT_SCHEMA_VERSION))
2965 .unwrap();
2966 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
2967 let v: i64 = conn
2968 .query_row("PRAGMA user_version", [], |r| r.get(0))
2969 .unwrap();
2970 assert_eq!(v, OBJECT_SCHEMA_VERSION);
2971
2972 // Lower (forward-migration path): check passes, version stays
2973 // at the OLD value — stamp_version is what bumps it after
2974 // migrations.
2975 let conn = Connection::open_in_memory().unwrap();
2976 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2977 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
2978 let v: i64 = conn
2979 .query_row("PRAGMA user_version", [], |r| r.get(0))
2980 .unwrap();
2981 assert_eq!(v, 1, "check_schema_compat must not write to user_version");
2982
2983 // Then stamp_version bumps it as the post-migration step.
2984 stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
2985 let v: i64 = conn
2986 .query_row("PRAGMA user_version", [], |r| r.get(0))
2987 .unwrap();
2988 assert_eq!(v, OBJECT_SCHEMA_VERSION);
2989 }
2990
2991 #[test]
2992 fn test_filestore_migrations_idempotent() {
2993 let conn = Connection::open_in_memory().unwrap();
2994 run_filestore_migrations(&conn).unwrap();
2995 run_filestore_migrations(&conn).unwrap();
2996 assert!(table_exists(&conn, "db_wave_file"));
2997 assert!(table_exists(&conn, "db_file_data"));
2998 }
2999
3000 #[test]
3001 fn test_saga_log_migrations_idempotent() {
3002 let conn = Connection::open_in_memory().unwrap();
3003 run_saga_log_migrations(&conn).unwrap();
3004 run_saga_log_migrations(&conn).unwrap();
3005 assert!(table_exists(&conn, "saga"));
3006 assert!(table_exists(&conn, "saga_step"));
3007 }
3008
3009 /// Identity M4c-1: each store's actor-UID columns exist on a fresh
3010 /// store, and an existing store at the previous version gains them on
3011 /// reopen — through each store's real `open`, so the flat CREATE, the
3012 /// ALTER loop and the version bump are all exercised. A row written
3013 /// before the upgrade reads the column's default, `''` (unknown).
3014 #[test]
3015 fn m4c1_actor_uid_columns_on_fresh_and_upgraded_stores() {
3016 use super::super::store::Store;
3017 type Open = fn(&std::path::Path) -> Result<Store, StoreError>;
3018 let cases: [(&str, Open, i64, i64, &[(&str, &str)]); 3] = [
3019 (
3020 "identity-store.db",
3021 Store::open_identity_store,
3022 10,
3023 IDENTITY_STORE_SCHEMA_VERSION,
3024 &[
3025 ("db_work_queue", "created_by_uid"),
3026 ("db_cron_jobs", "created_by_uid"),
3027 ("db_bundle_versions", "written_by_uid"),
3028 ],
3029 ),
3030 (
3031 "store.db",
3032 Store::open_shared,
3033 11,
3034 SHARED_STORE_SCHEMA_VERSION,
3035 &[
3036 ("db_cron_jobs", "created_by_uid"),
3037 ("db_bundle_versions", "written_by_uid"),
3038 ],
3039 ),
3040 (
3041 "objects.db",
3042 Store::open,
3043 38,
3044 OBJECT_SCHEMA_VERSION,
3045 &[("db_bundle_versions", "written_by_uid")],
3046 ),
3047 ];
3048 let dir = tempfile::tempdir().unwrap();
3049 for (file, open, old, new, cols) in cases {
3050 // M4c-1 was one bump each; later bumps (objects v40, durable
3051 // jekt) keep the upgrade from `old` covered.
3052 assert!(new > old, "{file}: the current version is past M4c-1's base");
3053 let user_version = |c: &Connection| -> i64 {
3054 c.query_row("PRAGMA user_version", [], |r| r.get(0))
3055 .unwrap()
3056 };
3057
3058 // Fresh.
3059 let fresh = dir.path().join(format!("fresh-{file}"));
3060 drop(open(&fresh).unwrap());
3061 let conn = Connection::open(&fresh).unwrap();
3062 for (table, col) in cols {
3063 assert!(
3064 column_exists(&conn, table, col),
3065 "fresh {file}: {table}.{col}"
3066 );
3067 }
3068 assert_eq!(user_version(&conn), new);
3069 drop(conn);
3070
3071 // Upgraded: the same store as the previous version left it —
3072 // the columns absent, stamped `old` — with a version row in it.
3073 let path = dir.path().join(file);
3074 drop(open(&path).unwrap());
3075 let conn = Connection::open(&path).unwrap();
3076 for (table, col) in cols {
3077 // Rebuilt without the column: SQLite's `DROP COLUMN` fails
3078 // to re-parse these tables' commented CREATE text.
3079 let keep: Vec<String> = conn
3080 .prepare(&format!("PRAGMA table_info({table})"))
3081 .unwrap()
3082 .query_map([], |r| r.get::<_, String>(1))
3083 .unwrap()
3084 .map(Result::unwrap)
3085 .filter(|c| c != col)
3086 .collect();
3087 conn.execute_batch(&format!(
3088 "CREATE TABLE m4c1_old AS SELECT {} FROM {table};
3089 DROP TABLE {table};
3090 ALTER TABLE m4c1_old RENAME TO {table};",
3091 keep.join(", ")
3092 ))
3093 .unwrap();
3094 assert!(!column_exists(&conn, table, col));
3095 }
3096 conn.execute_batch(&format!(
3097 "PRAGMA user_version = {old};
3098 INSERT INTO db_bundle_versions
3099 (id, bundle_id, name, instructions, content_hash)
3100 VALUES ('v-old', 'b', 'n', 'i', 'h');"
3101 ))
3102 .unwrap();
3103 drop(conn);
3104
3105 drop(open(&path).unwrap());
3106 let conn = Connection::open(&path).unwrap();
3107 for (table, col) in cols {
3108 assert!(
3109 column_exists(&conn, table, col),
3110 "upgraded {file}: {table}.{col}"
3111 );
3112 }
3113 assert_eq!(user_version(&conn), new, "{file}");
3114 let uid: String = conn
3115 .query_row(
3116 "SELECT written_by_uid FROM db_bundle_versions WHERE id = 'v-old'",
3117 [],
3118 |r| r.get(0),
3119 )
3120 .unwrap();
3121 assert_eq!(
3122 uid, "",
3123 "{file}: a pre-upgrade row is unknown, never guessed"
3124 );
3125 }
3126 }
3127}