diff --git a/CHANGELOG.md b/CHANGELOG.md index b6745ec2..72916ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ breaks something, or changes the observable behavior of existing surfaces broadly; **patch** for fixes, and for additive opt-in capability โ€” a new key, flag, or page that no existing user can encounter without opting into it. +## [Unreleased] + +### Fixed + + +- Serving registrations and their stable URL-name assignments no longer disappear + when attachment expiry updates the registry concurrently. Protection requires + both the running broker and brain to be updated; a brain-only update with an + older broker does not activate it. + ## [0.70.0] - 2026-09-13 ### Added diff --git a/docs-site/src/serving/attachments.md b/docs-site/src/serving/attachments.md index e3dbf6ea..cf30dd46 100644 --- a/docs-site/src/serving/attachments.md +++ b/docs-site/src/serving/attachments.md @@ -56,11 +56,23 @@ when the sender meant days is a deleted attachment. $ printf 'valid for a day' | spt send doyle --attachment ./build.log --ttl 24h ``` -The daemon's pulse reaps what has expired, deleting the snapshot file and its -registry entry together and logging how many it removed. An expired entry stops -serving the moment it expires, whether or not the reaper has run yet: between -expiry and the next tick the bytes are still on disk, and serving them would -make the TTL a suggestion. +The brain's pulse reaps expired registry entries and logs how many it removed. +For an attachment, it attempts to unlink the core-owned snapshot **before** +saving the registry without that entry. These are not one atomic transaction: +if saving fails after unlinking, the next pulse can finish removing the expired +entry even though its snapshot is already gone. Saving first could instead +leave untracked snapshot bytes that later pulses cannot find. + +Only attachment snapshots under `$SPT_HOME/serve/snapshots/` are candidates for +unlinking. Expiry removes a live file or directory reference from the registry +but **never deletes the user's source**. The +[writer-coordination contract](overview.md#register-inspect-remove) keeps the +guard across the reaper's fresh load, snapshot cleanup, and explicit save; +a pass with nothing expired does not save the registry. + +An expired entry stops serving the moment it expires, whether or not the +reaper has run yet: between expiry and the next tick the bytes may still be +on disk, and serving them would make the TTL a suggestion. ## Pulling one: `spt fetch` diff --git a/docs-site/src/serving/overview.md b/docs-site/src/serving/overview.md index 8e34b324..3521c45a 100644 --- a/docs-site/src/serving/overview.md +++ b/docs-site/src/serving/overview.md @@ -115,7 +115,8 @@ Served names remain stable for an entry's lifetime: - Allocation history is persisted per node. Another node has its own names; the node prefix keeps their URLs distinct. -The daemon is the only writer of `$SPT_HOME/serve/registry`. The store includes +The broker and brain are separate processes that write +`$SPT_HOME/serve/registry`; CLI clients do not write it directly. The store includes entry ids, kinds (`file`, `dir`, `attachment`), paths, assigned names, registration timestamps, optional origin endpoint ids, and allocation history. Optional lifetime (`ttl_ms`) and receiving-endpoint `audience` metadata apply @@ -125,6 +126,14 @@ registration and its enforcement belong to W2. Do not edit or remove this file to reclaim a name: that would destroy the history which prevents stale URLs changing meaning. +Writer coordination requires both processes to hold the +same `serve/registry.lock` guard across reading and updating the registry. +Atomic snapshot replacement alone does not prevent one writer from losing +another's update. Both running writer implementations must be updated: +a new brain with an old broker does not fully activate protection. HTTP readers remain +lock-free; [attachment expiry and source protection](attachments.md#lifetime) +are unchanged. + ## Adapter served roots diff --git a/docs/KNOWN-HAZARDS.md b/docs/KNOWN-HAZARDS.md index 039c2456..c2d05afd 100644 --- a/docs/KNOWN-HAZARDS.md +++ b/docs/KNOWN-HAZARDS.md @@ -459,6 +459,14 @@ Hard-won edge cases harvested from the sister project (`claude_skill_owl`, ~80 c - **Invariant:** decode tail bytes lossily only after discarding the leading row fragment; append above the global maximum. Detect non-increasing sequences, including equal adjacent values and resets hidden between a lower first and higher last row. Under the exclusive lock, repair retained rows in file order above their old maximum, preserving payloads. Retention keeps the newest rows by position, not sequence value. - **spt-core mapping:** `spt_store::iolog::{last_seq_at, append_locked, trim_locked}` and the mechanism, repair, and position-retention regression units. Repair can replay retained history once to an old cursor; it does not make old events new. +### 6.14 Serving-registry writers must serialize across processes + +- **Failure:** the broker handles serving registrations while the brain independently reaps expired entries. Each can load `serve/registry`, mutate its own snapshot, and atomically publish it; the later stale publication can erase an acknowledged registration and name-allocation history. A thread-only mutex cannot coordinate these separate processes. +- **Invariant:** every writer holds the stable `serve/registry.lock` guard from **before fresh load** through mutation, snapshot side effects, and explicit save. Atomic replacement protects readers from torn snapshots, not writers from lost updates. The authoritative [writer serialization contract](STORAGE.md#writer-serialization) defines sentinel lifetime, no-op saves, and activation requiring **both** broker and brain implementations; an old broker with a new brain is not protected. +- **Preserved requirements:** `REQ-WEB-SERVING-REGISTRY` retains entries and stable name ownership; `REQ-WEB-ATTACHMENT-PULL` retains snapshot unlink-before-save and never deletes user reference sources; `REQ-WEB-ENTRY-AUDIENCE` retains per-entry TTL/audience semantics. HTTP remains a lock-free atomic-snapshot reader. +- **spt-core mapping:** `spt_store::serving::lock_registry_at`, broker `servehost::apply_at`, brain `livehost::reap_expired_attachments`, and direct live-daemon fixture writers. +- **Evidence / status (releases#308):** the lost-update defect is proven from source; the exact original failure schedule is inferred, not captured. The observed serial-consumer cohort was **1 red in 10 runs**, a sample rather than a probability floor. Linux validation passed three deterministic two-process storage regressions, 26 serving-store units, 13 servehost units, and both affected serving E2Es. Disabling only the OS lock made the selected regression fail its independent contention witness; restoring the original source passed. This is storage-process plus E2E evidence, not a captured replay of the original broker/brain schedule. Windows validation remains pending. + --- ## 7. Boundary & delivery integrity (added 2026-05-31 โ€” Stage A red-team) diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 575aaa6e..09d88cde 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -144,10 +144,12 @@ would refuse). `$SPT_HOME/serve/registry` is schema-versioned JSON, not a regenerable discovery -cache. The daemon is its single writer and publishes durable atomic snapshots; -the HTTP listener and listing read those snapshots. Only a genuinely missing -file initializes an empty registry. Unreadable, corrupt, unsupported, or -inconsistent history refuses the operation rather than recycling stale URLs. +cache. The broker and brain are separate writer processes: broker control +requests register, remove, and reconcile entries; the brain's live-host pulse +reaps expired entries. They publish durable atomic snapshots; the HTTP listener +reads those snapshots without a writer lock. Only a genuinely missing file +initializes an empty registry. Unreadable, corrupt, unsupported, or inconsistent +history refuses the operation rather than recycling stale URLs. Schema 1 carries `entries`, `next_suffix` (per requested-name family), and `name_owners` (every assigned name mapped to its prior absolute `path` and @@ -183,6 +185,48 @@ Explicit references point outside SPT_HOME too: copying SPT_HOME alone does not copy their bytes or relocate their absolute paths. Losing the registry loses the history which prevents an old URL from naming a different resource. +### Writer serialization + +The releases#308 repair preserves schema 1, the CLI, TTLs, and the existing +save decisions. +See [KNOWN-HAZARDS ยง6.14](KNOWN-HAZARDS.md#614-serving-registry-writers-must-serialize-across-processes) +for the lost-update evidence and its limits. + +Every writer acquires `spt_store::serving::lock_registry_at(registry_path)` +**before a fresh load**, and keeps its returned `std::fs::File` alive through +the complete read-modify-write operation: load, mutation, associated snapshot +creation or cleanup, and any explicit `save_at`. Lock acquisition failure +refuses the operation; it never permits an unlocked write. The lock is +non-reentrant: a guarded caller must not reacquire it for the same registry. + +The guard opens a fresh file handle and takes an `fs2` exclusive lock on a +stable sibling formed by appending `.lock` to the **full registry path**: +`$SPT_HOME/serve/registry.lock`. Never lock the registry inode itself: atomic +publication replaces that file. Never unlink the sentinel, including on +unlock or cleanup: waiters and new writers must lock the same file. Dropping +the guard or exiting the process releases the lock without deleting it. + +Atomic publication and mutual exclusion solve different problems. Atomic +replacement gives lock-free HTTP readers a complete old or new snapshot; it +does not stop a stale writer from replacing a newer snapshot. Locking only +`save_at` is too late, and a process-local mutex cannot coordinate the broker +with the brain. Direct writers, including live-daemon fixture mutations, must +follow the same full-operation guard contract. + +The guard **never auto-saves**, including on drop. Read-only listing, an +unchanged input-reference replay, and a reaper pass with no expired entries +do not save the registry; a missing registry remains missing after a no-op +reap. Existing operations that explicitly save, such as reconciliation, still +do so. Snapshot unlink-before-save ordering and user-source protection remain +the [attachment lifetime contract](../docs-site/src/serving/attachments.md#lifetime) +(`REQ-WEB-ATTACHMENT-PULL`); locking does not make snapshot deletion and registry +publication one atomic transaction. + +Protection is active only when **both running writer implementations** use +this protocol. Updating only the brain while an old broker survives does not +fully activate it; a new broker paired with an old brain is likewise unsafe. +The sentinel's presence is not proof that both writers participate. + ## tracked/ โ€” the context git repo - A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-`** (per-agent) and **`p-`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents//` and `projects//` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact. diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 1550de0f..bf70ec01 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -4880,6 +4880,7 @@ requirements = [ "REQ-HAZARD-EBUSY-RENAME", "REQ-HAZARD-INFO-JSON-TORN-READ", "REQ-HAZARD-REGISTRY-STALE-CLEAN", + "REQ-HAZARD-SERVE-REGISTRY-LOST-UPDATE", "REQ-HAZARD-SINGLE-PATH-SOURCE", "REQ-HAZARD-STALE-INDEX-LOCK", "REQ-HAZARD-STORE-INIT-RACE", @@ -7744,3 +7745,8 @@ required_stages = ["doc", "impl"] # ACTIVATED by this lane (todlando 2026-09-13 id = "REQ-RC-HITCH-DISCRIMINATOR" title = "releases#302 diagnostic-only: an explicitly opted-in daemon-status probe reads the live broker's existing net_canary_age_ms and active_dial_tasks atomics on a fresh bounded IPC connection, without session/output-log locks or work scheduled on the net runtime. Report broker sample time and client elapsed time; absent net hosts and unsupported or unresponsive brokers must not appear healthy. Ordinary status is unchanged. This instruments the field discriminator, not a remedy or a claim that the rc hitch is attributed." required_stages = ["impl"] # Instrumentation only; no bug-fix or field acceptance stage claimed. + +[[requirements]] +id = "REQ-HAZARD-SERVE-REGISTRY-LOST-UPDATE" +title = "Serving-registry writers in the broker, brain TTL reaper, and live-daemon test fixtures serialize the complete fresh-load, mutation, snapshot side effects, and durable publication across processes using one stable per-registry sentinel. Atomic replacement alone must not erase acknowledged registrations or persisted name-allocation history. Lock acquisition failure never permits an unlocked write, and process exit releases the lock. Both writer binaries must participate; a brain-only update paired with an old writer broker does not close the race (releases#308)." +required_stages = ["doc", "impl", "unit", "int"]