You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Echo info_source_id on blob_available and fetch_failed; revise MUST-2 #28
Epic:CannObserv/archiver#137 · Step 2 — info_source_id on the wire Contract: CannObserv/cannobserv (co-core info_source_id on the content contracts)
Context
#137 reverses cannobserv#266's exclusion of info_source_id from the content contracts. The rationale was that content contracts should stay domain-agnostic; co-core is in fact the Cannabis Observer domain library (legislation.py, person.py, jurisdiction.py, information.py, source.py, replication.py all live beside changes.py), so the purity rule was applied in the one place it costs us.
Replicator's part is mechanical: echo the field, interpret nothing.
Scope
Bump co-core to the release carrying info_source_id.
Copy command.info_source_id verbatim onto BlobAvailableEvent and FetchFailedEvent at emit time. No validation, no parsing, no use in dedupe, no use in the envelope key, no participation in any routing or policy decision. Replicator holds no domain state and this does not change that.
None in → None out (a seed-harness emit with no info_source_id stays legal, same as command_id).
Contract doc updates
docs/contracts/content-fetch-issuer-contract.md needs revision in three places:
MUST-2 — soften from a correctness requirement to bookkeeping. The bus now does carry something that reconstructs the mapping, so the crash-between-mint-and-record window no longer loses the fact permanently. The issuer still wants a durable record for request options, audit, and the MUST-6 reaper; it is no longer the only thing standing between a fetch and an orphaned blob.
MUST-3 — leave the rule intact, add a sentence that the correct key is now on the fact directly, so there is no longer any reason to reach for url. The one-to-many argument (quoting Archiver's non-unique url) still stands and is still the reason url is not a key.
The success/failure fact tables — add the field.
Also update docs/contracts/content-fetch-issuer-reference.md if it enumerates payload fields.
CannObserv/archiver#118 asked Replicator whether Archiver or Watcher would own a content.blobs consumer group. The answer settled in #137 is neither is new — Archiver does not consume content.blobs; Watcher stays the correlator it already is. The blob TTL confirmation (7 days is ample) is answered on #118 directly.
Done when
Both facts carry info_source_id, echoed verbatim
Round-trip test: command with the field → fact carries it; command without → fact has None
Unblocked: cannobserv v0.8.0 is published. Pin co-core>=0.8,<0.9 at tag v0.8.0.
Three things landed differently from what CannObserv/cannobserv#300 proposed, and all three change this issue's scope.
1. The field is REQUIRED, not optional
The issue specified str | None = None with a tightening follow-up; that was reversed. info_source_id is required on ContentFetchCommand, BlobAvailableEvent, and FetchFailedEvent.
So the echo is not best-effort — a BlobAvailableEvent or FetchFailedEvent constructed without it raises at construction, in Replicator's own emit path. There is no None case to handle on either fact.
2. BlobAvailableEvent.command_id is required too
This one reverses cannobserv#266 and is worth checking against your emit path. It was optional so a "non-command-triggered emit (e.g. a seed harness)" would stay legal. The review could not find that producer — scripts/seed_fetch.pyissues commands and tails outcomes rather than emitting blobs.
If Replicator has any path that emits blob_available without a command_id, this breaks it and the finding was wrong — say so and we will restore the optionality in a patch. Otherwise nothing to do.
3. blob_available is re-keyed to content_fingerprint:command_id
Previously the bare content_fingerprint. This is a delivery-behaviour change on your stream: emissions that previously collapsed now all deliver. Consumers are already required to be idempotent, so it moves in the safe direction, but the volume characteristics change.
The reason it had to change: a bare fingerprint collapsed every fetch producing identical bytes into one fact — fine while the payload described only the bytes, not fine once it carried a domain key. Two InfoSources on one URL (exactly the case MUST-3 exists for) would collapse to a single fact naming whichever issuer won the race.
It also closes a pre-existing hole worth knowing about: the second command for identical bytes was deduped away and therefore never closed, leaving its issuer waiting on a fact that would never arrive. That looked like a slow origin, not a bug.
MUST-2 revision — scope it carefully
The issue asked for a doc revision here. With the field required, MUST-2 can relax from a correctness requirement to bookkeeping — but only once CannObserv/watcher#252 also ships. Until then an issuer cannot rely on the wire carrying the mapping, so the contract doc should describe the guarantee as new, not retroactive. Wording that survives both states is better than wording that has to be revised twice.
Also new, no action required
BlobAvailableEvent.blob_expires_at (optional) — when the blob stops being retrievable. Populating it lets Watcher/Archiver record a cache horizon honestly instead of hard-coding MUST-7's TTL, whose clock runs from last fetch reference and which no consumer can observe. Worth populating if you have the value cheaply.
The old "validator freshness caveat" is dissolved by the re-key — every fetch now emits its own fact with its own etag / last_modified / final_url.
1. Flush the streams.schema_version stays 1. cannobserv#266's justification (zero producers/consumers) no longer held — Replicator emits these today — so the decision rests instead on the wire being pre-production and discardable. Bumping to 2 would imply a v1 consumers must branch on, when the correct operation is to drop the v1 messages. Flushing content.fetch and content.blobs is a prerequisite step of this deploy, not optional cleanup.
2. Deploy the step-2 pair together. Required fields mean a half-deployed cluster does not degrade — it dead-letters. A Replicator on 0.8.0 will fail from_wire on any command from a Watcher still on 0.7.x, and a dead-lettered command destroys the command_id correlator. #28 and CannObserv/watcher#252 may be worked in parallel but must ship together, streams flushed between. Same for step 3's pair (CannObserv/archiver#139 before CannObserv/watcher#253).
Replicator's read, and the two answers the comments ask for
1. No blob_available emit without a command_id — the finding was right
Nothing to restore; keep it required.
src/worker/handler.py is the only BlobAvailableEvent construction site in src/, and it sits inside handle(command) with command_id=command.command_id.
src/worker/loop.py dead-letters a blank command_idbefore the handler runs, so the field is non-empty by the time the handler sees it.
scripts/seed_fetch.py issues commands and decodes facts read-only. It never emits one. The hypothetical seed-harness producer cannobserv#266 protected does not exist here either.
2. blob_expires_at — yes, populating it
The value is cheap and, more importantly, honest. LocalBlobStore.storeos.utimes on the content-addressed short-circuit, so the TTL clock genuinely means "since last referenced" and fetched_at + REPLICATOR_BLOB_TTL_SECONDS is correct at emit time. A re-fetch only ever pushes it later; the sweep runs on an interval so the real reap is also later. The announced horizon errs conservative in both directions and never optimistic, which is the property a consumer's cache horizon needs.
One blocker the issue does not mention: the boundaries charter refuses the field
tests/test_boundaries.py carries DOMAIN_TOKENS = frozenset({"info_source", "info_item", "watched_item", "tenant", "aspect"}), and its vocabulary scan reads identifiers and string literals across all of src/. The first info_source_id=command.info_source_id trips test_no_module_names_a_domain_concept — the charter's own load-bearing assertion, written precisely against "one optional field, one small settings table, and Replicator has a domain model."
That test is not wrong; it is doing its job. The charter is what changed. Amending both in the same commit:
Keep info_source in DOMAIN_TOKENS. Add a narrow (module, token) allowlist — the two emit sites and the report dataclass, nothing else.
Add the compensating assertion the allowlist now requires: info_source_id appears nowhere in src/core/config.py (never a setting), nowhere in src/storage/ (never a path component), and never in a comparison, if, or dict-lookup subscript anywhere in src/. "Verbatim echo" is mechanically checkable; "pass-through" as prose is not, and prose is what erodes.
docs/contracts/replicator-boundaries.md gains the reasoning: one echo, required by the wire contract, interpreted nowhere.
Also not adding a blank-info_source_id guard to mirror the command_id one. command_id is refused because a fact is unkeyable without it; a blank domain id is the issuer's problem and refusing it would be interpretation. If co-core sets min_length, the refusal lives there, which is the right place.
Doc surface is wider than the three spots in the body
content-fetch-issuer-contract.md opens with "There is no info_source_id, and no other domain identity" — that reverses outright, ahead of the MUST-2 edit.
The envelope-key table row for blob_available → content_fingerprint:command_id.
The success-fact table's command_id | str \| None / "None only for non-command emits" → required.
MUST-5 ("do not dedupe facts on content_fingerprint") is strengthened by the re-key — the hole it warned consumers about is now closed at the producer too. Saying so is worth a sentence.
Two Replicator-internal docs assert the fact dedupes on the bare content_fingerprint (AGENTS.md Bus Conventions, docs/ARCHITECTURE.md). Both now wrong; ARCHITECTURE's carries the reasoning and gains from the correction.
MUST-2 wording — taking the "survives both states" advice literally and phrasing by deploy epoch rather than by "now": the durable record stays a MUST for request options, audit, and the MUST-6 reaper; facts emitted from the step-2 deploy onward carry info_source_id, so the crash-between-mint-and-record window stops permanently orphaning the mapping for those facts. True before and after CannObserv/watcher#252, and needs no second revision.
Failure-mode note for the half-deployed window
With the field required, a pre-0.8 command does not reach the fetch_failed path at all — it fails from_wire and lands in content.fetch.dlq via the anomaly route, silently. That is already the reference doc's "frame that fails from_wire entirely" row, so no new taxonomy, but it is the concrete shape of the dead-lettering this issue's second comment warns about: the correlator is destroyed at decode, one step earlier than a rejected command.
Flush list
content.fetch, content.blobs, and both .dlq streams — v1 dead-letters are unreplayable under 0.8.0, so leaving them is leaving a trap for whoever triages next. Plus replicator:cmd:* if any command_id will be reused across the flush.
Notcontent.fetch-policy. It is read groupless as a state stream; flushing it leaves the worker with no policy until the next publish. Will confirm FetchPolicyState is unchanged in 0.8.0 during the pin bump.
Proceeding TDD-first, starting with the charter amendment.
Branch 28-echo-info-source-id, five commits (f455deb..1a0d0a8). Held short of main deliberately: on this VM main is the deployed code, and this cannot deploy without CannObserv/watcher#252.
Done when — all three met.
Both facts carry info_source_id, echoed verbatim. Required on the wire, so there is no None case on either.
Round-trip tested in both directions: the value crosses from command to blob_available (test_the_domain_key_is_echoed_verbatim_onto_the_success_fact) and to fetch_failed (test_the_domain_key_is_echoed_verbatim_onto_the_failure_fact), asserted against a value the emit site could not have derived from anything else on the command. A command without the field no longer exists — it fails from_wire and dead-letters, which is the half-deploy failure mode rather than a case to test.
Issuer contract and reference both revised: MUST-2 narrowed by deploy epoch, MUST-3 kept with its last excuse removed, MUST-5 noted as closed on the producer side too, the envelope-key table re-keyed, and blob_expires_at documented in MUST-7.
Also landed, beyond the issue's scope.
blob_expires_at is populated (cannobserv#301). Cheap and honest here: store() touches mtime on its content-addressed short-circuit, so the TTL clock genuinely means "since last referenced", and the stamp is read before the store so the published horizon can only fall earlier than the real reap.
blob_ttl_seconds is bounded. Making the horizon arithmetic turned an unbounded float into an OverflowError raised after the bytes were stored — dead-lettering a command whose blob was fine and leaving it orphaned. A config typo should not be able to manufacture those.
The seed harness takes --info-source-id and the live target refuses both the placeholder and a blank.
The boundaries charter needed amending, and that was the substantive work.
tests/test_boundaries.py forbids domain vocabulary anywhere in src/, so the first info_source_id=command.info_source_id failed the charter's own load-bearing assertion. Correctly — the rule it protects is that Replicator never learns what a domain key means. Rather than delete the token, the exemption is three modules wide and one identifier wide, and a second scan took over the real invariant.
That scan is the part worth knowing about. It began as a deny-list of reading positions, and three review rounds each found positions it had not enumerated — six in the first, one in the second, two in the third — while the charter cited it as what made the carve-out safe. It is now inverted: an echo has four legal shapes and everything else fails, including shapes nobody has thought of. Probed against fifteen violations it was never told about — assert, comprehension filters, set literals, del, sort keys, async for, setdefault among them — all fifteen caught, the three legal shapes clean.
Answers to the two questions the release comments asked.
No path emits blob_available without a command_id.handler.py is the only construction site and always sits inside handle(command); loop.py dead-letters a blank id before the handler runs; seed_fetch.py issues commands and decodes facts read-only. The finding was right — keep it required, nothing to restore.
blob_expires_at populated, per above.
Verification. 498 unit + 18 live-broker integration tests, coverage 99.77%, ruff clean, ty at its pre-existing baseline. AGENTS.md and all three contract docs back inside their context budgets.
Not closing this issue yet — it closes when the step-2 pair is deployed together, streams flushed between. Order of operations is in docs/DEPLOYMENT.md.
Deployed — step-2 pair shipped together, 2026-08-08 22:38 UTC
Merged as #33 (f4ea53b), deployed alongside CannObserv/watcher#252 in one window. Both services on co-core 0.8.0.
Order executed. Both consumers stopped → XPENDING confirmed 0 → streams snapshotted, then flushed (content.fetch, content.blobs, both .dlqs: 69/69/0/0 entries, plus 27 replicator:cmd:* keys) → Replicator uv sync --frozen, Watcher alembic upgrade head (d5a71c93e0f2 → e7c4b2a91f60) + uv sync --frozen → Replicator started first, Watcher second.
content.fetch-policy was deliberately not flushed, and the boot proved why: the replay rebuilt 3 tracked hosts from all 1992 messages in 417 ms. Flushing it would have left every host on the fallback interval until Watcher republished.
Verified on the live wire, one real command end to end:
That is MUST-3 behaving exactly as written — it correlated on command_id, found no match for a synthetic seed id, and refused to fall back to url or to the domain key now sitting in front of it. The one warning in the window is the contract working.
Two notes.
The smoke test used a fake --info-source-id against --production, so one command and one fact naming a nonexistent InfoSource remain on the live streams. Watcher discarded the fact; the blob expires 2026-08-15. Left in place rather than XDEL'd, since removing entries a consumer has already processed is more disruptive than leaving two aged frames to fall off.
watcher.service reports failed after a clean systemctl stop because uvicorn exits 143 on SIGTERM and the unit lacks SuccessExitStatus=143. Cosmetic, pre-existing, and worth a one-line fix in that repo so a real failure is distinguishable from an ordinary stop.
Closing: both "Done when" criteria met, contract and reference revised, and the pair is live.
Activity
gregoryfoster commentedon Aug 7, 2026
Unblocked: cannobserv
v0.8.0is published. Pinco-core>=0.8,<0.9at tagv0.8.0.Three things landed differently from what CannObserv/cannobserv#300 proposed, and all three change this issue's scope.
1. The field is REQUIRED, not optional
The issue specified
str | None = Nonewith a tightening follow-up; that was reversed.info_source_idis required onContentFetchCommand,BlobAvailableEvent, andFetchFailedEvent.So the echo is not best-effort — a
BlobAvailableEventorFetchFailedEventconstructed without it raises at construction, in Replicator's own emit path. There is noNonecase to handle on either fact.2.
BlobAvailableEvent.command_idis required tooThis one reverses cannobserv#266 and is worth checking against your emit path. It was optional so a "non-command-triggered emit (e.g. a seed harness)" would stay legal. The review could not find that producer —
scripts/seed_fetch.pyissues commands and tails outcomes rather than emitting blobs.If Replicator has any path that emits
blob_availablewithout acommand_id, this breaks it and the finding was wrong — say so and we will restore the optionality in a patch. Otherwise nothing to do.3.
blob_availableis re-keyed tocontent_fingerprint:command_idPreviously the bare
content_fingerprint. This is a delivery-behaviour change on your stream: emissions that previously collapsed now all deliver. Consumers are already required to be idempotent, so it moves in the safe direction, but the volume characteristics change.The reason it had to change: a bare fingerprint collapsed every fetch producing identical bytes into one fact — fine while the payload described only the bytes, not fine once it carried a domain key. Two InfoSources on one URL (exactly the case MUST-3 exists for) would collapse to a single fact naming whichever issuer won the race.
It also closes a pre-existing hole worth knowing about: the second command for identical bytes was deduped away and therefore never closed, leaving its issuer waiting on a fact that would never arrive. That looked like a slow origin, not a bug.
MUST-2 revision — scope it carefully
The issue asked for a doc revision here. With the field required, MUST-2 can relax from a correctness requirement to bookkeeping — but only once CannObserv/watcher#252 also ships. Until then an issuer cannot rely on the wire carrying the mapping, so the contract doc should describe the guarantee as new, not retroactive. Wording that survives both states is better than wording that has to be revised twice.
Also new, no action required
BlobAvailableEvent.blob_expires_at(optional) — when the blob stops being retrievable. Populating it lets Watcher/Archiver record a cache horizon honestly instead of hard-coding MUST-7's TTL, whose clock runs from last fetch reference and which no consumer can observe. Worth populating if you have the value cheaply.etag/last_modified/final_url.gregoryfoster commentedon Aug 7, 2026
Deploy constraints this release introduces
1. Flush the streams.
schema_versionstays 1. cannobserv#266's justification (zero producers/consumers) no longer held — Replicator emits these today — so the decision rests instead on the wire being pre-production and discardable. Bumping to 2 would imply a v1 consumers must branch on, when the correct operation is to drop the v1 messages. Flushingcontent.fetchandcontent.blobsis a prerequisite step of this deploy, not optional cleanup.2. Deploy the step-2 pair together. Required fields mean a half-deployed cluster does not degrade — it dead-letters. A Replicator on 0.8.0 will fail
from_wireon any command from a Watcher still on 0.7.x, and a dead-lettered command destroys thecommand_idcorrelator. #28 and CannObserv/watcher#252 may be worked in parallel but must ship together, streams flushed between. Same for step 3's pair (CannObserv/archiver#139 before CannObserv/watcher#253).gregoryfoster commentedon Aug 7, 2026
Replicator's read, and the two answers the comments ask for
1. No
blob_availableemit without acommand_id— the finding was rightNothing to restore; keep it required.
src/worker/handler.pyis the onlyBlobAvailableEventconstruction site insrc/, and it sits insidehandle(command)withcommand_id=command.command_id.src/worker/loop.pydead-letters a blankcommand_idbefore the handler runs, so the field is non-empty by the time the handler sees it.scripts/seed_fetch.pyissues commands and decodes facts read-only. It never emits one. The hypothetical seed-harness producer cannobserv#266 protected does not exist here either.2.
blob_expires_at— yes, populating itThe value is cheap and, more importantly, honest.
LocalBlobStore.storeos.utimes on the content-addressed short-circuit, so the TTL clock genuinely means "since last referenced" andfetched_at + REPLICATOR_BLOB_TTL_SECONDSis correct at emit time. A re-fetch only ever pushes it later; the sweep runs on an interval so the real reap is also later. The announced horizon errs conservative in both directions and never optimistic, which is the property a consumer's cache horizon needs.One blocker the issue does not mention: the boundaries charter refuses the field
tests/test_boundaries.pycarriesDOMAIN_TOKENS = frozenset({"info_source", "info_item", "watched_item", "tenant", "aspect"}), and its vocabulary scan reads identifiers and string literals across all ofsrc/. The firstinfo_source_id=command.info_source_idtripstest_no_module_names_a_domain_concept— the charter's own load-bearing assertion, written precisely against "one optional field, one small settings table, and Replicator has a domain model."That test is not wrong; it is doing its job. The charter is what changed. Amending both in the same commit:
info_sourceinDOMAIN_TOKENS. Add a narrow(module, token)allowlist — the two emit sites and the report dataclass, nothing else.info_source_idappears nowhere insrc/core/config.py(never a setting), nowhere insrc/storage/(never a path component), and never in a comparison,if, or dict-lookup subscript anywhere insrc/. "Verbatim echo" is mechanically checkable; "pass-through" as prose is not, and prose is what erodes.docs/contracts/replicator-boundaries.mdgains the reasoning: one echo, required by the wire contract, interpreted nowhere.Also not adding a blank-
info_source_idguard to mirror thecommand_idone.command_idis refused because a fact is unkeyable without it; a blank domain id is the issuer's problem and refusing it would be interpretation. If co-core setsmin_length, the refusal lives there, which is the right place.Doc surface is wider than the three spots in the body
content-fetch-issuer-contract.mdopens with "There is noinfo_source_id, and no other domain identity" — that reverses outright, ahead of the MUST-2 edit.blob_available→content_fingerprint:command_id.command_id | str \| None/ "None only for non-command emits" → required.content_fingerprint") is strengthened by the re-key — the hole it warned consumers about is now closed at the producer too. Saying so is worth a sentence.content_fingerprint(AGENTS.mdBus Conventions,docs/ARCHITECTURE.md). Both now wrong; ARCHITECTURE's carries the reasoning and gains from the correction.MUST-2 wording — taking the "survives both states" advice literally and phrasing by deploy epoch rather than by "now": the durable record stays a MUST for request options, audit, and the MUST-6 reaper; facts emitted from the step-2 deploy onward carry
info_source_id, so the crash-between-mint-and-record window stops permanently orphaning the mapping for those facts. True before and after CannObserv/watcher#252, and needs no second revision.Failure-mode note for the half-deployed window
With the field required, a pre-0.8 command does not reach the
fetch_failedpath at all — it failsfrom_wireand lands incontent.fetch.dlqvia the anomaly route, silently. That is already the reference doc's "frame that failsfrom_wireentirely" row, so no new taxonomy, but it is the concrete shape of the dead-lettering this issue's second comment warns about: the correlator is destroyed at decode, one step earlier than a rejected command.Flush list
content.fetch,content.blobs, and both.dlqstreams — v1 dead-letters are unreplayable under 0.8.0, so leaving them is leaving a trap for whoever triages next. Plusreplicator:cmd:*if anycommand_idwill be reused across the flush.Not
content.fetch-policy. It is read groupless as a state stream; flushing it leaves the worker with no policy until the next publish. Will confirmFetchPolicyStateis unchanged in 0.8.0 during the pin bump.Proceeding TDD-first, starting with the charter amendment.
gregoryfoster commentedon Aug 8, 2026
Implemented — PR #33, not yet merged
Branch
28-echo-info-source-id, five commits (f455deb..1a0d0a8). Held short ofmaindeliberately: on this VM main is the deployed code, and this cannot deploy without CannObserv/watcher#252.Done when — all three met.
info_source_id, echoed verbatim. Required on the wire, so there is noNonecase on either.blob_available(test_the_domain_key_is_echoed_verbatim_onto_the_success_fact) and tofetch_failed(test_the_domain_key_is_echoed_verbatim_onto_the_failure_fact), asserted against a value the emit site could not have derived from anything else on the command. A command without the field no longer exists — it failsfrom_wireand dead-letters, which is the half-deploy failure mode rather than a case to test.blob_expires_atdocumented in MUST-7.Also landed, beyond the issue's scope.
blob_expires_atis populated (cannobserv#301). Cheap and honest here:store()touches mtime on its content-addressed short-circuit, so the TTL clock genuinely means "since last referenced", and the stamp is read before the store so the published horizon can only fall earlier than the real reap.blob_ttl_secondsis bounded. Making the horizon arithmetic turned an unbounded float into anOverflowErrorraised after the bytes were stored — dead-lettering a command whose blob was fine and leaving it orphaned. A config typo should not be able to manufacture those.--info-source-idand the live target refuses both the placeholder and a blank.The boundaries charter needed amending, and that was the substantive work.
tests/test_boundaries.pyforbids domain vocabulary anywhere insrc/, so the firstinfo_source_id=command.info_source_idfailed the charter's own load-bearing assertion. Correctly — the rule it protects is that Replicator never learns what a domain key means. Rather than delete the token, the exemption is three modules wide and one identifier wide, and a second scan took over the real invariant.That scan is the part worth knowing about. It began as a deny-list of reading positions, and three review rounds each found positions it had not enumerated — six in the first, one in the second, two in the third — while the charter cited it as what made the carve-out safe. It is now inverted: an echo has four legal shapes and everything else fails, including shapes nobody has thought of. Probed against fifteen violations it was never told about —
assert, comprehension filters, set literals,del, sort keys,async for,setdefaultamong them — all fifteen caught, the three legal shapes clean.Answers to the two questions the release comments asked.
blob_availablewithout acommand_id.handler.pyis the only construction site and always sits insidehandle(command);loop.pydead-letters a blank id before the handler runs;seed_fetch.pyissues commands and decodes facts read-only. The finding was right — keep it required, nothing to restore.blob_expires_atpopulated, per above.Verification. 498 unit + 18 live-broker integration tests, coverage 99.77%, ruff clean,
tyat its pre-existing baseline. AGENTS.md and all three contract docs back inside their context budgets.Not closing this issue yet — it closes when the step-2 pair is deployed together, streams flushed between. Order of operations is in
docs/DEPLOYMENT.md.gregoryfoster commentedon Aug 8, 2026
Deployed — step-2 pair shipped together, 2026-08-08 22:38 UTC
Merged as #33 (
f4ea53b), deployed alongside CannObserv/watcher#252 in one window. Both services on co-core 0.8.0.Order executed. Both consumers stopped →
XPENDINGconfirmed 0 → streams snapshotted, then flushed (content.fetch,content.blobs, both.dlqs: 69/69/0/0 entries, plus 27replicator:cmd:*keys) → Replicatoruv sync --frozen, Watcheralembic upgrade head(d5a71c93e0f2→e7c4b2a91f60) +uv sync --frozen→ Replicator started first, Watcher second.content.fetch-policywas deliberately not flushed, and the boot proved why: the replay rebuilt 3 tracked hosts from all 1992 messages in 417 ms. Flushing it would have left every host on the fallback interval until Watcher republished.Verified on the live wire, one real command end to end:
Exit 0, blob on disk, both DLQs empty, no ERROR or WARNING on Replicator's side.
The cross-service half is the part worth recording. Watcher consumed that fact, decoded it under 0.8.0, read
info_source_idoff it, and discarded it:That is MUST-3 behaving exactly as written — it correlated on
command_id, found no match for a synthetic seed id, and refused to fall back tourlor to the domain key now sitting in front of it. The one warning in the window is the contract working.Two notes.
--info-source-idagainst--production, so one command and one fact naming a nonexistent InfoSource remain on the live streams. Watcher discarded the fact; the blob expires 2026-08-15. Left in place rather thanXDEL'd, since removing entries a consumer has already processed is more disruptive than leaving two aged frames to fall off.watcher.servicereportsfailedafter a cleansystemctl stopbecause uvicorn exits 143 on SIGTERM and the unit lacksSuccessExitStatus=143. Cosmetic, pre-existing, and worth a one-line fix in that repo so a real failure is distinguishable from an ordinary stop.Closing: both "Done when" criteria met, contract and reference revised, and the pair is live.
#252 merge: populate info_source_id on content_fetch commands
#29 fix: apply round-3 review findings (CR #26-#38)
#118 docs: curate DEPLOYMENT.md — two misplaced blocks home, two hist…