Sessions Log - noetl/ai-meta GitHub Wiki

Sessions Log

Chronological log of agent sessions that touched the NoETL ecosystem. Each entry links to the durable artefacts (PRs, commits, wiki updates, ai-task issues) so future sessions can pick up where the last one ended.

2026-10-17 (later) β€” #474 closed: single-pass templates in CLI 5.3.1

cli#98 and cli#99 merged; semantic-release cut CLI 5.3.1 / executor 0.13.1 on every channel.

  • render_template no longer re-scans substituted values.
  • The subscribe spool test no longer depends on a 10 ms wall-clock window.

Wikis now describe the template gap as fixed (agent-playbooks Security and Architecture; cli agent-steps and executor-crate-architecture). The follow-up pointer bump closes #474.

Left for later: the PyPI wrapper still bundles CLI 5.3.0, without the template fix; defang_template_markers can go.

2026-10-16/17 β€” local agent runtime: kind: agent in Rust, a credential store, and a live Gmail showcase

Opened #474 (Umbrella).

What shipped:

  • noetl-tools 4.1.0 (#112) and 4.2.0 (#113).
  • CLI 5.1.0 (#95), 5.2.0 (#96) and 5.3.0 (#97), with noetl-executor 0.11 β†’ 0.13.
  • PyPI wrapper 5.2.0 (noetl#710) and 5.3.0 (noetl#711).

Design.

  • AgentSession is turn-by-turn because the CLI runs each agent tool as a sub-playbook, and its runner enters tokio itself.
  • framework: claude bills an API key, and NoETL sees every tool call.
  • framework: claude-code runs headless claude -p on a Claude subscription, with the API-key env stripped so it can't fall back to API billing.
  • No Python anywhere, per the user's standing rule.

Credentials. Alias X resolves from NOETL_CREDENTIAL_X or a mode-600 file. http gains bearer / api_key / google_oauth. noetl credential google runs the installed-app OAuth flow with PKCE and stores a refresh token; a refresh token can never be sent as a bearer value.

Live. Four real Gmail inboxes, read-only, triaged through the published pip install noetl==5.3.0.

Cost. $0.35 β†’ ~2–3Β’ per run. Haiku, thinking: disabled, the Primary tab, compacted tool output and prompt caching. Tokens: 87,715 in / 6,148 out β†’ 8 in + 8,158 cache-write + 2,126 cache-read / 2,667 out.

Defects the work found in itself:

  • ⚠ Haiku spent a summary turn on a thinking block and the run was reported as successful with empty text. Now an error that names the block types (tools 4.2.0).
  • ⚠ render_template re-expanded substituted values in HashMap order. Found by the agent e2e test, stopgapped by defanging model input; fix in cli#98.
  • ⚠ The member-version guard failed cli#95 on an unmoved executor version. That's correct behaviour; bumped to 0.11.0.

Open: cli#98, cli#99 (flaky spool test). Owner: publish the Google OAuth app, or re-authorize weekly.

Wikis: tools: AgentTool, cli: kind: agent, cli: Credentials, agent-playbooks.

2026-10-10 (evening) β€” B3+D2 live on prod, and two worker releases that produced no image

ehdb v0.9.0, server v3.143.0, worker v6.2.4. Closed #457; opened #466.

D2 cleanup, owner-approved and shipped. Delete-after-sealed. The design is the refusal, not the deletion: contiguous_durable_watermarks stops at the first non-durable part, because max-over-durable skips a local-only part in the middle and would authorise deleting a tail object whose records exist nowhere off-box. Both mutants β€” the naive watermark and delete-regardless β€” are caught by that one safety test. The consumer's flag defaults ON, the only one here that does, because omitting cleanup grows a bucket forever while the deletion is safe by construction.

⭐⭐ B3+D2 proven on prod with real data. Tick 60 s (not the shipped 15 s: at prod's measured 314 appends/hour, 15 s carries ~1.3 records/object and writes 5,760/day). A 32-minute sampler caught the whole chain β€” 188 unsealed records out in 3 batches, objects under tail/ counted externally with gcloud, the age seal at ~900 s, then all 6 objects reclaimed (3 per replica Γ— 2), retained 6β†’0, bucket 3β†’0, tail_failed=0.

⚠ The first ~400 s read appends=0. That was the window, not the code: measured independently, prod had no execution traffic at all β€” only runtime_heartbeat writes. I did not trigger an execution to move a metric.

βœ… #457 closed on measurement β€” /data 31% β†’ 9%, local 1.2 G β†’ 11.9 M, substrate 1.2 G β†’ 75.5 M. Its "is growth linear or decelerating" question is moot rather than answered: retention changed the shape deliberately. ⚠ One goal item was genuinely not done β€” 0 of 19 alert policies reference any volume metric β€” so it was carved out as #466 rather than disappearing with the close.

β›”β›” The prod worker roll is blocked by a broken release, and my first fix was wrong. release-worker failed for v6.2.2 and v6.2.3, each ~45 min in, producing no image β€” on commits whose cargo test --locked was green. noetl-tools = "~4.0" floated to 4.0.6, which needs async-nats 0.47 against this crate's 0.38 β‡’ two incompatible Context types in one graph. It surfaced now only because 4.0.6 was published after the last good release (2026-10-02), so the break was waiting for whoever released next.

⚠ Adding --locked did not fix it. cargo chef cook works from recipe.json, a skeleton reconstruction, and writes its own Cargo.lock over the one copied in β€” so the cook stage resolved 4.0.6 with --locked passed and the real lock in place. The durable fix is an exact pin (=4.0.1), which removes the resolver from the decision; verified by building the real image (1,008 crates, 0 errors, one async-nats).

⚠ Also corrected: the pools already run v6.2.1, not v6.0.0. I had read build_info off the writer β€” the only pod with a reachable :9090 β€” and generalised it to the pools. The skew was one patch, not two minors.

C2+C3 remain HELD. Election-before-enforce is absolute.

2026-10-10 (later) β€” B3: the unsealed tail leaves the node, and I caught my own latency defect

ehdb v0.8.0 (#404) + v0.8.1 (#405), server v3.142.0 (#531). Design PR ehdb#394 closed as superseded by the wiki page rather than merged β€” a design doc duplicating a maintained page is a second representation of one thing.

The gap, measured not argued. A2/A3 covered sealed history. A part is local-only until it seals, so the window was the seal interval, which B2 bounds and cannot close. On prod: SEAL_MAX_AGE=900, oldest_unsealed_age=719, unreplicated_oldest_age=0 β€” nothing waiting to upload, ~12 minutes of records on one disk.

replicate_tail writes the pending records as write-once per-batch objects under tail/ (never parts/, or plan_retention could drop them) and cold_load_replicated replays them, deduping by sort key against the manifest's max_sequence β€” the overlap is expected, since a part sealing after its tail objects makes them redundant by design. Per-batch rather than re-uploading the active part, which would be quadratic over a part's life. And no new read path: read_partition_after_limited already reads the active writer, which is the discovery that shaped the whole design.

⭐ Window: 900 s β†’ the tick. Bounded, not zero.

⚠ The RED control carries the file. With replication off, 16 of 21 records come back β€” the loss is real, so every green result is falsifiable. With it on, destroying the local root and cold-loading from the replica alone returns 21 of 21, and 13 of 13 against a real GCS emulator. Four compiling mutants with distinct kills, including one that kills only the read-side tests and one that kills only the overlap test.

⚠⚠ A defect I introduced and caught before enabling anything. My v0.8.0 driver performed the remote put while holding the engine lock β€” and that lock is taken by shadow_append inside emit_events on the live write path. Every append colliding with a tick would have waited out the put: p50 77 ms, p99 234 ms, once per tick, indefinitely. A durability feature becoming a write-path latency regression. The measurement was already on #460 from the A2 work, and ehdb's uploader thread had documented the rule the entire time. v0.8.1 splits prepare (no I/O) β†’ upload (free function) β†’ commit (no I/O), with a source guard asserting the driver cannot regress and a RED control that restores the defect.

⚠⚠⚠ And a correction to my own shipped default. Prod's measured append rate is 314/hour, so a 15 s tick carries ~1.3 records per object and writes 5,760 objects/day β€” the object count is driven by the tick, not the data. 60 s is the better choice: still 15Γ— better than 900 s, at a quarter of the objects. I chose 15 s before measuring the rate.

Stopped at emulator-proven-and-ready; not enabled on prod. Enable needs D1 (the tick) and D2 (tail-object cleanup β€” a data-deletion policy on the event log), both owner calls, now with measured numbers and exact commands on #460. D3 resolved as recommended: a failing tail put fails nothing, the watermark stays put so the next tick re-drives, and the failure gets its own counter because a failing replicator and an idle one both leave tail_batches flat.

C2+C3 remain HELD. Election-before-enforce is absolute.

2026-10-10 β€” the off-box replica was armed, and the acceptance proof found the defect

Landed. server#528 (vendor/ removed, closing #463), ehdb#401 β†’ v0.6.0, ehdb#403 β†’ v0.7.0, server#529 (pins + backfill wiring). Prod: the replica bucket and IAM binding were created and A2 was armed (NOETL_EHDB_REPLICA_GCS_BUCKET, env 73β†’74, image unchanged, 1/1, 0 restarts).

A2 reached a real second domain β€” domains=[local-device:66320, remote-gcs], replica_set_size 1β†’2, survives_node_loss 0β†’1, single_point_of_failure 1β†’0 β€” and new seals replicate: over a 1853s window (the denominator is stated because a window shorter than the ~900s seal period would have proved nothing) the age seal fired at t=934s and the part, the manifest LATEST and a versioned snapshot reached the bucket, counted externally with gcloud rather than from the engine's own view.

⚠⚠ Then the acceptance proof refused to pass. Read back from the remote's own manifest: 40 parts listed, 39 at replica_count 1, one at 2. Uploads are enqueued at exactly one site β€” on seal β€” so arming a replica replicates nothing that already exists, and nothing retries because nothing considers it outstanding. Every durability gauge read healthy throughout, because they are computed from the declared replica set, not from the parts: survives_node_loss is a claim about configuration and was being read as a claim about data. ⚠⚠⚠ The resulting state is worse than an empty remote β€” write_manifest_to_all ships the manifest on every upload, so the bucket holds a manifest naming all 40 parts and the bytes of one. It reads as a complete store and is not a recoverable one, which makes the backfill a correctness prerequisite rather than an optimization, and means the cold-load-from-remote-alone headline is not yet claimable on prod.

Fixed, not redefined. backfill_under_replicated enqueues every under-replicated part onto the existing background uploader. Its own trap was one level down: durable_view stores local_path: None and the durable view is what seeds the in-RAM manifest at open, so after any restart a repair path that only knew fs::read(local_path) would have compiled, run, reported success and copied nothing. Hence UploadSource::Replica. Backfills carry separate counters because a part sealed in an earlier process has no seal→durable interval, and folding it into upload_lag_micros_total would divide the real total by a larger denominator and deflate a mean a capacity decision reads.

The worker bump produced the second fix. v0.3.2 β†’ v0.5.2 had exactly one compile break across two minor versions: the new ShardLag.inflight. Defaulting it to 0 was refused on purpose β€” render_snapshot publishes that field and high lag with zero in-flight is specifically the stalled consumer reading, so a hardcoded zero makes a healthy bus publish an outage signal on every scrape. ClaimCoordinator::inflight() now exists.

⚠ Three of my own mutants did not compile before one did, and a mutant that does not compile is not a caught mutant β€” the first two "RED controls" on the server wiring proved nothing until the third removed the call block in a form that built.

⚠⚠ Rule 0a drift, found on the dashboard itself: Home's Active-umbrellas section was missing #457, #459, #460, #462 and #463 β€” every umbrella opened in the previous two sessions, including the one this session was about. Fixed in the same change set.

C2+C3 (election + fencing) remain HELD. Election-before-enforce is absolute. Framing kept honest: a backfilled sealed part survives node loss; the unsealed tail stays RF=1 and replication is async, so this is a bounded-loss consistent prefix of sealed history, not "no data loss".

2026-10-09 (evening) β€” the archive backlog drains, the floor is wired, and four of my own instruments were wrong

Headline: the retention/archival tier is live and draining on prod β€” 1,472 of 6,251 archivable executions archived, every one also verified_durable and indexed, 0 failures, 4,431 real GCS puts. ⚠⚠ The disk-reclaim number is NOT measured and is not claimed: the prune floor correctly refuses while any archivable execution is unarchived, so enabling prune today reclaims nothing. Reclaim is gated on the drain finishing (~4 h), not on code.

Deployed: server v3.137.2, by digest a3bb8087 (rollback c46a032a recorded first). 1/1, gen==observed=69, revisions match, env 71 = baseline, 0 restarts, PRUNE absent.

The measurement #460 was decided without. Real GCS-put latency, n=300 in-region: p50 77 ms, p90 98 ms, p95 139 ms, p99 234 ms. Roughly 19x a ~4 ms local fsync, against a provisional estimate of "tens of ms" β€” so decision (i), synchronous per-record remote append, is closed harder than the guess closed it.

Two fixes proved themselves on the first pass after the roll:

before after
drain (server#514) archived=0 already=100 β€” stuck at max_per_pass archived=100…1472, 0 failures
archive path measured (#515) histogram at its seed while ~300 objects reached GCS 301 puts on pass one

Four defects found by controls rather than by reasoning

  1. The prune floor would have reclaimed ZERO. compute_prune_floor returned NoFootprint when every archivable execution is archived β€” which is exactly the steady state this drain produces. Every call would have returned Ok. Caught only by a positive control asserting that a fully-archived log does reclaim; without it, every negative test is satisfied by a prune that never prunes. This is also why HotExecution::max_sequence exists β€” added for this case, then never read.
  2. collect_candidates could silently truncate. Capped at 200x100 and returned Ok on a short list. A floor computed from a partial population can sit above an execution never classified, and its records are then deleted. Completeness now travels with the data.
  3. The put metric's "pin" fabricated samples. observe(0.0) on a histogram records a measurement never taken. inc_by(0) on a counter is harmless; this is not. In prod the only sample below 50 ms was that invented zero β€” on the very number the #460 decision turns on. Replaced with a pinned counter.
  4. My own B2 gauge understated its own exposure. It stops at sealing, but replication is asynchronous, so a sealed part still has a window where the only copy is local. L0Engine::unreplicated_snapshot() has computed the whole durability window all along and had zero server callers.

A2 β€” the off-box replica, proved by moving bytes

server#518 / #519. GcsSubstrate implements ehdb's DurableSubstrate and declares Remote{provider:"gcs"}. It lives in the server, not ehdb-l0: the storage core has no HTTP client and a deliberately lean dependency set, and DurableSubstrate is the extension point for exactly this. ⚠ ehdb-core becomes a third pin that must move with ehdb-l0 β€” a different tag puts two EhdbError types in the graph.

⭐ The unit tests establish the domain algebra, which a substrate that declared Remote while silently dropping every upload would also pass. So the proof is a cold-load of the log from GCS alone, confirmed by listing the bucket outside the test: 6 sealed part objects spanning sequences 1-48, the FORMAT_VERSION guard and the manifest chain β€” 14 objects / 26,159 bytes, control bucket 0.

⚠ A correction: two GCS buckets are one failure domain, but they do survive node loss (both are off-node, and is_single_point_of_failure() is !survives_node_loss). The gauge that would catch a two-bucket plan is domains_distinct, not the single-point-of-failure one. Reading redundancy off the wrong gauge is how a correlated replica set reads as safe.

B2 β€” built in ehdb, unreachable from the server

server#520. seal_max_age + seal_aged_parts() existed; the server configured neither and called the sealer from nowhere. ehdb's own doc names the trap: should_seal is consulted only on append, so a configured age with no timer is "inert on exactly the shard it was added for." Both halves now ship together, guarded including the spawn in main.rs. The exposure gauges publish unconditionally β€” including with the trigger off, because the tail is an RF=1 exposure either way. manifest_parts is the deliberate counterweight: smaller parts is the trade B2 makes, and that is the shape that filled the cmdbus PVC.

The honest durability statement

Sealed parts survive node loss. The unsealed tail does not. Replication is asynchronous. Recovery is a bounded-loss consistent prefix; B2 bounds it, B3 (not built) would remove it. Not "no data loss".

Shipped / state

server #514–#521; releases v3.137.2 (DEPLOYED) through v3.140.1 (not deployed). Wiki: noetl-server-wiki β€” the floor's three refusals, the put-instrument lessons, and a new off-box-replication-and-sealing page.

⚠ Owner-gated: A3 needs a replica bucket + IAM grant (exact commands on #460); and PR #355 carries 38 commits stranded on a design branch, including pointer bumps for several prod rolls, now CONFLICTING β€” all four gitlink conflicts measured as "this branch is strictly ahead", STATUS.md needs a human call.

⚠⚠⚠ C2+C3 (election + fencing) remain HELD; election must be live and minting epochs, verified in shadow, before fencing flips to enforce.

2026-10-09 (deploy) β€” v3.135.0 LIVE: the EHDB retention work is deployed, and prod now says it is a single point of failure

Umbrella: #455 Β· #456 Β· #459 Β· #460 Prod: noetl_server_build_info{version="3.135.0"} Β· digest sha256:231e8eeb… Β· rollback sha256:94bf4efb… (v3.135.0 ← v3.131.0)

Closed a four-release gap in one roll β€” v3.132.0 (retention config, #459 P1–P4), v3.133.0 (retention metrics, P6), v3.134.0 (archive retrieval, P5) and v3.135.0 (the #460 A1/B1 instruments) had all been built and sitting in AR while prod ran v3.131.0.

Health vs the pre-roll baseline

1/1 ready, 0 restarts, gen 64/64 β†’ 65/65 with currentRev==updateRev, 67 env vars identical, all three chain gates still armed, divergence 0 before and after. The 2 ERROR lines were my own probe requests β€” tower_http logging the two correct 503s from the archive endpoints; zero in the previous container.

⭐⭐⭐ The number that was green for the wrong reason

noetl_ehdb_replica_set_size                1
noetl_ehdb_survives_node_loss              0
noetl_ehdb_replica_single_point_of_failure 1
noetl_ehdb_replica_domains_distinct        1   (vacuous at RF=1, labelled so)

And on the real pod:

WARN embedded event-log store is a SINGLE POINT OF FAILURE: no replica is in an off-node
failure domain … reality=replica_set_size=1 survives_node_loss=false
domains_distinct=true (vacuous at RF=1) domains=[local-device:66320]

The redundancy did not change. Its visibility did. What read as a green ehdb_l0_replica_domain_violations 0 now reads single_point_of_failure 1 β€” because that 0 was an unevaluated pin (the engine's check is gated on replicas.len() >= 2 and L0Engine::open makes a set of one), and this 1 is a verdict.

⚠⚠ Archiving did NOT start β€” confirmed, not assumed

0 archive/prune/retention env vars on the StatefulSet, and all 14 retention series present and zero β€” archived=0 pruned=0 prune_bytes_reclaimed_total=0. Which is exactly what the unconditional pinning was for: a 0 that means checked and nothing ran rather than absent.

The retrieval endpoints answer 503 with the variable to set, and 400 on a malformed date β€” "not configured" and "nothing archived" stay different facts, verified on prod.

⚠⚠ A flaw in my own instrument, found by reading it

noetl_object_store_put_seconds_count{backend="gcs",outcome="ok"} 1 with sum 0 is my init seed, not traffic. observe(0.0) makes a histogram label set present β€” and for a counter inc_by(0) is harmless, but for a histogram it is a fabricated data point, so a naive mean is skewed toward zero by a sample I injected.

⭐ And the pre-seed is unnecessary for a histogram: build_info{version} already tells a reader whether this build has the metric. The pins are right for counters and gauges and wrong here. Not hot-fixed β€” the seed is subtractable, the first real number will be reported with it subtracted and said so, and the next server change drops the pre-seed.

2026-10-09 (night) β€” the C1 guard, and the honest stop

Umbrella: #455 Β· #460 Landed: noetl/worker#348 (d135e97d) β€” a structural guard, zero src changes.

The guard

build_durable_stack wraps the shared backend when NOETL_EHDB_FENCING is set, and a decorator cannot be skipped by a caller who forgot. ⚠⚠ But it can be bypassed by constructing the store somewhere else β€” invisible to every runtime test, because a second site works perfectly and simply is not fenced. That is the M5 shape one level up.

Four guards, the two substantive ones RED-proven: a second FilesystemSharedBackend::open fails; a new unregistered append* in the durable-write modules fails. It prints its denominator (6 scanned) and asserts its own test-stripping slice is plausible before asserting anything about it.

⚠⚠ Three guards written and deliberately NOT shipped

"Build the stack from plain" and "read the setting after the wrap" are both compiler-guaranteed β€” plain is moved in both match arms, and the wrap is match setting {…}, a data dependency. My mutants failed to compile or were self-defeating, and a mutant that does not compile is not a caught mutant. Shipping those assertions would have been checks that cannot fire for the reason they claim. The finding is recorded in the test file: within build_durable_stack the bypass is impossible by construction.

⚠ And an over-constraint caught by its own control: my first ordering assertion failed a mutant that merely hoisted a read earlier β€” harmless. An over-constrained guard teaches people to delete the test rather than read it.

The ehdb skew: drift, not a correctness risk

FORMAT_VERSION is 1 at both pins; ehdb-service/src is unchanged between v0.3.2 and v0.5.0; ehdb-feed/src differs only in the KEDA scaler.rs; ehdb-l0/src is 4161 insertions against 2 deletions. And the two do not share a volume. ⭐ The real consequence is capability, not correctness β€” the worker cannot use the D8 registry, supersede_key or the state gauges until its pins move, which is why C2 has a pin bump and a worker release in front of it.

The GCS-put number is gated on a deploy, not on traffic

Prod runs v3.131.0; the latest tag is v3.135.0. Four releases are built and sitting in AR undeployed β€” the retention tier (P1–P4), its metrics (P6), archive retrieval (P5), and the A1/B1 instruments. ⭐ One deploy delivers all four. Until then option (i)'s rejection still rests on an estimate, and it stays labelled as one.

β›” The honest stop

P7/P8 is now blocked on user decisions (#460) plus the IAM grant for archival β€” there is no further safe autonomous work. What remains all changes durability or safety semantics: C2 (election + a dependency decision + a pin bump), C3 (⚠⚠⚠ an immediate outage with no election live, so C2 strictly precedes it), B2 (a new feature on a primary-serving store), B3 (a replication protocol), A2/A3 (the injection decision, then RF>1).

Not building any of them speculatively, and not inventing adjacent work to avoid idling.

2026-10-09 (late) β€” A1/B1/C1 measured, and two of my own framings corrected

Umbrella: #455 Β· #460 Landed: server#511 (1753084b) Β· spec 5ff3511 Observability + audit only. Nothing armed, no semantics changed.

βœ… A1 β€” the RF=1 reality now evaluates

Four always-computed gauges, seeded pessimistically (survives_node_loss=0, single_point_of_failure=1) because until something evaluates the replica set, "we do not know" must not read as "safe". ⭐ The alert keys on survives_node_loss, not on "RF < 2" β€” two replicas on one device also die with the node, and an RF<2 alert would have missed prod entirely.

A real bug the tests caught: distinctness first used the derived Eq on FailureDomain, which includes root, so prod's two copies on device 66320 compared as distinct. Now uses ehdb's own label(). ⭐ Which collapses Remote to its provider β€” so two GCS buckets are one domain, and any "spread across two buckets" plan must reckon with that.

⚠⚠ Correction 1 β€” the tier is not an L0 engine

I had said the tail fix was to wire with_seal_max_age into the tier. True that tier_store.rs never calls it; misleading, because the tier is a JSONL append file with segment sealing by rename, and with_seal_max_age is an L0Config knob. The equivalent β€” age-based segment sealing β€” does not exist, and would be a new feature on a primary-serving store. So it stays specced and flagged rather than built, and must default off per tier_seal_max_bytes's own fail-safe-off rule.

βœ… B1 β€” the tail, measured

/data/eventbus  19.5G total, 12.7G used (65%)   ehdb-tier 11.4G
13 sealed eventlog segments, all 256.0–256.8 MiB   ← byte sealing works as configured
ACTIVE: eventlog 62.0 MiB Β· projection 129.0 MiB Β· catalog 7.1 MiB, UNSEALED ~7 HOURS

⭐ catalog.jsonl is the age exposure made concrete: a byte-only trigger bounds the high-rate tiers and leaves the quiet ones arbitrarily exposed β€” the opposite of the intuition that low traffic means low risk.

⚠ And one of my own errors: I read df /data, got the overlay at /, and briefly concluded the tier was on ephemeral storage. /data is not a mount; the PVCs are at /data/{cmdbus,eventbus,eventkv}. Checking the mount table rather than publishing the first reading caught it.

⚠⚠ Correction 2 β€” I checked the wrong binary for the fence

I had said the server links neither election nor fencing. True, and the wrong binary: the writer does link ehdb-reference, and the fence is already a selectable decorator (NOETL_EHDB_FENCING ∈ Off/Shadow/Enforce). What is missing is the election that mints tokens, not the fence that checks them.

⭐⭐ And the writer's own metric HELP text already states it: "0 means single-writer rests on StatefulSet replicas:1 alone, and fencing enforce would be an outage." Prod has the var unset, so the decorator is Off.

⚠⚠⚠ Which sharpens C3: with no election live, enforcing the fence is an immediate outage, not merely reduced availability β€” the writer would fence itself against an epoch nobody mints. C2 strictly precedes C3.

βœ… C1 β€” five durable-state mutation paths, none wrapped

tier_store::{append, append_with_seal, append_batch}, tier_shadow::append, dataplane::append_domain_record, eventlog_backend::append_selected β€” consistent with ehdb_fencing_active 0. Remaining work is a structural guard so a path added later cannot bypass the fence silently, which is the M5 shape. ⭐ A decorator is the right answer to M5 because it cannot be skipped by a caller that forgot.

⚠ Worker pins ehdb at v0.3.2; server is at v0.5.0.

A CI failure of mine, and how it was diagnosed

A P6 test went red in CI while passing locally. The signature named the cause β€” left: 0, right: 4242, and only a concurrent init_…_series() writes 0 there. My first hypothesis (a collision with the negative-age test, which writes 5) was wrong, and the 0 ruled it out. The race would not reproduce naturally: 0 failures in 82 attempts across three control configurations. Forcing the interleaving reproduced it in 47 of 20,000 reads β€” a ~0.24% window, which is why 82 single-read attempts missed it and one CI run caught it. Fixed with a file-scoped mutex; my new test file had the identical latent race and happened to pass.

2026-10-09 (evening) β€” P7/P8 audited: prod runs RF=1 and says it is fine

Umbrella: #455 Β· new tracking issue #460 Landed: noetl/ehdb design/p7-p8-distribution-frontier.md (ece9ac7) Design only β€” nothing built, no prod changes, no durability-semantics changes.

⚠⚠⚠ The finding

Both of prod's "replicas" are on device 66320 (/dev/nvme0n5), and every durability signal reads healthy: replica_domain_violations=0, parts_under_replicated=0, parts_local_only=0.

That 0 was never evaluated. L0Engine::open wraps the substrate in a replica set of exactly one, and the failure-domain check is gated on if replicas.len() >= 2 β€” "one replica makes no spreading claim". The server calls open(), so the check short-circuits and the healthy path pins the counter at 0.

The code names the rung above: "an RF of N over one domain is an RF of 1 wearing a larger number." Prod is one rung below, where the check that would say so is unreachable.

⭐ Same shape again: FailureDomain::Remote exists in the enum and is constructed in tests only; survives_node_loss requires it, so that predicate is false by construction for every deployment, with no production caller either.

⚠⚠ The exposure is the tail, and the reassuring number is about other engines

Verified in code rather than recalled: with_seal_max_age is called at exactly three sites (command_bus.rs:225, event_bus.rs:262, eventlog_backend.rs:1379), and ehdb/tier_store.rs calls it zero times. So the two buses and the eventlog backend all get the 5 s bound, and the tier β€” the store at risk β€” gets TIER_SEAL_MAX_BYTES = 256 MiB and no age bound at all: up to 256 MiB of acked events on one PVC for as long as it takes to accumulate.

The bounds, stated rather than softened

  • ⭐ EHDB is not etcd-with-sharding; it is a WAL with a lease. etcd has consensus and no shards; EHDB has shards and no consensus.
  • FLP β‡’ a failover window can be bounded, never eliminated.
  • CAP β‡’ P8 reduces write availability: a partitioned writer must stop accepting, which is the opposite of today's behaviour. The owner's trade.
  • CALM β‡’ appends are monotonic so the append path stays coordination-free (a real result) β€” but there is no coordination-free linearizable cross-shard read, ever. A theorem, not a gap. The coordination-free offer is a monotonic consistent-prefix read, which is what replay, projection and audit need.
  • Two claims to avoid afterwards: P8 gives mutual exclusion, not consensus, and a second copy is not a quorum.

⚠⚠ The #459 archive is not P7

Terminal+expired executions only, lagging by the retention window, and downstream of sealing β€” so it can never hold what the hot store lost. ⭐ What it does is retire the dependency blocker: DurableSubstrate is a sync trait and the substrate write runs on the uploader's dedicated std::thread, not the append path, so GcsSubstrate can be implemented in the server crate (where a GCS client is already live in prod) and injected β€” ehdb gains nothing. That is the option second-substrate-choice.md parked on and did not consider.

P8's risk is placement, not algorithm

The state machine is already proven. The 2026-09 M5 incident is the proof of where the risk is: fencing that was "ACTIVE, ENFORCING" still served a superseded writer, because the guard sat on a path the publish skips. So the acceptance test is enumerating every durable-state mutation path β€” the bullet skipped last time.

Next

A1 / B1 / C1 are pure measurement and unblocked β€” all three replace an inferred belief with a number, and two address a signal that is green for the wrong reason. B3 (streaming tail replica) and C3 (enforcing the fence) change semantics and are owner-gated, with three decisions listed on #460.

⚠ One claim deliberately left marked as an estimate: the GCS-put latency that option (i) was provisionally rejected on is unmeasured, so B1 owes a real number from the writer pod before the rejection is treated as settled.

2026-10-09 (later) β€” a retention + archival tier, proven against a real GCS emulator

Umbrella: #459 (new) Β· closes #457 Landed: server#508 Β· #509 Β· #510 Β· spec noetl/ehdb design/retention-archival-tier.md Β· noetl-server-wiki 35fe938 Also filed: #458 1440 tests, clippy clean. Flags default-off β€” nothing in prod deletes anything.

The audit came first, and it halved the work

Four things already existed and were not rebuilt: the retention planner (plan_retention β€” drops whole parts below a sort-key floor and never splits a part), its executor (apply_retention), a GCS client already live in prod (ObjectBackend/GcsBackend, ADC via Workload Identity, 21,569 objects written), and the archive bucket itself. So: no new object client, no new auth, no credential handling.

⚠⚠ Four did not exist: no cloud DurableSubstrate (only LocalFs + InMemory, so nothing goes off-box and prod's "second replica" is the same PVC); apply_retention had zero callers in the server β€” built-and-dormant, the #332 pattern, verified with a control grep; no time- or completion-based floor; no per-execution layout, date index or config.

The constraint that shaped the design

partition = shard_for_execution(execution_id, 1) and index_key = execution_id, so a part holds many executions interleaved by append sequence. An execution owns no part, execution_bloom cannot enumerate, and ⚠⚠ dropping an old part would destroy the early events of a still-running execution β€” data loss, since noetl.event is append-only with replay as the source of truth.

Resolution: archive per execution (exact), prune per part (coarse), with a floor as the bridge β€” min(global_sequence) over everything not yet safe to prune.

Proven, on a real engine and a real emulator

BEFORE: parts=9 bytes=10736
pinned floor: keep_from=2, pinned by execution 1002 (2 of 3 safe)
  dropped=0                        ← nothing pruned while a live execution is unarchived
  live events: 20 of 20 survive    ← the data-loss case, proven impossible
open floor: keep_from=101 (all archived)
  dropped=9  parts 9β†’0  bytes 10736β†’0  reclaimed=10736

Against fake-gcs-server through the production client: 120 records round-tripped set-equal; date=… listing keys_seen=12 ids=12 unrecognised=0; and with the hot store pruned, served from archive 6 of 6 and fetched 6 of 6 discovered ids. Verified independently with curl so my own reader was not the only witness.

⭐ The bug my own test caught

The all-archived floor branch used max(min_sequence)+1 β€” the highest first sequence, far below the log tip β€” so a fully-archived log reclaimed nothing while every call returned Ok. Caught only because the end-to-end test asserts on reclaimed bytes rather than on the call succeeding.

⚠⚠ Two scope corrections I made rather than let stand

  • The store this prunes is a SHADOW. Verified with a control grep: its only readers are the shadow writer and the parity verifier, and GET /api/executions/{id} reads Postgres noetl.event, which is append-only and never purged. So pruning it cannot make API history unreadable, and wiring a retrieval fall-through into that endpoint would have been an instrument that cannot see its own target. The archive routes are for EHDB-format history, which is a real need β€” just a narrower one than the brief assumed.
  • The parity verifier already has ReadVerdict::OutOfCoverage, so a prune will not make it report a false divergence. Nothing was needed there.

Design decisions worth keeping

  • ⭐⭐ Two independent flags for archive and prune, both default-off, so a state exists where the archive is provably complete and nothing has been deleted. Prune-with-archive-off is refused.
  • ⭐⭐ One date-index object per execution, not an appended per-day manifest: GCS has no append, so that would be read-modify-write and lose entries. 60 concurrent archives on one date all survive.
  • Order is the guarantee: upload β†’ read-back verify β†’ record durably β†’ then prune.
  • ⚠⚠ The pinned-floor hazard is a metric, not an assumption: one stuck execution blocks all reclamation while every counter reads healthy. The alert pairs two series β€” archiving succeeding while reclaimed bytes stay 0.
  • Three read states, never two: an empty hot result is a miss that falls through, and a broken archive is an error, never a clean 404.

Blocked on one owner action

The server SA has no IAM binding on the archive bucket, so archiving fails closed. The exact gcloud command is on #459. Then: deploy β†’ archive-only β†’ check β†’ prune β†’ measure.

2026-10-09 β€” the fleet is discoverable in prod, and the hook I shipped yesterday could never fire

Umbrella: #455 Β· Umbrella: EHDB north-star registry Landed: server#506 β†’ v3.130.1 Β· server#507 β†’ v3.131.0 Β· gateway#55 β†’ v3.13.0 Β· noetl-server-wiki 30810c6 Β· noetl-gateway-wiki aafbbe4 Filed: #458 Prod: server v3.130.1 (sha256:86283cb5…, rollback sha256:4fc8053c…) Β· gateway v3.13.0 (sha256:8d91c62f…, rollback sha256:02756b57…)

βœ… discover(Gateway) is reached

The gateway identified itself to the server in no way at all before this β€” zero references to any registration endpoint, no component_type, no background task in main.rs. So the registry could describe every component except the one users reach.

GET /api/runtime/topology on prod now returns Server: 1 Β· Gateway: 1 Β· Worker: 5, with the Gateway id equal to the exact new pod (gateway-6c585b4784-tzlmw), so it is the registration and not a stale row.

⭐ The heartbeat interval is read from the server's ttl_secs response rather than configured on the gateway. A TTL duplicated on both sides is a representation that drifts the moment one changes, and an interval derived from a stale copy would expire the lease it exists to renew. A missing ttl_secs is an error, not a default.

⚠⚠ The correction: a correctly-gated hook on a path nothing reaches

v3.130.0's Ehdb registration measured 0 on prod across 18 minutes. The hook sat on the events publish, and should_publish skips system executions β€” which is all of prod's regular traffic:

noetl_event_ingest_publish_skipped_total{reason="system_execution"} 22
noetl_event_ingest_total                                            (absent β€” family pruned)
noetl_command_publish_total{pool="system",route="legacy"}            4

Two structural tests passed the whole time. Both assert the call site is correctly gated; neither asserts anything reaches the gated path. That is #326 β€” existence is not reachability β€” committed in the same change whose message quoted #326 as the reason a different hook was placed correctly. The guard I trusted was measuring the wrong half.

Fixed on the command publish's Landed arm only. Deferred means the on-path budget elapsed and a background task is still retrying, so the writer may be down β€” registering there would claim liveness from a timeout, the same misclassification as tier-load reporting ok: true for an explicit refusal.

Adding the second call site exposed a real defect: EHDB_LAST_SEEN was a single global timestamp, so whichever bus published first throttled the other out for a full floor interval, leaving a live tier unregistered while every counter read healthy. One cap serving two addresses is the same defect as one cap serving two directions (worker#314). Now keyed per address and extracted as tier_seen_due so the decision is assertable instead of buried in a static. RED proven at runtime first, not as a missing symbol.

βœ… P5 β€” catalog registration refuses secret material

Registration is the last point at which the answer can still be "no": the catalog is EHDB-backed and noetl.event is append-only and never purged, so an inline credential cannot be removed later. ⭐ The scan runs before the write β€” the log keeps it either way, so the order is the guarantee (proven by mutation). ⚠⚠ The error carries the YAML path and marker class only, never the value, not even a 12-char prefix: a validator that quotes the secret into its error writes it to the log.

⚠⚠ Measured against 26,081 string values in 494 real playbooks before shipping a hard refusal β€” it produced one finding and the finding was wrong. vertex-ai.yaml embeds Python that reads a service-account credential, so credential-handling code names both keys while holding no material at all; the naive rule would have broken a legitimate registration. Requiring a PEM body took it to 0 findings in 26,081 values, and the false positive is kept as a regression test. ⭐ That fix then made the class unreachable behind the bare-PEM test β€” caught only because the test asserts the class is not PemBlock rather than merely is_some().

⏳ discover(Ehdb) β€” shipped, not yet reached

The fresh pod's noetl_command_publish_total is absent (family pruned β€” absent, not zero), and the prior pod had accumulated only 4 publishes, so the dispatch period is tens of minutes. Volume is not duration: the fix is a window sized to the mechanism's period, not faster polling. Reported as not-reached rather than inferred.

⚠ My first poll conflated absent with zero by summing matching lines; corrected to count series explicitly.

Instruments that were wrong this session

  • A topology parser looking for a flat members reported live members: 0 against a response that actually carried 6 β€” the shape is kinds[]. The data was right.
  • An unauthenticated GHCR probe 404'd on a tag that demonstrably exists; with auth it still 404'd on all four tags including a deliberate fake, so the probe had zero discriminating power. The digest came from the build log instead.
  • A grep --include=*.rs unquoted in zsh returned "no matches found" for a file that exists.
  • A Python str.replace silently matched nothing (a line-continuation that had been collapsed on write) and reported success; the test kept reading the old value.
  • A lock-count assertion read 4 where the answer was 2, because the doc comments mention try_lock. Comments standing in for callers, again.
  • One of my own new tests was decorative: it re-implemented the enable predicate inline instead of calling it, so it would have passed against any change to the real arms. Split out as disabled_by and confirmed by mutation.

Also filed

#458 β€” 26 ops files still name the retired noetl-demo-19700101 (vs 19 naming the live project, as a control), including automation/agents/mcp/firestore.yaml with a hardcoded fallback at line 879. A wrong GCP project returns an empty collection, not an error, and the planner's 404-tolerance disguises it. ⚠ Whether the registered version matches the repo file is deliberately left open β€” drift here runs in both directions (#295).

Still the owner's

The shadow→enforce auth flip — it now gates write endpoints, execution data, and POST /api/runtime/register, so an enforce flip needs a service credential on the gateway or registration starts failing (fail-soft: it keeps serving, and goes missing from discovery). #457 disk headroom · #458 · P7/P8 stay specced.

2026-10-08 β€” the registry became discoverable, and five measurement gaps closed

Umbrella: #455 Β· Umbrella: EHDB north-star registry Landed: ehdb #385, #386, #387 Β· ehdb-wiki 916e947 Filed: ehdb#388, ehdb#389 1,188 tests passing, clippy -D warnings clean.

North star P2–P5

RuntimeKind {Server, Gateway, Ehdb, Worker, Playbook, Execution} with discover(kind, now, ttl) turns D8 from a worker table into a service registry β€” without discovery by kind, "where are the gateways" means inferring kind from the shape of an id, which is a naming convention masquerading as a schema. watch_since gives a resumable cursor whose load-bearing property is that resuming returns nothing when nothing changed (a watch that re-delivers the whole log every poll is a scan, and the two look identical in any test that only polls from 0). An Execution registers ephemerally and vanishes by not being renewed β€” no tombstone, no reaper. SecretRef is provider://path[#version] and cannot render material, because the type never holds any.

⚠⚠ A real bug the tests caught: heartbeat reset the kind to Worker, so a renewed Execution silently stopped being discoverable one heartbeat after registration β€” a registration that disappears while being actively renewed.

⚠ An inert guard in my own new code: disabling MATERIAL_MARKERS entirely left every test green, because each material case was refused by a different rule. Fixed by adding the three cases only it can catch β€” material smuggled inside an otherwise valid reference.

The measurement pass

⭐ Memory is O(parts), not O(records) β€” exponent 0.52, fitting ~16.6 KB fixed + ~1.8 KB per part, so 1 M records at seal_max_records=1024 holds ~1.7 MiB. Part count is the memory signal and merge is the memory control. The control ran first, because a counting allocator that is not installed reports a confident 0 bytes of growth β€” which is also the ideal result.

⚠⚠ No ehdb-l0 test had ever spawned a thread. The engine is a single writer by construction (append_record(&mut self)), and throughput falls 10% from 1 to 8 threads. And a shared AtomicU64 sequencer plus a Mutex is not sufficient: minting the sort key before taking the append lock reorders 547 of 1,600 appends (34.2%) while losing and duplicating exactly nothing β€” ids stay unique and dense, only their order is wrong, which is why it is invisible in production. That is #362's mechanism, the one that rolled back the v0.4.4 chain-store ramp, reproduced in 20 ms. My own first draft asserted the sequencer was safe.

⚠⚠ A standing replication deficit was invisible. The uploader assigns p.replicas = locations β€” the successful writes only β€” so a part holding 1 of 2 copies is is_durable(), on_upload_done fires, and the age-based durability window reports it done. replica_writes cannot fill the gap: cumulative, so it climbs while the deficit stands. New parts_under_replicated gauge, proven with a write-refusing substrate.

⚠⚠ Batching is worth 530x on the drain path (1.353 s vs 2.55 ms for 4,000 records) β€” what #344's never-batching relay cost. And the batch cap that fixed #298's unbounded memory makes a drain above the cap quadratic (exponent 2.06). Both real; the tradeoff had never been measured.

⚠ poll_assign without acking is 2.86x slower than with β€” the expiry scan walks the whole in-flight BTreeMap (keyed by sort key, not deadline) on every poll, so lazy acking is O(nΒ²) in the consumer. Doing less work costs more.

⚠ inflight is not derivable from lag: measured lag 10 with 0 in flight (stalled) and lag 10 with 4 in flight (busy) β€” identical lag, opposite conditions. The accessor had existed all along with no render_* emitting it.

Benches now run in CI

CI ran cargo bench --no-run β€” it compiled every bench and executed none. Running them is most of the value, because each case asserts in its timed region that it got the records it claims; that is what caught ChangeFeed::poll returning 2,000 of a 10,000 backlog. ci/bench_ratios.py gates the denominator (expected=13 parsed=13) and monotonicity, and reports the headline ratios β€” gating those would fail CI the day somebody improves the drain path.

⭐ Cross-machine, first run: batch ordering 530.5x local vs 528.3x on the runner (machine-independent); per-poll cost 32.0x vs 16.4x (factor of two). A "per-poll β‰₯ 25x" threshold would have failed on the runner, not the code.

⚠ Six of my own instruments were wrong before they were right

A part count that matched the parts directory (1 part for 16,000 records, wrong by ~60x and plausible-looking); a println! whose labels did not match its arguments, printing under_replicated=200 on a single healthy replica; a SERIES row count of 10 against a real 31, because only 10 rows are short enough for rustfmt to keep the string on the opening-paren line β€” a line-oriented grep over Rust source counts formatting, not code; a gauge called inert by a test dataset that defines no dedupe_key; a "not measured yet" list that understated work already shipped; and an awk summary printing failed=0 with a FAILED line on screen, because ok. and FAILED. shift the field positions.

⚠ Owner decision surfaced, not actioned

noetl/server pins both ehdb-feed and ehdb-l0 at v0.4.5, and the two must move together (different tags put two ehdb-l0 versions in the graph). ehdb has no semantic-release.yml, so its tag is manual. None of P1–P5 or the new gauges reach the server until a tag is cut and both pins move. P7 (unsealed-tail replication) and P8 (writer election + fencing) remain specced and deliberately not started.

Later the same day β€” ehdb#391 and release automation

ehdb#392 + ehdb#393.

RED first: 2,400 records before, 2,400 after β€” the merge ran and collapsed nothing. Dataset::supersede_key + a compacting merge_once: 4,608 stored records / 65.02 ms β†’ 500 / 7.13 ms, converging to exactly the live set, per-record cost identical across every sealed row.

⚠⚠ Eligibility was narrower than the issue assumed. Only VectorDataset may opt in. RuntimeDataset (D8) is disqualified by watch_since β€” the op-log reader I added in P2 β€” because compacting it would delete the history a watcher resumes from. One history reader disqualifies a dataset.

ehdb is releasable. It had no release automation; every v0.4.x tag was hand-pushed. GH006 and the bot-tag-doesn't-trigger trap are both avoided by construction. ⚠⚠ Gated off behind EHDB_RELEASE_ENABLED and verified by merging: the job skipped, tag still v0.4.5. Next version computes as v0.5.0 β€” cutting it and moving the server's two pins together is an owner decision, and nothing reaches prod until it happens.

⭐ The dry-run guard caught a real defect in its own first two runs: semantic-release reads its branch from env-ci (GITHUB_REF), not git HEAD, so a pull_request event reported refs/pull/N/merge, matched no --branches, analysed nothing β€” and exited 0. Checking out the head ref changed nothing, which located the mechanism.

⚠ #393's first push produced zero workflow runs at all; an empty commit dispatched it. Same symptom as catalog#28's "zero CI runs ever dispatched" β€” several probes went into hunting a YAML defect that did not exist.


2026-10-07 (late) β€” three prod actions completed, and SIX defects in one playbook that all looked like "empty"

All three items done and verified. The headline is not the completions β€” it is that travel/playbooks/catalog/calendar/list carried six separate defects, every one producing a clean COMPLETED, 0 failed steps, and an empty result.

βœ… 1. calendar/list re-registered against the LIVE project β€” v1 β†’ v6

# defect PR
1 kind: agent β€” not one of the 25 ToolKind variants, it is in the reject list #135
2 gcp_project: noetl-demo-19700101 β€” retired, Firestore frozen since 2026-08-09 #135
3 no step named start β€” rejected at parse time (src/playbook/parser.rs:184) #136
4 the render step read fields at the top level; they live under data β†’ 0 of 12 documents survived #137
5 {{ step.result }} resolves to nothing; a child is read by the bare step name #138
6 no return_result β†’ the dispatch was async, the step got {"status":"started"} #139

⚠⚠ #3 fires BEFORE tool-kind validation, so it masked #1. My earlier report that fixing the tool kind made the playbook runnable was wrong, and only executing it showed that.

⚠⚠ #2 is why none of it was noticed. A wrong GCP project returns an empty collection, not a 404, so one symptom covered five causes and never looked like a bug.

⚠ #6 was invisible to a 29-check guard that execs each step's embedded code:. The step bodies were correct; only the declaration was wrong. Three of the new checks are therefore structural over the YAML, which no fixture can replace.

Proof it reads the live project β€” same thread, same registered playbook, only gcp_project differing:

run gcp_project event_count documents_returned
live shastaratech-noetl-prod 12 12
control noetl-demo-19700101 0 0

Firestore holds exactly 12 documents at that path.

βœ… 2. Six Firestore composite indexes deployed

Baseline 0. Now 6, all READY, and set-equality exact against firestore.indexes.json. firebase CLI is absent, so gcloud firestore indexes composite create was used; the account carries roles/owner, so no permission blocker.

βœ… 3. All 53 adiona/* playbooks registered

53/53 by set-equality, all at v1, 0 rows removed. Audited before registering: all 53 parse, 53 distinct paths, only kind: postgres, all have a start step, and no GCP project pins β€” they target external Postgres by keychain alias (adiona_actor Γ—49, adiona_migrator Γ—4), so the Firestore project does not apply to them. Catalog went 1395 β†’ 1453; the +58 reconciles exactly as 53 adiona + the 5 calendar/list versions.

⚠ My own measurements that were wrong first

  • Prod server is v3.123.2, not the v3.122.0 my index claimed.
  • I read registered=53, failed=0 and simultaneously printed "non-success: 53" β€” my filter looked for "success" when the status word is "registered".
  • Two probes returned a confident empty from a mistyped scratchpad path and from cargo missing on the PATH I exported.
  • mk() {…} and $K get pods both died on zsh traps already in the index.

The port-forward carried every prod reading, and the negative control confirmed it: killing the forward killed the probe.

travel#135–#139 Β· ops#324 Β· wikis 84b8cc5 + 537f3d6

2026-10-07 (evening) β€” the travel business catalog on Firestore, and three silent defects on the way there

The business-catalog half of the two-catalog split, built where it belongs: Firestore, reached from playbook steps, never EHDB.

Audit first β€” the connector already existed

connector automation/agents/mcp/firestore (noetl/ops)
registered v11 in the prod catalog, 904 lines, ten tools
transport kind: python + urllib β†’ Firestore REST v1, hand-rolled value encoding so the worker image needs no google-cloud-firestore
auth Workload Identity / ADC β€” the worker's own SA token from the GKE metadata server. No key, no secret, nothing in the keychain.

There is no firestore tool kind and none was needed. The 25 ToolKind variants are Http Postgres Duckdb Ducklake Python Workbook Playbook Playbooks Secrets Iterator Container Script Snowflake Transfer SnowflakeTransfer Gcs Gateway Nats Shell Artifact Noop TaskSequence Rhai Subscription Wasm β€” Gcs is Cloud Storage, not Firestore.

Four connector limits, read off the registered implementation rather than its docstrings, and documented rather than worked around (#451): AND-only where over = < <= > >= array-contains; the query built without allDescendants, so no collection-group queries; limit capped at 500 with no cursor; and delete_doc that does not recurse, so no cascade.

⚠ Three silent defects, and a hypothesis of mine the data refuted

I reasoned my way to a read/write project split-brain β€” the inline read defaulting to shastaratech-noetl-prod while the MCP write path defaulted to noetl-demo-19700101 β€” and went to check instead of publishing it. The Firestore data refuted it: slot_state is live in shastaratech-noetl-prod to 2026-09-30 and frozen in noetl-demo-19700101 since 2026-08-09. Both paths use the live project.

But the check found the real shape. The registered connector says shastaratech-noetl-prod; the git copy said noetl-demo-19700101. Re-registering from git would have done two things, neither of which errors:

  1. Repointed the entire travel business catalog at a project frozen two months ago. Both projects hold a (default) database and a chat_threads collection, so the wrong project returns an empty collection, not a 404 β€” and the planner treats an empty slot_state read as "a brand-new thread… not an error". Every conversation would have looked new.
  2. Dropped half of every turn's writes. batch_write's set_docs_extra / append_events_extra keys β€” which persist_all_atomically depends on β€” are absent from the git copy, and absent keys are ignored silently, not rejected.

Third: travel's playbooks/catalog/calendar/list, registered in prod as v1, declared kind: agent β€” which could never have run β€” and pinned the dead project.

⚠ Zero composite Firestore indexes are deployed

And the travel repo had no index declaration file at all; firestore.rules governs client access and says nothing about indexes. Every query in the new model needs one. firestore.indexes.json now declares the six β€” a declaration, not a deployment.

The model

Borrowed from a relational catalog structurally, not schematically: a recursive CTE becomes a materialised ancestor array queried with one array-contains; localization becomes items/{id}/content/{lang_code}, where the document id is the language; attrs is a map so Firestore auto-indexes attrs.<key> and the EAV rows arrive with the parent.

⚠⚠ Firestore enforces no referential integrity, so nullable and cardinality are advisory and on_delete: cascade must be written as playbook steps β€” an orphaned subcollection is invisible to every query under its deleted parent while still billed and readable by path. Soft delete preferred.

Verification

scripts/business_catalog_items_test.py: 29 checks, runs the playbook's own embedded code, no network, no credentials. It carries a RED control feeding query_collection's documents key to a batch_get_docs reply β€” the exact wrong shape my first draft used β€” because that bug produces an empty join, indistinguishable from "no item has a translation". 4 of 4 injected mutants caught, green baseline after restore. And verified running, not merely present: guards discovered: 6 … guards run: 6 failed: 0.

⚠ Three of my own measurements were wrong first: the ops checkout was 28 commits behind, the travel checkout was on a feature branch without adiona/ at all, and two Firestore probes returned a confident empty because the scratchpad path was wrong and because cargo/curl were not on the PATH I exported.

noetl/travel#135 Β· noetl/ops#324 Β· travel#134 closed Β· wikis a28d21e + 537f3d6 Β· ai-meta@6ea4025.

2026-10-07 (later still) β€” two catalogs: the line drawn, and a bleed found in my own fixtures

Architectural clarification: "catalog" names two different concerns in NoETL, and conflating them is the one mistake worth designing against.

internal catalog (noetl/catalog) business catalog (not that repo)
holds noetl's own objects β€” playbook, credential, mcp, agent, memory, subscription domain data β€” hotels, flights, trips, items, categories, translations
storage EHDB only, no external datastore ever anything β€” external Postgres, a third-party API, an object store
interface /api/catalog/* a playbook step, under that playbook's policy block

The internal catalog holds the playbook that reads the business data. It never holds the business data.

⚠ The bleed, in my own test fixtures

Two files modelled business entities as internal objects, and I wrote both:

  • foreign_key_identity.rs β€” table_row with categories/10, category_types/1, trip_category/1, trips/100: adiona's business rows. Re-fixtured onto real internal references, playbook β†’ mcp and playbook β†’ credential.
  • localization.rs β€” resource_type: "category" with category_name in en/de/ka: adiona's categories and its translations, business data twice over. Re-fixtured onto memory, a real internal type.

A fixture teaches what belongs in a store even though the store cannot tell the difference. ⚠ My first re-audit had a bad denominator and I published it. It scanned one idiom, resource_type: "x", found 15 literals and reported the types as playbook, subscription, memory. There are three idioms; with the helper constructors and ResourceType:: literals the population is 46 and the set is six β€” playbook, subscription, memory, credential, mcp, agent. The narrow scan missed three of six, including both halves of the FK fixture it was meant to be checking. All six are internal types, so the conclusion held and the measurement did not.

"No business entity name appears anywhere" is also false as written. By data vs prose: categories 4/4, category_type 3/3, trip_category 2/2, table_row 1/1, item_content 1/1 β€” all prose, zero as data. The 11 mentions are doc-comments recording where the structural ideas came from, provenance worth keeping. Two more hits were substring artifacts of my own greps: 9 of 14 trips are round_trips, and every flights hit is the playbook name muno/playbooks/flights-details.

Localization is the sharpest illustration

It was added here citing adiona's 24 _translate / _content tables β€” which are business tables. adiona.item_content carries lang_code in external Postgres, read by adiona/playbooks/catalog_list.yaml through kind: postgres + auth: adiona_actor. So no internal object type has a demonstrated localization need; the lang dimension is kept only because it is already built, tested and inert, and is not built on further.

The business catalog already exists β€” documented, not redesigned

From real evidence in noetl/travel: 53 adiona/playbooks/*.yaml reach the external adiona.* schema via kind: postgres; flights/hotels/places/documents go through mcp/duffel, mcp/hotelbeds, mcp/google-places, mcp/firestore. That is exactly what execution-model.md already mandates β€” a data touch inside a playbook step under its policy block, credential by keychain alias. The business catalog is that pattern, not a component to build.

Why adiona was only ever inspiration

Because adiona is a business catalog, and its relational model already lives on the business side. What the internal catalog took was structural β€” one polymorphic identity instead of a table per entity type, the EAV collapse, the self-referencing taxonomy, the typed value union. Its tables were never the target, which is why there is no DDL parser and why acceptance is set-equality over noetl's own objects.

catalog#25. travel#72's registry note corrected too: the model as an object is plausibly internal, the weights are neither (object store, with a catalog holding the reference), and training data drawn from the domain is business data that must not enter EHDB.

2026-10-07 (later) β€” #343: the drain already exists and loses nothing; my mechanism was wrong

Set out to build the graceful-shutdown mirror drain. It is already built, already called, and measurably working β€” and the premise for needing it was my own error.

Measured on the serving pod, 22-hour window

enqueued             11330
drained              11330
shutdown_abandoned       0      <- the loss counter
mirrored on 1st try  11323
recovered on retry       7
mirrored + recovered 11330      == enqueued βœ…
parity match          1332 / divergent 7   = 99.48% agreement

Every enqueued event reached the tier. flush_on_shutdown() (ehdb_eventlog_mirror_queue.rs:449) is called from main.rs:1464 after graceful_shutdown on SIGTERM, and its own comment already states the stake: "Dropping them on SIGTERM would produce a permanent missing_event."

Three errors of mine, corrected

  1. attempt_total{outcome="unavailable"} is not a loss counter. It is AttemptOutcome::Retryable β€” a failed attempt that gets retried. The counter to read it against is mirror_total{outcome="recovered"}, which says all 7 affected batches succeeded. I built a causal story ("one lost batch per roll") on a retry counter.
  2. I attributed unavailable=2 to rolls that predate the pod. The pod started 19:23:33Z; a counter cannot record anything before that.
  3. ⚠ The discriminator added for this very issue already answered the sub-question β€” send_error_total{kind="timeout"} 1, every other kind 0 β€” and was sitting unread while I hypothesised. It exists because a prior session could not tell a timeout from a refused connection.

What the divergences look like

divergent = 7 equals recovered = 7. A batch that fails and recovers on retry arrives late, so a comparator sampling in that window finds the tier short and records a kind="count" divergence β€” a timing artifact of the retry path, the same "two parity oracles disagree by construction" family as #325. ⚠ Not claimed as proven identity: counters cannot establish that, and proving it needs execution ids the metrics do not carry.

Disposition: no code change

There is no drain to add and no loss to stop. Inventing a fix here would mean changing working code on a story the data refutes. The residual β€” whether the comparator should tolerate an in-flight retry window β€” re-tunes a paging alert and is owner-gated, like #325.

2026-10-07 β€” sync sweep: docs, wikis, issues and the board against the measured state

Measure-first throughout; every claim below was read from the live system or the repo, not from notes. Two of my own probes were wrong and are recorded as such.

Prod, verified from the pod

v3.123.2, digest 49eb47fc, 1/1, restarts 0, env 67. Chain gates armed and working β€” all three present in the process env, chain_populate{extended}=229, and bad states (diverged / fork / multiple_roots / stale_log) at 0. #410 applied and closed.

⚠ Eventlog divergence is 7, kind=count only, with mirror_attempt{unavailable} still pinned at 2 β€” the two env-only rolls of 2026-10-06 and nothing since. The pair is the reading: flat unavailable with climbing count means a bounded deficit being re-counted, because the comparator counts comparisons rather than distinct diverged executions. #343 updated.

Drift corrected

⚠ A Rule 0a stale version assertion killed: the Ecosystem-map noetl/server cell read "v3.123.1 released · v3.123.0 in prod, digest 091ff47f". Prod is v3.123.2 / 49eb47fc. The cell now says so and tells the reader to take the version from the pod digest, never from the table.

⚠ The board had drifted from the tracker, and the issue's own state wins: 8 closed issues showed In progress (#251, #297, #339, #341, #342, #378, #402, #415), and #241 was OPEN while the board showed Done β€” the dangerous direction, a ticked box over work that is not finished. All nine flipped.

⚠ ai-meta has 104 open issues, not 100 β€” --limit 100 is a cap, not a page size, a trap already recorded for gh issue list and gh project item-list.

Two probes of mine that were wrong

  1. The CI-gate table β€” wrong twice, and the published figure was also wrong. The first pass reported catalog as || true because the grep matched || true inside comments that say "NO || true" (comments counting as code). I corrected that and published "3 of 8 gate; 5 cannot fail" β€” which was also wrong, for a different reason: I measured the local submodule checkouts, which are 7–23 commits behind origin/main, so I read the pre-#374 state. The truth: all 8 Rust repos gate clippy with -D warnings, all pin 1.99.0, only cli carries 2 named allows. #374 and #378 both closed correctly; there is no clippy-gate debt. ⚠ This happened inside the sweep whose purpose was killing stale assertions, in the same edit that told readers to "read the version from the pod digest, never from this table". Measure a repo claim against origin/<default>, never a local checkout. ⚠ And a third defect in the same three-line probe: head -1 of the clippy lines picks cargo clippy --version, so catalog would have misread as ungated even on a fresh tree.
  2. gh issue create --json is not supported and returned empty, which I nearly read as a failure. Verified by listing before retrying β€” an empty return is not evidence either way.

Issues reconciled

Two untracked findings from the catalog object-type enumeration are now filed:

  • #446 β€” subscription is a first-class catalog type in the server with no noetl.resource row for noetl.catalog.kind's foreign key to reference. Deliberately does not diagnose whether the FK is enforced on prod; related evidence is that 'Playbook' (875 rows) has no row either.
  • #447 β€” a resource type is not a tool kind: two namespaces, 5(+1) vs 25, with agent/mcp valid in one and rejected in the other. Conflating them is what the catalog's first ingest allowlist did in spirit.

Updated with re-measured state: #343 (above), #422 (reader DONE via server#495; gauge still absent β€” 0 series in a 66,688-byte scrape, so a real absence rather than an empty scrape; replay still absent), #406 (both upstream waits still stand; closing it would lose the reason the exposure is accepted).

Catalog docs

catalog#23 β€” README rewritten to what is built (5 crates, the full API surface, the four-dataset invariant, the three reverse indexes, adiona as inspiration only, and a table of what was consciously dropped), a CURRENT SCOPE banner at the head of the spec because earlier sections still read as though adiona were the target, and the staged wiki page marked STAGED NOT PUBLISHED with the exact three publish steps.

⚠ The noetl/catalog wiki repo still does not exist β€” catalog.wiki.git returns Repository not found because GitHub creates it only after a first page is saved through the web UI. One manual step, nobody can do it from a clone, and nothing claims otherwise.

2026-10-07 β€” catalog: scope corrected to noetl's own objects, API-only, no SQL

catalog#19–#22. 142 tests, fmt + clippy -D warnings clean, AC3 still exactly 4 Dataset impls.

Scope corrected (spec Β§2.9)

Generic catalog for noetl's internal object types. No SQL surface. API-only. adiona is cited as inspiration for the relational/EAV patterns only β€” its schema is not mapped in, there is no DDL parser, the acceptance proof is not an adiona slice. Dropped: the catalog-schema DDL crate, the adiona round-trip test, further localization work.

The object types, discovered from noetl.resource

Five seeded rows, and noetl.catalog.kind has a FK to it. Their executable/catalog flags map onto ResourceType's two fields exactly β€” which is why those fields existed. playbookΒ·t/t Β· credentialΒ·f/t Β· mcpΒ·f/t Β· agentΒ·t/t Β· memoryΒ·f/t, plus subscription as the sixth, de facto.

⚠ Two findings for noetl/server: subscription is not seeded in noetl.resource while the server treats kind: Subscription as first-class (nothing for the FK to reference); and a resource type is not a tool kind β€” ToolKind has 25 variants, and agent/mcp are rejected tool kinds while being perfectly good resource types.

The allowlist is gone

matches!(type_name, "playbook" | "subscription") rejected four of noetl's own six types. Every kind now registers, with reporting (new_types) replacing the gate. SkipReason::UnknownKind and its metric label were deleted β€” unreachable, and an unreachable skip reason is an inert series reading 0 forever. ⚠ That exposed a gap in my own guard: it checked each label was produced by some arm, hand-built β€” existence-vs-reachability inside a reachability test.

First-class relations, and three failures from one change

RelationKind::References(ForeignKey{…}). Making the kind carry data broke the edge identity three ways, because both edge-key sites used format!("{:?}", kind): one reference re-asserted with corrected metadata became 2 edges; the reverse answer returned "References(ForeignKey { nullable: false, … })" as its kind label; and a retraction matched nothing, leaving the edge unremovable. Fixed with discriminant().

/api/catalog/* β€” the only interface

15 endpoints, four query shapes (by type / by attribute / by relation / by reverse-relation). Every read returns the full set with its count, never a page β€” RED-proven at the HTTP boundary by crippling each fold and watching a 200 OK carry a wrong answer: by attribute: 1 of 7, callers: 2 of 5. The acceptance proof runs over HTTP with set equality, including a seventh unknown type accepted-and-reported.

Auth mirrors the server: 503 when unconfigured (no permissive default), 403 otherwise, constant-time. ⚠ The unsafe_code = "forbid" lint forced a better design β€” the token lives in ApiState, because the std::env version could only be tested by mutating a process global. ⚠ Writes serialize behind a Mutex: the honest shape of EHDB's single-writer assumption.

AC10 narrowed, not ticked: its "no consumer on a serving path" half is closed, but the literal wording is about noetl/server's surface and execute path.

Two of my own mistakes

CI caught a race I introduced β€” the metrics delta test read scanned=+6 for a run that scanned 3, from a concurrent test sharing the process-global registry. The hazard was documented in a comment I wrote, in another file. RED: 2 of 8 runs failed without the lock, 0 of 8 with it; a ~25% rate is why one local run passed. And a test of mine was wrong while the code was right: I expected kind: "Invokes" to be refused, but normalising it to canonical invokes is correct per #429.

2026-10-06 (night) β€” catalog: relations on real data, observability, AC audit, then a clean stop

catalog#15-#18 merged on green. 120 tests, fmt + clippy -D warnings clean, AC3 still exactly 4 Dataset impls.

Relations, finally against a corpus that has them (#15)

The relation path had never run against real data: the adiona 53 are leaf playbooks, so relations=0 there is correct and proves nothing. Over muno/* it is correct, matching ground truth derived by reading the documents β€” 7 reference occurrences folding to 5 distinct edges (flights-details names duffel twice, profile names firestore twice, and an edge asserted twice is one edge).

⚠ Correction to my own figure: "36 registered muno/* playbooks" was the prod catalog registration count. travel@origin/main carries 12 documents with muno/ paths, 4 of them with child references.

What it surfaced: nothing answered "what calls X" β€” mcp/firestore has 2 callers and finding them meant scanning every path. Third reverse index (\u{1}to/<path> in c2), still no fifth dataset.

⚠⚠ Three mutations, and the first two did not fire. I predicted the hazard as "folding by edge_key() collapses all callers". Wrong twice: edge_key()'s reverse arm already keys on from_path, and relations_to never calls it. Only mutating relations_to's own closure fired β€” 1 of 2 callers, and 1 of 40 on a shared dependency. A mutation that passes is not reassurance; it means the mutated thing was not holding the property up.

Observability (#16)

11 series, six non-test recorder call sites verified by grep. ⚠⚠ With the unconditional pins removed the whole scrape is 155 bytes containing only build_info β€” all three counter families pruned by Registry::gather, the shape in which the prod gateway served a 200 with zero bytes. ⭐ build_info surviving is precisely why it is the right discriminator: that scrape says "the binary has the code and nothing is pinned or firing", a different diagnosis from empty.

reclaimed is the one that matters β€” catalog#11 found the merge driver doubling disk and nothing would have shown it. ⚠ The counters are per-process, so a standalone catalog metrics is all-zero by construction; the subcommand says so rather than letting a zero be misread. No new knob, so no deployment-spec row owed.

The ten acceptance criteria, audited in code (#17)

9 cited, 1 deliberately open, 0 broken. Each names a test function and the audit asserts it exists in the file it claims β€” proven by mutation (a renamed test + a missing file β‡’ 2 broken citations). ⚠ AC8 was covered but untraceable β€” zero mentions of AC8 anywhere while the behaviour and its test existed. AC1 and AC9 had no test at all, only prose. ⚠ AC1's naive form false-flags a "SELECT 1;" inside a YAML fixture β€” SQL in a document the catalog catalogues, not SQL it runs.

πŸ”΄ Then a clean stop, because what remains is owner-gated

  • AC10 needs a server consumer β€” nothing in noetl/server links catalog-store, so the crate has no presence on any serving path.
  • The noetl/catalog wiki repo does not exist: has_wiki=true but catalog.wiki.git returns Repository not found, because GitHub creates it only after a first page via the web UI. Content is staged in-repo (#18) and ready to push. Needs a human with repo access.
  • Spec Β§13's four open questions (noetl.registry merge, DDL ownership, Observed write volume, own PVC vs shared) are all decisions.

Recorded in STATUS as the explicit stopping point rather than re-emitting a report.

2026-10-06 (late) β€” catalog: both reverse indexes, without a fifth dataset

catalog#13 + #14. 109 tests, fmt + clippy -D warnings clean, AC3 still exactly 4 Dataset impls β€” the constraint the whole design had to respect.

uses_credential.<alias> was half a feature: the forward index is keyed by path, so "every resource using alias X" β€” the query that matters when rotating a keychain alias β€” meant reading every path. And catalog list printed "a full path listing needs an index this store does not yet keep".

A fifth dataset would answer both and break AC3. Instead each index lives inside an existing dataset as a second row kind, distinguished by its index key β€” which works because ehdb-l0 matches the index key by exact string equality (engine.rs:1489):

dataset forward key synthetic key answers
c3 path \u{1}attr/<name> every resource using credential X
c1 path \u{1}type/<kind> every resource of type X

The sentinels are control characters: a path reading attr/uses_tool.postgres would otherwise silently answer a reverse query. A forward write intruding on either space is refused, since the path comes from a document.

⚠⚠ The RED was never a zero

index with .last() planted
c3 uses_credential.adiona_actor: 1 path(s), expected 49
c3 after an unset 0 path(s), expected 48
c1 playbook: 1 path(s), expected 53

Partial, and returned successfully. The middle row was unpredicted and is worst: once any resource unsets the attribute the latest op under the shared key is a tombstone, so .last() answers "nobody uses this credential" while 48 do β€” green-lighting a rotation that breaks all 48. Every assertion is set equality; a count of 49 can still be the wrong 49.

Verified against git-derived ground truth

git truth adiona_actor:   49   catalog: 49   SET EQUALITY, byte for byte
git truth adiona_migrator: 4   catalog:  4   SET EQUALITY, byte for byte
union 53, intersection 0 Β· list --type playbook -> 53 Β· --type Playbook -> 53

Three bugs of my own, each caught by a test

  1. Archiving a version is not archiving the path. Got the live/dead rule right, then read the type name from versions() β€” which folds latest-op-per-version and emits only Registered, so once every version was archived it returned an empty vec, the fallback returned early, and no tombstone was written at all: the path stayed listed forever. The same under-reporting the function exists to prevent, by the opposite route.
  2. partition() derived from r.path would send a reader to the wrong shard, returning nothing rather than erroring. Both datasets now derive it from index_key().
  3. Synthetic rows in a forward fold are excluded from the input, not the output β€” a reverse row's name() equals a real attribute name and would shadow it.

Also closed a self-contradiction: ingest left resource_type("playbook") as None while resources_of_type("playbook") returned all 53, so the CLI printed "type playbook is not declared in this store" above a successful listing of 53 of them. Ingest now declares on first use.

Scope, measured: a kind: sweep across travel/noetl/ops finds 183 Playbook plus Deployment/Service/ConfigMap/ScaledObject/Secret/PVC/VMRule β€” Kubernetes manifests, not NoETL internal resources, so out of scope. No third NoETL resource type exists in the tree to add.

Next, by evidence: the adiona 53 are leaf playbooks, so the relation path has never run against real data β€” the 36 muno/* playbooks do call children and are the corpus that would test it. And Ticked/Ingested are returned but recorded nowhere.

2026-10-06 (evening) β€” PROD chain-store gates: disarmed 18:36Z, RE-ARMED 19:23Z on evidence

Net state = the original baseline. I disarmed the three gates at 18:36:35Z under an explicit approval, then the decision rule became "disarm only if it is blocking progress." Applying that test returned NO, so the prescribed state was armed and I restored it at 19:23:33Z. NOETL_CHAIN_{POPULATE,ADVANCE,SOURCE} armed, env 67, generation 56, revision 7c97b8df, image unchanged throughout at 49eb47fc (v3.123.2). ops#323 restores the as-built; ops#322 was the disarm.

The test, leg by leg

leg finding
the unsafe mechanism #362 CLOSED β€” an event committing into the middle of an ORDER BY event_id read, because a snowflake id is minted before insert. Fixed in ehdb v0.4.5 (order_by_links, mutation 5/5) + server#477
durable verdict 2026-09-30 re-ramp GREEN over 8h 28m, ~8 divergence cycles: coverage 278, divergence 0, refusals 1 (dangling_prev)
measured today armed pod 3h55m: extended=50, in_sync=1, opened=1, diverged=0, fork=0, multiple_roots=0, stale_log=0, one_root=1088; divergence 0/16 series; refusals 0/10; ERROR 0
#360 open, but false comparator divergence β€” not corruption
catalog no interaction β€” owns c1–c4 under its own root; every "chain" mention is a doc comment
deploys v3.123.2 rolled fine earlier with the gates armed

Working and failing safe, so disarming mid-flight would remove a measured-good capability for no stated benefit.

What the disarm window nevertheless established

  • ⚠ Absent is the correct off, not 0. The off-states are different shapes: the booleans are an allow-list, while SOURCE's _ => None is a fail-safe for unrecognised values, so 0 reads off only by landing in those branches rather than as a declared default.
  • ⚠⚠ Never (dis)arm by applying the ops manifest. It pins image v3.118.0 against a prod running v3.123.2, and a server-side apply conflicts on .image with field manager kubectl-set β€” it would roll the image backward into the known-wrong event_id ordering its own header warns about. Targeted JSON patch instead: test ops asserting each name at its index, remove in descending order, dry run diffed whole-object (exactly 6 changed leaf paths, every survivor byte-identical).
  • ⚠ Either direction needs a positive control, because the patch touches spec.template: the pod rolls and every counter resets, so chain_populate_total = 0 is also an idle new pod. The cleanest discriminator found β€” an armed boot logs event-chain DDL skipped as the chain path initialises, where a disarmed pod logged zero chain lines at all.
  • ⚠ Honest limit: chain_populate has not moved since the re-arm, and the reason is measured rather than hopeful β€” chain_head_hydrate is 0 across all outcomes, so no execution has advanced on the new pod and there is nothing to populate (worker logs: last execution 19:20:04Z, previous 16:20:35Z). The proof this config populates is the pre-disarm pod's extended=50 over 3h55m. No synthetic execution was driven to force the counter.
  • Collateral from two rolls: exactly 2 heartbeat-failure WARNs per worker, one per roll, each self-healed on the next beat.

2026-10-06 β€” adiona/frontend triage complete; catalog gains reclaim and a driver

adiona/frontend triage β€” 8 of 8 open issues, zero frontend code touched. One backend fix (travel#133, issue #59: a login-time profile read was writing to Firestore on every login β€” not data loss, since merging {} changes no field, but a write on the login critical path, and set with merge creates the document, so after one login "never saved a profile" became indistinguishable from "saved an empty one"). One deployment gap surfaced and deliberately not actioned (#5: 53 adiona/v1/* playbooks exist on noetl/travel@origin/main, 0 registered on the catalog the SPA reads, while 36 muno/* are β€” registering them is a prod catalog change and the owner's call). One blocked on product input (#55: the three use-case .docx yield 22 scenarios, D6 is absent β€” numbering jumps D5β†’D7 β€” and the three documents use three divergent canonical-slot vocabularies). Four frontend-only, one split.

⚠ A false zero in my own verification: the coverage check filtered comments by user.login == "Kadyapam" (the git user name) and reported mine=0 on all eight while I held eight comment URLs. The API login is lowercase. Trusting it would have double-posted every comment; the contradiction between the filter and the URLs is what caught it.

catalog#11 β€” the merge driver was doubling disk. The previous phase proved the part count fell 25 β†’ 4. It did, and bytes rose 42% in the same tick: 94,784 β†’ 189,568 (2.00x), 54 part files and substrate objects for 4 live parts, because caller-owned reclaim_orphans was never called. The test measured the number the fix was about. Now reclaimed=48, files 49 β†’ 8, 1.02x. ⚠ The test's byte assertion was wrong on first draft while the code was right β€” a merge consolidates parts holding the same records, so bytes are flat by construction; the property that discriminates is that bytes must not grow. All five caller-owned EHDB lifecycle calls are now accounted for: three driven by tick(), with apply_retention (drops parts below a floor β€” a resource whose only Registered op fell below it would vanish from latest()) and flush_and_wait_uploads (L0Engine::drop joins the uploader, whose mpsc loop drains before disconnect) deliberately not, reasons recorded on the doc comment.

catalog#12 β€” nothing could run any of it. Three library crates, zero binaries, and tick() / register_from_source had 0 non-test call sites: 89 tests passed and the catalog had never seen a real document. The new catalog binary reads a git ref, not the working tree, because a population sweep on this very repo read adiona yaml: 0 while origin/main carries 53 (the checkout was 41 commits behind on a side branch). First real run: 53/53 registered, relations=0, attributes=0. relations=0 is correct β€” leaf playbooks calling no child, after a first regex wrongly counted each document's own metadata.path as a child reference. attributes=0 was a gap: all 53 carry a tool kind (53x postgres) and an auth alias (49x adiona_actor, 4x adiona_migrator), and a playbook yielded nothing because find_attributes only read metadata.labels. Now uses_tool.* + uses_credential.* β†’ attributes=106 = 53 x 2, the four migrator playbooks cross-checked against ground truth derived independently from git. ⚠⚠ Alias only, never a value: a mapping under auth: is an inline credential and is skipped β€” mutation-tested, the relaxed check fails with the leak in its own output (uses_credential.Mapping {"password": String("hunter2")}). 99 tests, fmt + clippy clean.

Three stale server PRs remain OPEN, not closed (#480/#473/#481): my staleness measurements were wrong. #473's premise is live (two byte-exact == "true" sites plus a third spelling accepting "enabled", no shared truthy()), #481's benchmark is the only record of its conclusion, and #480's examples/chain_cert_bench.rs is unique to it. Instead the evidence was made durable on ai-meta#367 β€” including a correctness hazard recorded nowhere else: rolling up by commit batch is boundary-dependent, so two replicas that batched differently produce different digests for the same chain.

2026-10-06 β€” noetl/catalog created with a proven gate, and the catalog kind filter was returning a PARTIAL set

Prod, both actions the owner approved. v3.123.0 rolled to the server STS by digest (091ff47f), confirmed by the binary's own report β€” noetl_server_build_info{version="3.123.0"} 1 β€” not by a note. Revision 7fd4c9bb4f β†’ 5f6cffb8ff, gen 52 β†’ 53, restarts=0, env 67 before and after, sibling deploy/noetl-server-rust untouched at 0/0. Rollback digest 919bc70c recorded and unused. The dead-letter reader was then checked with controls: /api/health β†’ 200 (the probe works), the reader β†’ 403, an unknown route β†’ 404 β€” so the 403 is the gate, not a catch-all. ⚠ The first probe used busybox nc and returned empty for everything including the control; wget works and curl/python3 are absent from the image. ⚠ Before rolling I checked the chain path at hunk level, because prod has the gates armed: 5 of 23 changed files match chain|parity|populate|advance|cert and every hunk in them is inside mod tests, with an identical NOETL_CHAIN_* read-set at both tags. An earlier note of mine claimed the range touched nothing chain-related β€” that was asserted at file granularity and unverified when written.

#410 applied and scraping cleanly: up{job="noetl-server"}=1, 8 targets with 0 DOWN, 680 samples, and all six previously-blind families reporting at 0. No pager noise, and none was possible β€” the conditions were read rather than assumed: the comparator policy needs a rate ratio > 0.5 over 1800 s, which is 0 at zero rates. Server-side apply hit a .spec.selector conflict with manager kubectl-client-side-apply, so plain client-side apply was used instead of --force-conflicts.

⚠ One precondition correction. NOETL_CHAIN_CERT is unset, so the flag the instruction named is genuinely OFF. But NOETL_CHAIN_POPULATE/SOURCE/ADVANCE are set β€” re-armed on prod 2026-10-01 at 15:06:18Z, which my records never captured. That is the #360 configuration, currently failing safe (~7 "refusing to populate, falling through to Postgres" in 13 h, 0 divergence hits, positive controls passing). Rolling neither armed nor disarmed it; disarming is an owner call.

New repo: noetl/catalog β€” the generalized, event-sourced catalog of NoETL internal resources, playbooks first. Public + Apache-2.0 + main, matching all 10 production Rust submodules; branch protection identical to ehdb/server/worker (required check rust, strict, no required review, so it is self-mergeable on green). Wired in as a submodule through the pointer gate (#428), which also filled two pre-existing linkedProjects gaps β€” repos/ehdb and repos/signal-mesh are both Cargo workspaces rust-analyzer had never been indexing.

CI from commit one, 4 of 4 RED-proven β€” fmt, clippy -D warnings, tests, and a source-hygiene guard, each failed on a planted defect and the tree restored clean. No step is suffixed || true; ai-meta ran its whole life with no CI and five repos carry exactly that suppression (#374). ⚠ The hygiene guard fired correctly on its first run, before anything was committed, by refusing to compare against an empty git ls-files β€” a scan that finds nothing reports no violations and reads exactly like a healthy workspace.

Design spec (#427, design/catalog-model.md) β€” storage is EHDB and only EHDB: a write is an event appended to an EHDB dataset, a read is a fold. Three audit findings reshaped it: (1) ⭐ an event-sourced catalog already exists in shadow β€” catalog_log.rs writes CatalogRecord to StoreTier::Catalog, the event types catalog.registered/.archived/.restored already exist, and CatalogRelation is a pure fold nothing calls on a read path; 1,601 prod records. So this finishes a named predecessor, and ⚠ the RFCs those files cite do not exist on disk. (2) d7_catalog already exists in ehdb-l0 and D1–D10 are all taken, so the spec takes neither D7 nor D11 and defines its own c1..c4 namespace. (3) ⚠⚠ there is no Projection/Fold trait in EHDB β€” 23 public traits, none a fold; the pattern folds read_index_after(key,0).last(), i.e. latest-wins, which is correct for the entity log and silently wrong for attributes and relations where many entries share an index key. A naive copy keeps one and looks fine, so AC4 plants two attributes as a positive control.

The generalization in one line: adiona needs a new table per entity type (item_attributes, trip_attributes, trip_category); one polymorphic identity (resource_type, path, version) replaces that, so adding a resource type is one appended row β€” enforced by AC3, which asserts the Dataset impl count does not change. P1 landed (catalog#2): the four model types, 30 tests, version widened to u32 because SMALLSERIAL caps at 32,767 and catalog load re-versions every entry on each run.

⚠⚠⚠ And the audit was wrong about prod, in the direction that matters. /api/catalog/list filtered kind case-sensitively while prod stores both spellings β€” 'Playbook' 875 rows, 'playbook' 650, 'mcp' 2. So the filter did not return zero; it returned a partial set: "playbook" gave 650 of 1,525 playbooks (43%) and "Playbook" gave 875 (57%). Neither was empty, which is why it survived β€” an empty result gets reported, a 43%-complete one gets believed. Cause is two halves: registration has lowercased since f0301e86 (2026-06-09) but never backfilled, and the predicate bound the caller's string verbatim. Both are fixed, because each alone is still wrong β€” predicate only gives an empty result for a caller sending Playbook, parameter only still misses the 875 legacy rows. server#496 β†’ v3.123.1, #429. RED-proven 3 of 3 (column half, parameter half, both). ⚠ Nothing tested it: resource_type appeared in 0 of 27 files under tests/, and the existing shape guard asserted page.contains("kind = $1") β€” it had pinned the buggy predicate as correct while passing Some("Playbook") to it. ⚠⚠ My own new guard's first draft counted its own assertion messages, because include_str! reads the whole file and the messages name the needles verbatim β€” it reported 4 uses where the code has 2 and failed with the fix correctly in place. It now slices at the last #[cfg(test)] via rfind, not the first via split, because this file has an earlier #[cfg(test)] with real code after it. ⚠ Also corrected: mcp has 2 prod entries, so the "never registered" conclusion was about the repo's fixtures, not about prod.

P2 landed the same session (catalog#4, 47 tests): the four Dataset impls in a c1..c4 namespace and their folds, on ehdb-l0 + ehdb-core at tag v0.4.5. ⚠⚠ The fold was the whole risk and it is RED-proven three ways β€” EHDB has no Projection/Fold trait, and the in-tree read_index_after(key,0).last() idiom is correct only when the index key uniquely identifies the folded thing, which here it never does. Planting each naive variant made the matching test fail: the attribute fold returned 1 of 4, the relation fold 1 of 3, the entity fold [1000000] instead of four versions. The naive version returns a plausible-looking record and does not error, so AC4's assertion is on the count. seal_max_age is set and CatalogStore::tick drives seal_aged_parts, because the flag alone is inert on exactly the shard it protects and the catalog β€” almost always idle β€” is the worst case for that. AC3 gained a storage half asserting exactly four Dataset impls: the model crate's AC3 still asserts zero there, which stays true but would now pass vacuously. Exact rather than a floor, because the failure it guards against is always an addition.

βœ… v3.123.2 rolled to prod, and both fixes verified against the live endpoint. build_info{version="3.123.2"}, digest 49eb47fc, restarts=0, env 67 unchanged with the chain trio untouched. The range was 2 files, both catalog, 0 touching the chain path. #429: /api/catalog/list returns 1525 for every casing (was 650 / 875), and Mcp returns 2 (was 0) β€” 1525 + 2 = 1527, so the arithmetic closes. #432: a list-form bogus kind is rejected naming step 't1', the tool item's own name, which only the new sequence arm supplies; the old walker would have accepted it. Four controls: a positive (right pod), the mapping-form case naming step 's', a discrimination control (missing metadata gives a different message), and a negative (killing the port-forward made the probe return HTTP 000). No catalog rows were written β€” every probe was rejected.

⚠ A +33% latency reading that dissolved. A post-roll window read emit 143.4 ms against a 108.1 ms "baseline", but that baseline was a 30-min window and the adjacent 17-min pre-roll window has zero traffic, so no like-for-like comparator existed; an equal-length window earlier reads 177.0 ms, worse than post-roll. With 0 of the 2 changed files on the event-emit path, it is variance on sparse bursty traffic. ⚠ The honest limit: prod traffic here is too sparse to support a tight latency claim in either direction.

⚠⚠ And a real defect in my own store, found by reading EHDB's contract rather than by a failing test (catalog#9). next_seq was initialised to 1 in open(), so a reopened store re-emitted op_seq 1, 2, 3 below the ops already there β€” violating "ascending sort_key within a partition", which the sparse index, MinMax pruning and merge all rely on, without erroring. The consequence is a WRONG ANSWER: the fold takes the last op per key, so a post-reopen write with a lower key is ordered before the write it supersedes β€” the test read "dev" where "prod" had been written afterwards. A catalog is the worst shape for it: written rarely, restarted often, so after the first deploy almost every write is a post-reopen write. ⚠ Of the three tests, the one asking "did my writes survive" passed β€” two attributes with different names both survive, because the defect is wrong ORDERING, not loss. Fixed with the idiom EHDB provides (append_writer_assigned re-keys to global_sequence + 1, recovered from the manifest on open), and the hand-rolled counter deleted rather than patched.

⭐ And then subscription landed as a real second resource type (catalog#7, 83 tests), which turns the generalization claim from structural into measured. AC3 previously proved "adding a type adds no Dataset impl" with a synthetic widget; it now proves it with a live type that shares nothing structurally with a playbook β€” no workflow: at all, a spec: instead, its reference at spec.dispatch.playbook (9 of 9 fixtures carry one), and a credential alias at spec.auth (6 of 9). The playbooks they dispatch sit in the same fixture directory, so the cross-type graph is closed and checkable. Zero datasets added, RED-proven by planting a c5_mcp_entity β€” the exact regression AC3 exists to catch and the one that looks most reasonable in isolation. ⭐ RelationKind::Requires finally has a real use: spec.auth names a credential, a type the catalog deliberately does not hold, and the edge is recorded anyway because a dangling dependency is worth knowing about.

⚠ Two API shapes would each have passed a naive test. FoundReference hardcoded Invokes and "playbook", which would have made the credential dependency unreadable while still producing a plausible edge count of 2; and the attribute scalar typing puts bool before integer before string, because a string fallback placed first captures everything and makes every attribute Text β€” which still round-trips.

P3 landed, and surveying the YAML for it found a live server defect. catalog#5 adds catalog-extract, which reads the playbook references registration throws away: a playbook names a child by path inside a step's tool: block and register discards it, so a playbook can be registered referencing a child that does not exist and nothing says so until the step runs.

⚠⚠ Surveying tool: to write that walker found #432: the server's own collect_tool_kinds read tool: as a mapping only, so validate_tool_kinds ran, returned successfully, and validated an empty set for the sequence form. Measured across the 166 e2e fixtures carrying a tool: key β€” 20 sequence-only, 109 mapping-only, 37 using both in one file β€” the walker missed 219 of 1,178 tool kinds across 59 files; hand-verified on test_vars_block.yaml, five list-form blocks each carrying kind: python, extracted zero. Fixed in server#497 β†’ v3.123.2, RED-proven 2 of 2, 0 genuine misses after. ⚠ The probe's raw after-figure is 3, all three my own heuristic counting kind: Playbook inside an HTTP prompt string and two Python code strings β€” text in string literals, not tool kinds. ⚠ Not deployed: prod runs v3.123.0.

⚠⚠ And the oracle was wrong before the code was. P3's ground-truth heuristic counted any kind: naming a playbook followed by a path:, which matches every document's own opening kind: Playbook / metadata.path β€” so every fixture appeared to reference itself, and the truth said 2 where the extractor correctly said 1. That is the more dangerous direction of disagreement: the tempting move is to "fix" the code until it agrees with the oracle. ⚠ The negative-control fixture also needed a second half, because "zero references" is exactly what a reader that cannot parse the list form returns β€” so the test asserts zero references and that the five list-form tools were visited.

⚠ Three shell traps cost time and each produced a confident wrong reading: \s is unsupported in BSD grep -E (a glob matched 1 file instead of 166), zsh does not word-split $FILES into argv (the whole list became one argument, so a probe reported files examined: 1), and cargo's absence from PATH made a clippy run print 0 warnings that was really exit 127.

⚠ cargo fmt --all remains unusable on noetl/server β€” 138 files are unformatted on main and test.yml excludes a fmt gate deliberately (these crates were formatted with rustfmt 1.9.0). One unrelated collapse rustfmt 1.99.0 introduced was reverted so the diff is only the fix.

2026-10-05 (wind-down) β€” the audit now says which tree it measured, and the queue is empty

Last item of the window, and it came from distrusting my own verification.

⚠⚠ drift-audit.sh reads the working tree, and said nothing about it

Re-running the four checks I had fixed today, two came back DRIFT:

schema-copies   DRIFT  schema_ddl.sql differs between noetl/noetl and repos/server
pinned-sets     DRIFT  5 pinned set(s) are unverifiable        (I had registered 3)

Both looked like regressions I had caused. Neither was:

  • repos/noetl's working tree was behind its pointer, so its DDL lacked the table I had ported;
  • ai-meta itself was on a feature branch, and although I ran the script from origin/main it loads playbooks/lib/pinned_sets.py from $ROOT β€” so the branch's copy ran.

Verified clean at the recorded pointers instead: the two DDL bodies are byte-identical under the audit's own comparison, and origin/main's pinned_sets.py carries all three registrations.

Measured cause: 22 of 33 submodule trees in the primary checkout differ from the pointer ai-meta records. So the audit has been describing a tree that diverges in 22 places, silently, for as long as it has existed. #425 makes it the first thing printed and repeats it in the summary β€” WARN rather than DRIFT, because a dirty dev checkout is normal and counting it would make the finding total useless; unmissable by position instead, since being skimmed past is the failure mode.

⚠ Two bugs in that preflight, both found by testing it

  • git rev-parse HEAD in an uninitialised submodule directory returns ai-meta's HEAD, because the empty directory is still inside the parent repo. The first version reported 31 of 31 trees differing β€” every one a phantom whose tree sha was ai-meta's own.
  • With nothing readable, "every tree matches" is true and useless. A separate arm now says NO submodule tree is readable (31 of 31), which also explains the 0 first-party .rs of 0 the checks below print. Same vacuous pass a linked worktree produced earlier today.

Four arms proven: nothing-readable β†’ WARN; feature branch + 22 of 33 β†’ WARN; synthetic all-match β†’ OK; synthetic one-moved β†’ WARN with correct shas. ⚠ That last control did not fire on its first attempt β€” the submodule clone had not fetched the commit, so git checkout silently did nothing. A mutation that was not applied reads exactly like a check that did not fire.

🟒 Winding down to monitoring

Measured before saying it: 0 currently-failing workflows across all 16 default branches by latest-completed-run-per-workflow; v3.123.0 released 7 of 7 jobs with amd64 + arm64 + manifest; env-docs OK, inert-tests OK over 566 first-party files, schema-copies identical at the pointers, pinned-sets registered on main; wiki sweep green over 15 wikis.

⚠ One caveat on that zero: the per-workflow sweep lists dead Dependabot update-ids as failing for ever, because each update gets its own workflow name and its last run stays that failure. noetl/noetl's four are all from before dependabot.yml was removed.

Everything still open is owner-gated β€” #410 (pages on first scrape), rolling v3.123.0, #422's gauge (invisible until #410) and its replay design, #406 (accepted wait; 3 is the expected RustSec reading), #234 (do not sweep), #235, #380.

2026-10-05 (evening) β€” the reader, and a cargo fmt that turned a security guard red

Built the safe half of #422. noetl.event_dead_letter is no longer write-only: server#495, released v3.123.0 with amd64 + arm64 images β€” so the Dockerfile fix from earlier holds.

GET /api/internal/events/dead-letter?execution_id=&include_resolved=&limit=1..1000
β†’ { outstanding, returned, limit, rows }

Both constraints respected rather than worked around

Payload scrubbing β€” sidestepped, not solved. The query does not read the payload column. A parked row carries the raw event payload, and an event payload can carry resolved credential values; wiring a reader through scrub::scrub_in_place and trusting its coverage is a bet, whereas not selecting the field is not. Enforced by a guard asserting the column constant and the struct, since adding the field to only one would still change what reaches a client.

The cost is deliberate and written into the code: enough to triage, not enough to replay. That keeps the scrub decision inside the replay work, where it belongs.

Auth is the writer's, unchanged β€” RequireInternalApiToken plus the group's auth_gate::gate layer β€” because the endpoint enumerates execution ids. Compare #303, where an enumerating endpoint answered 200 with decrypted data and no Authorization header.

outstanding is separate from rows because a list truncated by limit tells you what you can see, not how much there is, and the failure this table exists for is a backlog nobody noticed. It is also the first query ever to use idx_event_dead_letter_outstanding.

⚠⚠⚠ A cargo fmt run turned a security guard red

tests/auth_gate_wiring.rs searches main.rs for the literal .merge(<name>.layer(. rustfmt splits those merges across lines, so after one cargo fmt the guard reported 12 privileged routers merged without the auth gate β€” credential_routes, sealed_credential_routes, cross_region_routes and nine more.

Reverted. main.rs now carries an 11-line diff that is only the route, with a comment above it saying not to reformat the file and why. The guard did exactly its job. ⚠ This is the second time rustfmt has blinded a text-scanning guard in that crate, which is an argument about where such guards should live, not just a note.

Fmt accounting, because "I ran fmt" is otherwise unfalsifiable: 139 sites on origin/main β†’ 138; main.rs unchanged at 7, event_dead_letter.rs 1 β†’ 0.

Still open on #422

A /metrics gauge β€” outstanding is in the response, so you can ask, but nothing publishes it, so nothing can alert on it; and it would be invisible until #410's apply lands anyway. And replay or discard, which is where the payload question returns and which needs a design rather than an endpoint.

2026-10-05 (later) β€” the dead-letter table exists in prod, and nothing can read it

Follow-through on the three merged noetl/noetl PRs, then the downstream question for #709.

Closed

#400 β€” the publish guard is on main (e18e50d2), verified there rather than on the branch: verify_release.py present, publish.needs = [check, verify-release], and its --self-test printed controls: 10 firing as specified: 10 wrong: 0 on the PR. ai-meta pointer bumped to e18e50d2 (#421).

#708 and #709 noted on their tracking threads β€” #383 for the dependabot config whose only directory left the repo in March, #201 for the DDL copies agreeing again.

⭐ Does noetl.event_dead_letter need anything downstream?

Measured rather than assumed, and the answer has two halves.

It already exists in prod's database, created 2026-10-01T15:06Z. That claim needed care, because ensure_table swallows every error and returns Ok(()) β€” "Never fails startup" β€” and the code explicitly anticipates the server role lacking CREATE. So a Ready pod proves nothing. The evidence is the absence of its "dead-letter DDL skipped" warning in the startup window, with a filter control showing the search works there:

textPayload:"dead-letter"  15:06:00..15:08:30Z   -> 0 hits
textPayload:"chain"        same window (control)  -> hits

⚠ And a correction that fell out of it: prod's server is v3.122.0 (digest 919bc70c), not the v3.112.7 my notes and this dashboard carried for weeks. ensure_table first shipped in v3.115.0, so prod has it β€” but I had been reasoning from a version three weeks stale.

The table is write-only. The only SELECT against it reads back the rows it just inserted, to decide whether the caller may ack β€” durability confirmation, not a consumer. No GET, no CLI (doctor has 0 references), no ops query, no backlog gauge. The worker counters count parking attempts by a process and reset on restart: a rate with no total.

That matters because parking acks the poison, removing it from the durable log β€” so the row becomes the only remaining copy. Harmless today (NOETL_MATERIALIZER_DEAD_LETTER is off everywhere, nothing is parked); the moment it is armed we would be deleting events into a table nobody can read.

#422 tracks it, with the two design constraints that make it more than a SELECT: parked payloads are raw event payloads and must go through the response-boundary scrub, and a reader enumerating execution ids needs at least the writer's internal-token gate. Sequencing recorded as build the reader before arming the flag, not after.

2026-10-05 β€” the server image had been unbuildable for two days, and nothing said so

Picked up #415 (three unguarded pinned label sets). The guards were the easy part; what releasing them uncovered was not.

The guards, and what the reverse direction found

Three guards in noetl/server (#493), each a total match so a new variant fails the build, each asserting both directions β€” every label pinned, and nothing pinned that no caller can produce.

The reverse assertion found a live bug on its first run. EMBEDDED_READ_OUTCOMES pins four values; ReadVerdict::label() produces three. The fourth, engine_unavailable, existed in the const and in a JSON response body β€” and the handler returned early without touching the metric. So that series was registered, pinned at 0, and could never increment, telling anyone who read it that the embedded engine was always available.

A pinned series nothing can increment is worse than an absent one: absence at least prompts the question. NO-GUARD went 3 β†’ 0 (#418).

⚠⚠ Defect 1: a closing keyword in a submodule commit breaks the release

My commit body carried Closes noetl/ai-meta#415. @semantic-release/github resolved it against noetl/server, failed, and did so after creating tag v3.122.2 and before the step that dispatches release.yml. That version was tagged, had a GitHub Release, and no image β€” and nothing reported it.

⭐ The sharp part: GitHub itself resolved the same line correctly and closed ai-meta#415 that same minute. Two consumers, one keyword, opposite outcomes.

⚠ It arms only on a commit type that releases β€” 1 failure in 60 runs, and the only other commit of that shape was a ci: one that released nothing and went green.

⚠⚠⚠ Defect 2: the server image had been unbuildable since 2026-10-03

Dispatching release.yml by hand to recover v3.122.2 is what surfaced it:

#14 rustup target add wasm32-unknown-unknown  -> rust-std for wasm32 (under 1.91.1)
#18 cargo build --target wasm32-...           -> syncing channel updates for '1.99.0-...'
                                                 error[E0463]: can't find crate for `core`

rustup target add ran before COPY . ., so the target was installed for the base image's 1.91.1 β€” then rust-toolchain.toml (1.99.0, which I added on 10-03 in the clippy sweep) took over, and 1.99.0 has no wasm32 std.

Invisible for two days because every commit between was ci:/chore:, which release nothing β€” so release.yml never ran. The first fix: commit was the one that exercised it, and the image build failed on a change touching three test files. The same shape as an inert gate: broken, and the only thing that would report it does not run on the commit types we had been landing.

Fixed in server#494, verified locally with podman both ways β€” old β†’ exit 101 E0463; new β†’ exit 0 with the 1,533,325-byte .wasm present. v3.122.3 then built amd64 + arm64 + manifest + github-release; AR carries latest,v3.122.3.

⚠ Four self-inflicted measurement failures, recorded because they are the reusable part

  • A mutation battery where no mutant was applied reported 5-of-5 FIRES. The helper never forwarded its arguments; the non-zero exits came from E0308 after a sed changed an array's length but not its contents. Two later mutants had the same shape in reverse. A mutant that fails to compile proves nothing.
  • A red baseline from a usage error. cargo test --lib a b c takes one positional filter, so three names is a usage error β€” which reads as a failing baseline and makes every mutant look caught.
  • $MINE did not word-split in zsh, so a loop meant to revert everything except my three files reverted those too; the git stash/pop that followed popped a pre-existing stash from another session and conflicted. Recovered with that stash intact; work re-applied.
  • cargo fmt --all reformatted 39 files. main already carries 139 fmt diff sites and CI does not gate fmt. Reverted; the PR quotes 139 before and after.

2026-10-04 (late night) β€” the audit was 94% noise, and two prod flags had no row

Continued autonomously through what the gates surface. Three findings, all about instruments rather than code.

⚠⚠ inert-tests was 94% of the audit's output at 0% signal

findings in that section      : 151
inside repos/server/vendor/   : 150
ours                          :   1   <- also a false positive

The one first-party hit was certified_fold.rs:755:produce_again β€” text inside a synthetic Rust source string literal, embedded by a real #[test] whose entire purpose is to demonstrate that naive source scanning gets fooled. The check was fooled by the test about being fooled.

Four defects, fixed in #413: no vendor exclusion (server has 11,990 .rs files, 192 first-party); "assert" in L[i:i+40] asked about a 40-line window so a helper reports as asserting when the asserts belong to the next function; the attribute regex missed #[crate::test]; and string-literal lines were treated as code. A fifth came from my own control refuting my own claim β€” the assert test matched a bare substring, so a fixture named orphan_with_no_**assert**s_of_its_own satisfied it on its own name.

After: 566 first-party files examined, 0 findings, with the battery showing the genuine orphan still reports in both old and new. That row is the control that matters β€” widening a check to admit legitimate cases is the easiest way to silently delete it.

⚠ And the denominator earned itself immediately: the first run of the fixed check printed 0 first-party .rs of 0 for all six repos and then OK, because I ran it from a linked git worktree where submodules are uninitialised. It examined nothing and said clean.

Audit output overall: 160 DRIFT lines β†’ 9.

⚠⚠ Two prod flags were armed with no row on their deployment-spec page

19 env vars (7 server, 12 worker) were read by the binaries and documented nowhere β€” Rule 2a. Now #414, all three components OK.

variable live value where
NOETL_EXECUTION_FAIL_ON_STEP_ERROR true all four worker workloads
NOETL_EHDB_MIRROR_REPAIR_SWEEP true server STS
NOETL_EHDB_TIER_SEAL_MAX_BYTES 268435456 cmdbus-writer

The first changes what a run does, not what it reports: with it on, a playbook relying on an erroring step to continue begins failing. Default off, armed everywhere, undocumented. The second is an armed background writer whose every metric comes from the unscraped server β€” so its counters are unknown, not zero, and the row says so.

Reading defaults from source rather than guessing surfaced that the 0-value semantics differ between neighbours: MIRROR_REPAIR_INTERVAL_SECS discards 0, CHAIN_INVARIANT_SAMPLE_SECS treats 0 as an off switch, TIER_SEAL_MAX_BYTES treats 0 as never-seal. Reading one off a manifest and assuming the others is how a knob gets set wrong.

Pinned label sets: 5 unverifiable β†’ 3, deliberately still visible

All five pass a variable or method call to their recorder, so registering any would report SHORT 0-of-N. Two have real guards and are now exclusions naming them. The other three β€” PROJECTION_SERVE_REFUSALS, EMBEDDED_READ_OUTCOMES, EMBEDDED_SHADOW_OUTCOMES β€” have no guard anywhere, so they stay reported. Excluding them would turn a visible gap into an invisible one, which is the defect the file exists to catch. #415, #416.

2026-10-04 (night) β€” the serving server has been scraped by nothing for 25 days

Autonomous pass over what the gates actually surface. The headline is not CI: it is that the pod serving production has had no metrics collected since 2026-09-09, and the check that says so has been printing it.

#410 β€” a selector pointing at a workload with no pods

drift-audit.sh scrape reports "pod noetl-server-rust-embedded-0 is selected by NO applied PodMonitoring". podmonitoring-noetl.yaml selected app: noetl-server-rust β€” the Deployment, 0/0 since the serving workload became StatefulSet/noetl-server-rust-embedded.

Proven rather than inferred, with a positive control:

-l app=noetl-server-rust   ->  0 pods
-l component=server        ->  noetl-server-rust-embedded-0, and only it

noetl_worker_build_info   17 series / 7405 points   <- control: scraping works
noetl_server_build_info    0 series /    0 points   <- the subject

*_build_info is pinned unconditionally at 1, so its absence is what separates a scrape gap from a metric that never fired. That discriminator is the reason this is a 25-day outage and not an "is the metric implemented?" question.

Six of the ten metric families the 19 prod alertPolicies reference have zero data over 6h β€” every one server-emitted: crossstore parity / divergence / control, eventlog mirror queue / pending, command publish failed. Those are the signals built for #320, #325 and #360. They cannot fire.

Fixed in ops#321 with component: server, which both the StatefulSet's and the dormant Deployment's templates carry β€” a role label, not an app name, so a variant swap needs no edit. The prod apply is held: resuming a dark scrape feeds conditions that have had nothing to evaluate, so it can page on the first scrape, and picking the hour is the owner's call.

⚠⚠ The whole-object diff caught the fix nearly shipping a regression

Per apply-safety.md the diff was taken over the whole file, not the field being changed. The workers object came back stale in the losing direction β€” live already had worker-pool Exists while the manifest still carried the enumerated app In [...] list:

worker-pool Exists   11 pods   (live)
app In [4 names]      9 pods   (manifest)
dropped               noetl-worker-system-pool-shard1-*  x2

Two of the four names selected nothing at all. Applying the file to fix the server would have removed both shard1 pods from monitoring β€” #238 for the third time, and the 2026-09-08 incident shape exactly: verify one field, apply the whole object, break something adjacent. Aligned before merge.

⚠ Three of my own measurements were false before they were right

  • "0 of 19 alert policies affected." The filter matched metric.type="prometheus.googleapis.com/…" and all 19 policies use PromQL conditions, so it matched nothing and printed a clean result. Caught by printing the denominator: 0 distinct metric.type references across 19 policies is not a finding, it is a broken scan.
  • "Three repos have a red main." I counted bad runs anywhere in a 6-run window instead of the latest run per workflow; docs, doctor and travel were all superseded by later green runs. Then the corrected version flagged gateway, because a tag-triggered run reports the tag as its branch β€” the latest main run was a stale August failure while v3.12.3 built fine.
  • A pytest RED proof that ran no tests. The interpreter had no pytest and the exit code came from the surrounding pipeline, so it printed 0 β€” a pass.

Also landed

  • #400: the publish guard, RED-proved against live PyPI β€” noetl#707, green, awaiting review.
  • noetl.event_dead_letter ported into the DDL the deploy applies, with a contract test and three controls β€” noetl#709, green, awaiting review.
  • The dependabot config whose only directory left the repo in March β€” failing weekly for seven months with dependency_file_not_found β€” noetl#708, green, awaiting review.
  • #360 acceptance box 6 un-ticked: it claimed a criterion met on a ~10-minute window against a mechanism that fires hourly. The measurement is kept; the tick is not.
  • #234 annotated do-not-sweep: the travel Maps secret exists in noetl-demo-19700101 and is NOT_FOUND in shastaratech-noetl-prod, so repointing breaks the deploy. A grep for a project ID cannot tell a comment from a credential lookup.
  • STATUS.md added β€” there was no present-tense answer to "what is blocked on me right now".

2026-10-04 (last) β€” the publish guard, and the check a string comparison cannot do

#400: noetl#707, CI green on 14385af6 (3 of 3: ci, pypi, forbid-client-term), REVIEW_REQUIRED β€” batched for the owner, since noetl/noetl enforces review on admins. #400 stays open until it merges.

The case the obvious fix misses

Two mistakes produce a green release that ships nothing, and only one involves a mismatch:

  • pypi-v5.1.2 cut while packaging/pypi/ still says 5.1.1 β†’ builds 5.1.1. Tag and artifact disagree; a string comparison catches it.
  • pypi-v5.1.1 cut again β†’ builds 5.1.1, every file skipped, zero bytes reach PyPI, job green. Tag and artifact agree perfectly. No comparison of the two can see it.

So the guard queries PyPI. The only thing distinguishing releasing 5.1.1 from re-releasing it is whether PyPI already holds those files β€” which is state, not a string.

verify_release.py gates publish and the disabled publish-oidc: one version in dist/ with floors (β‰₯1 wheel, β‰₯1 sdist β€” an empty dist/ is what a broken download-artifact leaves), pyproject.toml == Cargo.toml == built filenames, tag == that version, and not every file already published. A partial overlap is allowed, so a retry after a half-finished upload completes β€” that is what --skip-existing is legitimately for.

RED against live PyPI, not a stub

noetl 5.1.1 is genuinely published with 4 files, so the silent-skip case is reproducible for real β€” 342 versions known:

RED   pypi-v5.1.1 re-tag            exit 1  "every one of the 4 file(s) for 5.1.1 is already on PyPI"
RED   tag 5.1.2, tree+dist 5.1.1    exit 1  "the tag says 5.1.2 but the build produced 5.1.1"
GREEN genuine new 5.1.2             exit 0
GREEN PR mode, no tag               exit 0
GREEN project with zero releases    exit 0  <- --require-new is not just always-fail

⚠ Confirming each RED fired for the right reason earned itself immediately: my first run of the tag-ahead case exited 1 because a shell variable was unset and --dist /dist did not exist. A control firing for an unrelated cause reads exactly like a control working.

Plus --self-test: 10 controls, 7 must fail and 3 must pass, run on every PR and tag. In CI: controls: 10 firing as specified: 10 wrong: 0.

⚠⚠ Two defects in my own guard, both caught by measuring rather than reasoning

The upload-count regex. It anchored on ^Uploading. Against the actual 5.1.1 publish log it matched 1 of 4 β€” twine writes a progress bar, and three of the four upload lines begin with an ESC[?25h cursor-show sequence. Had twine emitted that before the first line too, it would have read 0 and failed a good release. Unanchored with ANSI stripped now, verified to read 4. I only looked because I had the real log from this morning's publish; reasoning about the format would have shipped the bug.

The success line claimed more than it checked. The first CI run printed, on a pull request, RELEASE OK β€” the artifact matches the tag and has something new to upload. There was no tag and no PyPI lookup had happened. That is #400's own failure mode one level up: a confident statement nothing established. It now enumerates what ran and states what did not.

Monitoring from here

Open: #400 (awaiting the owner's merge) and #406 (the accepted upstream wait). Closed across the program: #361, #374, #375, #378, #385, #390, #395, #398, #402.

2026-10-04 (wind-down) β€” the deferrals are accepted waits, so #402 closes

Owner direction: rsa/Marvin is explicitly not a blocker, and the same for the two quick-xml DoS advisories. Waiting for upstream is the accepted disposition, so they do not hold a release or an issue open.

#402 closed. Its goal was the patchable half of the oracle gap, and that is closed on the published artifact. The three remaining advisories moved to #406 β€” a tracked watch rather than a line in a closed issue, because an accepted wait still has to be visible when the upstream release lands.

Two things that split decided this shape rather than simply closing #402 with a note:

  • issue-tracking.md treats work blocked on external action as something that belongs in an open issue. The wait qualifies; the blocker does not.
  • The two quick-xml advisories are not actually waiting on upstream. The patch exists at >= 0.41.0; what is missing is our decision about which crypto backend ships in the wheel, because object_store 0.14 pulls aws-lc-rs in beside ring and leaves rustls with no default CryptoProvider. Filing that as "waiting for upstream" would have been wrong, so #406 states the two causes separately.

Final counts on the shipped lock

5.1.0 shipped lock   5 vulnerabilities    <- positive control
5.1.1 shipped lock   3                    <- all three accepted waits
informational        10 -> 9
denominator          1270 advisories parsed (0 unparsed), 574 packages
Dependabot, same lock: 290 alerts, all fixed, 0 open

⚠⚠ A note describing a trap instantiated it

The previous entry recorded that slicing Home's table at the next ## heading swallows Recently closed, and said to bound it at ### Recently closed instead. Writing that sentence put the literal string in backticks, mid-line, above the real heading β€” so the next str.index("### Recently closed") matched my own note and truncated the slice from 59,491 bytes to 1,485. The table then read as containing no rows at all, and the #402 row I was looking for "did not exist".

The reconciliation numbers were unaffected (they were measured before the note was added), but the measuring script was broken by its own documentation. Fixed by anchoring on a line-start regex rather than a substring.

Two general forms worth keeping:

  • A substring search for a heading is not a heading search. Anchor on line start, or parse the structure.
  • Prose about a pattern is itself an instance of that pattern as far as any text search is concerned. Documenting a grep trap in the file the grep runs over will reproduce it.

State at wind-down

Open from this arc: #400 (the publish guard β€” --skip-existing plus no tag/version cross-check; real work, not started) and #406 (the accepted wait). Everything else from the CI/debt program is closed: #361, #374, #375, #378, #385, #390, #395, #398, #402.

2026-10-04 (later) β€” a 3 is only a result if the scanner was shown able to find 5

Three issues closed and one narrowed, all by driving each to a published, measured end state rather than leaving a decision open.

#402 β€” 5 β†’ 3 on the artifact, not the repo

noetl 5.1.1 is on PyPI (noetl#706). h2 0.4.15β†’0.4.19 (RUSTSEC-2026-0258) and rustls 0.23.42β†’0.23.45 (RUSTSEC-2026-0285) clear; event-listener 5.4.2 and rustls-webpki 0.103.15 came with the re-lock. ai-meta pointer bumped in #404.

The verification that mattered was not the bump but the control. Reporting "3 vulnerabilities remain" from a scanner that has never produced a positive on this input is indistinguishable from reporting 3 from a scanner that is broken β€” the same shape as every inert gate in this log. So the previous shipped lock (5.1.0) was scanned first and had to report exactly the five this issue opened on. It did:

5.1.0 shipped lock   5   h2, quick-xml x2, rsa, rustls      <- positive control
5.1.1 shipped lock   3   quick-xml x2, rsa
denominator          1270 advisories parsed (0 unparsed), 574 packages in the lock

⚠ The scan nearly produced its own false clean twice. The advisory files are .md with TOML front matter, not .toml, so a glob for crates/*.toml matches nothing and prints a clean repo β€” caught only because the script asserts parsed > 1000 before reporting. And my own display of rustls-webpki's advisories used grep -m1 patched with a trailing sed, which showed 0.104.0-alpha.6 as the only patched version and made it look like the scanner was missing three hits; the real field is [">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"], which 0.103.15 satisfies. The truncation was in the instrument I built to check the instrument.

The two deferrals are why #402 stays open. quick-xml's patch is >= 0.41.0, reachable only through object_store 0.14, which pulls aws-lc-rs in beside ring and leaves rustls with two registered CryptoProviders and no default β€” five TLS tests fail at runtime. That is a choice about which crypto implementation ships in the wheel, not a version bump. rsa/Marvin has patched = [].

And the gap this issue exists for, restated with both denominators: on the same lock, Dependabot 290 alerts all fixed, 0 open; RustSec 3.

The publish, checked the way #400 says to

Uploading       5    (the "Uploading distributions to" line + 4 files)
Skipping        0
already exist   0

PyPI latest 5.1.0 β†’ 5.1.1, with 5.1.1 confirmed absent before the tag. All four files downloaded and sha256-matched to PyPI's own digests; the sdist Cargo.lock is byte-identical to packaging/pypi/Cargo.lock on the bumped commit, so unlike #395 there was no repo-vs-shipped gap to find.

#390 β€” the third option

The issue left a product decision open: index the stale arm64 debs, or drop them. Neither β€” build a current one. ubuntu-24.04-arm runners were already available, so build-deb became a two-entry matrix rather than a cross-compile, and newest arm64 moved 2.8.7 β†’ 5.0.3 (cli#93). Nothing was deleted; the old debs stay installable as the older releases they genuinely are, which is what --multiversion is for.

Measured on the live published repository after v5.0.3 released through the new pipeline:

.deb in no index        22 of 23  ->  0 of 26
architectures indexed   [amd64]   ->  [amd64, arm64]
Release header fields    1 (Date) ->  8
Release self-entry      present   ->  absent, both suites

⚠⚠ The finding nobody had reported. Without --arch, dpkg-scanpackages writes every .deb into whichever index it is told to produce β€” architecture is simply ignored. Harmless while only amd64 shipped, and it would have started serving arm64 binaries to amd64 clients the moment arm64 existed. All three findings promoted REPORTβ†’FAIL (apt#3) with a fourth for arch leakage, each injected one at a time against the real tree to prove it fires β€” a promoted check that cannot fail is just a comment, which is what the REPORT tier already was.

One deliberate omission: Version is not required in the Release header set. It is optional in Debian and the generator does not emit it; Suite and Codename are what apt matches on. Requiring a field the correct output lacks is how a gate earns a permanent || true.

#398 β€” the pin found what the lint count had not

All 7 Rust repos now pin 1.99.0 (gateway#54, cli#94). The measured cost was 2 lints: cli's needless_borrow (one character) and gateway's result_large_err, kept as a function-scoped #[allow] with the reason written down, because axum's middleware signature is Result<Response, Response> and the error variant is a whole Response by construction.

⚠⚠ The pin also found gateway would not compile on 1.99.0:

error[E0464]: multiple candidates for `rlib` dependency `base64` found

base64-compat = "1.0.0" declares [lib] name = "base64", colliding with the real crate. Referenced nowhere in gateway source β€” dead long enough for the collision to go unnoticed. I first called this a local artifact precisely because it reproduced on both toolchains locally; CI was the instrument that proved the location real. A failure that reproduces everywhere reads like environment noise, which is the inverse of the usual tell.

2026-10-04 β€” published noetl 5.1.0, and two ways a clean result was not one

#395 closed. noetl 5.1.0 is on PyPI β€” https://pypi.org/project/noetl/5.1.0/ β€” so the artifact users install finally carries the crates the repo cleared back on 2026-10-03.

Arc completed: 214 β†’ 31 β†’ 7 β†’ 0 in the repo β†’ 0 in the artifact.

Version 5.1.0, and the file I had been comparing

Minor: above the published 5.0.0; not a patch because the embedded CLI moved 4.21.0 β†’ 5.0.1; not major because the package's own API (run, cli, main, __version__, the noetl/ntl entry points) is unchanged. Deliberately not aligned to the CLI version β€” packaging/pypi/README.md documents that the two are separate.

⚠ There are two pyproject.toml files. The root one (setuptools, 4.26.1) is the retired Python distribution's version carrier that semantic-release bumps; the wheel is built by maturin from packaging/pypi/pyproject.toml, which read 5.0.0. My earlier claim that the version line was broken β€” that pyproject sat below the published 5.0.0 β€” compared the wrong file.

⚠⚠ A green publish job is not evidence that anything shipped

twine upload --non-interactive --skip-existing dist/*

The published version comes from the packaging/pypi/ files, not the tag β€” and nothing bumps them automatically, because semantic-release's prepareCmd rewrites the root pyproject and packaging/pypi/** is outside its assets. So tagging without bumping builds the already-published version, --skip-existing skips everything, and the job goes green. || true in PyPI form, and nothing cross-checks the tag against the built version. #400.

So the publish was verified by its upload/skip counts: 4 Γ— Uploading noetl-5.1.0-*, Skipping 0, already exist 0, with PyPI's own state captured before tagging (5.1.0 absent) and after (4 files, none yanked).

Verifying the artifact three ways, because the lock is not the artifact

Files downloaded from PyPI, sha256 matched to PyPI's digests: the published sdist's Cargo.lock (524 packages, all four patched, none in a vulnerable range, with a control proving the range logic flags 0.102.8), strings on the published wheel binary, and the publish-run compile log β€” which is what closed the jsonwebtoken gap, since it left no string in the stripped binary.

⚠ pypi.yml builds without --locked, so a wheel's resolution need not equal the committed lock. Demonstrated: the 5.0.0 run compiled both rustls-webpki 0.102.8 and 0.103.13. Which is how a strings probe briefly convinced me the old artifact was fine β€” finding a patched copy is not evidence that a vulnerable one is missing.

⚠⚠ "0 Dependabot alerts" is not "no known vulnerabilities"

The sharper finding, and it came out only because the release verification used a second tool. A full RustSec scan of the same shipped lock Dependabot reports as 0 finds 5 patchable vulnerabilities in 4 crates: event-listener (memory-corruption), h2 (DoS), rustls (crypto-failure), quick-xml Γ—2 (DoS) β€” plus rsa (Marvin, no patch) and 8 informational unmaintained notices.

Two oracles, same input, different answers (#402). #395's closure is therefore scoped to the seven alerts it tracked, and says so.

That is the issue's own lesson recurring one level out: the fix was verified against the oracle that raised the alarm, and a different oracle sees more.

2026-10-03 (late) β€” pinning the last two toolchains found a repo already broken

Completes #378's pin work. All 7 Rust repos now carry a rust-toolchain.toml.

⚠⚠ ehdb's main was queued to fail, with no commit at fault

ehdb and signal-mesh were the last two unpinned repos, and both gate with -D warnings and no allow-list β€” neither had anything to absorb a new lint. Pinning them turned up a live case:

rustc 1.99.0 deprecated Atomic::fetch_update (renamed try_update)
crates/ehdb-l0/src/fault.rs:68 calls fetch_update
ehdb's last green run 2026-10-01, which installed rustc 1.98.1
the action's roll to 1.99.0 2026-10-03, verified across five other repos' runs

So the next push to ehdb would have failed the gate, with nothing regressed and no commit responsible. Found by pinning, not by the build β€” the failure was simply waiting for someone to trigger it.

The fix was deliberately not try_update. That method does not exist before 1.99, and the workspace declares rust-version = "1.82" β€” adopting it would raise a declared MSRV by 17 releases to silence a pure rename with identical semantics and no behaviour change. That is a policy call about what the crate promises consumers, so it was left alone. A scoped #[allow(deprecated)] carries the reason instead, placed on the expression rather than the crate so a different deprecated call still fails. Proven load-bearing: removing it reproduces the exact error.

⚠ A pin changes rustfmt too

Both ehdb and signal-mesh gate cargo fmt --all --check, and the pin also fixes which rustfmt runs β€” 1.99.0 ships rustfmt 1.10.0. Both pass --check under it, verified before the pins landed. A pin that fixes the lint floor and breaks the fmt gate is not an improvement, and this fleet already has a note that rustfmt version drift reformats large parts of a tree.

2026-10-03 (evening) β€” the allow-lists came down, and one of them was hiding a finding

#378: fleet clippy -A entries 23 β†’ 2. server 14β†’0 (#492), worker 7β†’0 (#347), tools pinned (#111), pointers bumped in #396.

⚠ The count was previously reported here as 27. It was 23 β€” that grep matched -A below, -A entries and -A is from comment prose rather than the command.

⚠⚠ server's -A dead-code was hiding #361

Two tests carried no #[test] and had never run: labels_are_a_closed_set and insert_column_and_bind_counts_agree. Both now armed, both pass, #361 closed.

The mechanism in both files: a stray #[test] sat above a doc block with a second #[test] below it, so both attributes bound to the earlier function β€” reported as duplicate_macro_attributes, also allow-listed β€” while the next function was left unmarked and inert. The two entries that hid it sat adjacent in the list.

A second gap came out with it: unused_variables covered high, a member of a deliberate up(500 ns)/down(499)/nearly(999)/high(798) family in which every other member is asserted. The lint's own suggested fix β€” rename to _high β€” would have preserved the gap and marked it intentional.

The lesson is sharper than "pay down debt": an allow-list hides the next finding of the same kind, not just the ones it was created for.

How to tell a justified allow from an inherited one

Mechanically: remove it and see whether the gate fails. Each surviving allow was removed individually β€” all produced exit 101; all restored, exit 0.

Three classes survived, each scoped with its reason: dead_code on two worker statics that are read only from a #[cfg(not(test))] arm (so --all-targets, which compiles the test cfg, sees them as dead while production uses them); non_snake_case on three server test names whose capital is the verdict; and cli's large_enum_variant β€” where the documented claim "boxing changes the published DSL schema" was then tested and confirmed, because boxing ToolSpecDoc::One makes schema_guard fail despite Box<T> being transparent to serde.

⚠⚠ Two of my own suppressions were themselves inert

In an issue about inert gates:

  1. #[allow(clippy::assertions_on_constants)] placed directly on an assert! invocation is silently ignored β€” rustc says so explicitly. The lint still fired while my comment claimed a scoped suppression that did not exist. Attributes belong on items.
  2. The first load-bearing battery reported 2 of 4 as decorative. Wrong β€” the shell split cases on :, which also appears inside #[allow(clippy::...)], so the wrong text was removed and the label printed empty.

Smaller corrections worth keeping

  • const { assert!(..) } fails cargo build, not cargo check. Check is metadata-only and skips const evaluation of function-body inline consts β€” a first mutation test of this read as a pass.
  • cargo test <bare_fn_name> matches nothing and still exits 0 (0 passed; 1178 filtered out). My first verification of the two armed tests ran nothing and looked green.
  • The toolchain action's banner is not the toolchain cargo used. It prints what it installed; the pin still wins. Reading the banner briefly convinced me pinning did not work, and that claim had to be retracted β€” the lint's own URL (rust-clippy/rust-1.91.0 on pinned cli vs rust-1.99.0 on unpinned tools) is authoritative.

Advisories: 0, but not where it counts

#385 closed β€” open alerts on noetl/noetl are 0, control-verified (state=fixed returns 290). Arc 214 β†’ 31 β†’ 7 β†’ 0.

⚠ The published wheel still embeds all seven: pypi.yml publishes only on a pypi-v* tag and none was cut, so the artifact users pip install is noetl 5.0.0 from 2026-07-26 with the 4.21 CLI and every vulnerable crate. Dependabot reads the lockfile in the repo; users install the artifact. Opened as #395, with the version line left for the owner because pyproject on main (4.26.1) sits below the published 5.0.0.

2026-10-03 (later still) β€” the wiki sweep, and a gate that printed FAIL and exited 0

#375 CLOSED. Every repo that can host a PR gate now has one, and the 15 wikis β€” which cannot β€” are covered by a scheduled ai-meta sweep (#392).

wikis declared:  15      markdown files:  243
wikis checked:   15      relative links:  1684
not cloneable:    0      RESULT: pass

It started at 3 findings. Two genuinely broken links that nothing had ever checked, because *.wiki.git has no runner: runtime/topology in noetl-wiki (resolved to a nonexistent path β€” GitHub wikis address pages by slug regardless of directory) and worker/src/ehdb/guard.rs in ehdb-wiki (a relative path resolving inside the wiki; source links are absolute GitHub URLs). Both fixed upstream so the sweep starts green.

⚠⚠ The first version printed RESULT: FAIL and exited 0

The sharpest instance of this entire arc's theme, and it was mine.

trap 'cleanup; on_exit' EXIT replaced the earlier trap, so rc=$? inside on_exit captured the status of cleanup β€” always 0 β€” instead of the script's. Two controls printed RESULT: FAIL while exiting 0: a green job announcing its own failure, because CI reads the exit code and not the text.

Caught only because the controls asserted the exit code rather than grepping the output. Had they grepped for "FAIL" they would have passed, and a permanently-green sweep would have shipped looking like thorough work β€” the exact defect #374 was about, reintroduced by the person fixing it.

The general form: when testing a gate, assert the thing CI actually consumes.

Design choices

The sweep checks the live wiki, not the submodule pointer (a pointer can be months behind; the question is whether a wiki is broken now), clones over anonymous HTTPS (declared URLs are SSH, a runner has no key, the wikis are public), derives its list from .gitmodules rather than enumerating, and fails below a population floor. Bare page names stay exempt because wiki links are slugs β€” flagging them would make it red on arrival.

2026-10-03 (later) β€” PR gates for the three repos that had none

Continues the entry below. #375's three gateless repos now gate, all merged: ops (#320), e2e (#93), apt (#2).

Each was measured green before being made gating, each prints its denominator, and each fails below a population floor β€” because 0 failures is also what a check that examined nothing looks like. Every floor was proven to fire by running the checker against an empty repo.

⚠ A naive YAML gate on ops starts red 36 times

All 36 are Helm templates: Go template syntax is not valid YAML by design. Shipping that would be a gate crying wolf on arrival, which is how a check gets switched off. helm lint covers them instead, and a control proves it is not a blind spot β€” breaking a template fails charts while yaml stays clean. Templates are found by a sibling Chart.yaml, never a path pattern.

The e2e gate caught real drift on its first run

docs/playbooks/** is generated from fixtures/playbooks and regenerated by npm run build, so the published site was always right while the committed copies rotted: the committed index claimed 144 playbooks against 162, and seven category pages had never been committed at all. The check is git status --porcelain docs/ rather than git diff, because most of that drift was files never added.

Also: onBrokenLinks was 'warn' β€” a build could not fail on a broken link, in a docs repo. Measured at 0, flipped to 'throw'.

⚠⚠ apt: the index does not describe the pool, and the correct generator is unused

New issue #390:

  • 22 of 23 .debs are listed in no index β€” dpkg-scanpackages without --multiversion.
  • arm64 is in pool/ but indexed nowhere β€” binary-amd64 is hardcoded.
  • Release carries none of its 8 identifying fields and lists itself at 38 bytes against a real 1792.

And the finding behind them: ops/scripts/publish_apt.sh already discovers architectures, uses --multiversion and writes a full Release header β€” and nothing invokes it. The release pipeline uses its own inline commands. "Exists but is not reached", applied to a script rather than a code path.

Reported rather than failed, because the arm64 call is a human one: newest arm64 is 2.8.7 against amd64's 5.0.1, so indexing it would make arm64 look supported years behind.

⚠ A third local-green-means-nothing case, and a flaw in my own CI wait

The e2e gate's first CI run failed on my own workflow β€” ModuleNotFoundError: No module named 'yaml', because the docs job set up Python without PyYAML while the files job did. Locally yaml was already importable.

Separately: my CI-wait helper reported a failure that belonged to the previous head. Right after a push, gh pr view can still serve the old SHA, so the loop matched the old completed run and exited on a stale verdict. It now requires the head to be stable across two reads before trusting any result. The run on the real head was green all along.

2026-10-03 β€” the Rust advisory backlog hit 0, and clippy gates everywhere

Picks up the CI arc from the entry below. Two tracked issues closed; the headline is that every number here was checked against a control, because several of the obvious measurements were wrong.

βœ… #385 β€” all 7 Rust advisories cleared

The wrapper has exactly two direct dependencies (noetl, pyo3), so all seven were transitive and each needed a breaking bump one or two repos up.

crate was β†’ now upstream
rustls-webpki Γ—4 0.102.8 β†’ 0.103.13 async-nats 0.38 β†’ 0.47 (tools#107)
jsonwebtoken 9.3.1 β†’ 10.4.0 tools#108
rpassword 3.0.2 β†’ 7.5.4 cli#90
lru 0.12.5 β†’ 0.18.5 ratatui 0.26 β†’ 0.30, same PR

Re-locked in noetl#704. Arc across the whole effort: 214 β†’ 31 β†’ 7 β†’ 0. The 24 npm alerts were already gone with the e2e suite removed in #701.

⚠⚠ jsonwebtoken 10 broke at runtime, not compile time. It requires an explicit CryptoProvider; the code compiled clean and failed only when exercised. One existing test caught it β€” a green cargo build would have shipped it. Fixed with features = ["rust_crypto"].

⚠ lru is still in the tree and that is correct. ratatui 0.30 does not drop it; ratatui-core 0.1.2 still depends on it. The advisory clears on version, not removal β€” so "did lru go away?" answers no, and that is not a failed fix.

⚠ ratatui 0.30 exposed a duplicated crossterm β€” ratatui on 0.29 while cli declared 0.27 directly, i.e. two independent copies of raw-mode/alternate-screen state driving the same tty. It compiled and tested fine because the types never cross. Collapsed to one version.

βœ… #374 CLOSED β€” clippy gates on all five

repo lints cleared by exceptions
tools (#109) 3 question_mark Γ—3 fixed none
gateway (#52) 4 derivable_impls, let_and_return, map_clone Γ—2 none
cli (#91) 39 22 auto, 11 by hand, 2 by threshold 2, each with a stated reason

Every RED proof was done in Actions, not locally β€” a gate seen only green is indistinguishable from one that cannot fail, which is the defect being fixed.

Three cli findings that were not style

  • extract_auth0_tenant_from_domain was dead β€” 7 passing assertions, zero callers. First read was that the Auth0 region fix had been left unreachable; it had not. This was the superseded region-dropping wrapper, and the live path is correctly wired. But deleting it naively would have cost real coverage: one of its tests was the only check of the domain-side trim/lowercase, so it was re-pointed at the live function rather than removed.
  • Two stale doc comments had drifted onto the wrong items β€” one described a function deleted in R-1.1 PR-2c-3 and had landed on resolve_duckdb_path.
  • if_same_then_else was a dead branch β€” contains("kind: Playbook") returned the same value as the default, so the check never changed the outcome.

⚠⚠ Five ways these measurements lied

  1. A -D warnings run is not an inventory. On cli it reports 15; the truth is 39. Warnings-as-errors aborts compilation at the first failing crate, so the main noetl crate β€” holding 23 of them β€” was never linted.
  2. clippy served cached warnings. A "39 β†’ 6" reading still printed (8/7) after the threshold was raised to 8. The control on a recompiled crate said (9/8). True figure: 39 β†’ 4.
  3. clippy --fix introduced a hazard worse than the lint. manual_flatten β†’ reader.lines().flatten() is lines_filter_map_ok: loops forever on repeated Err. An auto-fix is a suggestion, not a proof.
  4. A local RED proof returned exit=127 three times and looked clean β€” the command sat in a shell variable and zsh does not word-split, so clippy never ran. Gating on "0 errors in the log" would have recorded a passing proof of nothing.
  5. git checkout -- <file> to drop a probe reverted four real fixes in the same file; the assertion only checked the probe was gone.

⚠ A local green is not evidence about CI

Local cargo was 1.97.1, CI ran 1.99.0 β€” two minor versions, a different lint set. And tools, server and worker have no rust-toolchain pin while CI installs @stable, so their lint set floats: the tools proof run logged stable … updated - rustc 1.99.0 (from rustc 1.98.1) mid-run. gateway and cli pin 1.91.1. Pin decision tracked on #378.

A guard that fired correctly and hid what it was not

cli's first gate PR looked like a clippy failure and was not: ci/check-member-versions.sh (#331) refused it because the PR changed executor/ while leaving executor/Cargo.toml at 0.10.0 β€” the release would have published a noetl-executor without the change. It runs before the toolchain install, so build/test/clippy all reported skipped. The companion RED-proof branch died at the same step and proved nothing until it was rebuilt.

2026-10-02 (later) β€” closing out the CI arc: the gate had to be fixed twice, once by itself

Follow-on to the entry below. Everything here is the gate being made trustworthy rather than merely present.

⚠⚠ The worst finding: CHECK 3 forbade correcting a CHECK 2 violation

The gate deadlocked itself. CHECK 2 reports a pointer sitting on an unmerged side branch. The fix is to move it back onto the tracked branch β€” which is never a fast-forward, so CHECK 3 blocked it. The gate reported a defect and then refused to let anyone fix it. That is precisely where #372 sat for a day.

Fixed in #379: a non-fast-forward is allowed when the new pointer is on the tracked branch and the old one was not, reported as a corrective re-point and counted separately. It keys on the old pointer being off-branch, which a genuine regression never is β€” so the case CHECK 3 exists for still fails. All four controls re-run: corrective pass, regression FAIL, normal fast-forward pass, detached bump FAIL.

The general shape is worth keeping: a check that reports a defect must leave the correction reachable. Two checks can each be individually right and jointly produce a trap.

What landed

PR repo outcome
#377 β†’ 5bd14bdb ai-meta removed the two private submodules; coverage 31 of 31, zero unverifiable
#379 β†’ 37530b0e ai-meta the corrective-re-point exception above
#381 β†’ cda1f371 ai-meta re-pointed repos/docs onto docs main; closes #372
#376 β†’ 4f932527 ai-meta docs gate printed every failure twice
noetl/docs#195 β†’ 05d190b1 docs PR build gate (npm ci + typecheck + build)
noetl/doctor#9 β†’ 52041d10 doctor PR gate; recorded on the doctor wiki
noetl/server#491 β†’ 446882ae server clippy can now fail
noetl/worker#346 β†’ 36275e5b worker clippy can now fail

Coverage is full, and the token question is moot

repos/noetl.io and references/chatui are both private, so an unauthenticated runner could never fetch a SHA from either β€” they sat permanently unverifiable (no CI access), holding coverage at 31 of 33. Neither carried a live dependency (checked before removing: one dated memory-archive mention, and AGENTS.md, updated in the same change). Removed. Measured on main by the runner itself: declared 31, examined=31 of 31, check 1 31/31, unverifiable 0.

⚠ Measuring first explained the || true

clippy ran as cargo clippy … || true on five Rust repos β€” present in every log, costing full runtime, unable to fail. The obvious fix was refused by the evidence: a bare -D warnings gives 22 errors across 14 lint kinds on server and 15 across 7 on worker. An outright flip would have landed red on arrival, which is how a gate becomes something people skip β€” and is almost certainly why || true was written.

Both now gate with the existing kinds allow-listed by name, so a new lint of any other kind fails. Proven by planting a non-allow-listed clippy::len_zero: the clippy step went red while cargo test stayed green, isolating the lint gate. Sweep: #378. cli, tools and gateway deliberately left alone until each is measured.

Two things the inert step had been hiding: an unused std::sync::Arc import in server's tests/chain_log_sourced.rs, introduced by this repo's own recent chain work; and the step's name, clippy (visible, non-gating), which stayed accurate right up to the moment it started gating. Both fixed.

Decisions recorded rather than escalated

  • Re-point repos/docs rather than merge noetl/docs#188. #188 has no CI checks at all β€” the docs build gate did not exist when it was pushed (it is what #195 added). Merging an unverified RFC to make a pointer valid is the larger, less reversible action, and four docs PRs are open at once, so merging one out of band inverts the dependency. The RFC reference is dropped until #188 lands; re-bump is #380 and will be an ordinary fast-forward.
  • ci: released nothing on server and worker, as release-versioning.md says. Verified rather than assumed: both newest tags (v3.122.1, v6.2.1) predate the merge commits.

⚠ A stale pointer in my own memory

playbooks/332-embedded-cutover/STATUS.md does not exist β€” checked every local and remote branch and the working tree. That index entry was always wrong. The live status surface is this wiki: Home, Sessions-Log, and Continuous Integration.

Wiki

Home's Also open table reconciled again: 1 closed row dropped, 5 open issues added, now 58 rows with 0 gap and 0 closed-listed-as-open. Five of those had been referenced only in the previous Last refreshed prose β€” replacing that header turned them into durable table rows, which is the more robust place for them.

2026-10-02 β€” ai-meta gets its first CI, and the gate's first sweep catches the gate

ai-meta had zero workflow files. That is how #370 merged five submodule pointer bumps with nothing checking them. A gitlink is a 40-hex string and git validates nothing about it, so a typo or a never-pushed SHA produces a repository that cannot be cloned β€” and nothing anywhere says so.

What landed

PR repo what
#371 β†’ a7d4b08e ai-meta pointer gate + docs sanity, two jobs, on PR and push-to-main
#373 β†’ 90f10022 ai-meta calibration β€” prove access before blaming the pointer
noetl/docs#195 docs npm ci + typecheck + npm run build on PRs (open)
noetl/doctor#9 doctor cargo test + clippy -D warnings on PRs (open)

The ai-meta gate: CHECK 0 remote reachable β†’ CHECK 1 commit reachable from some ref β†’ CHECK 2 ancestor-or-descendant of the tracked branch tip β†’ CHECK 3 fast-forward. Treeless fetches (--filter=blob:none) β€” ancestry needs the commit graph and nothing else.

⚠⚠ The first real sweep produced three findings; two were the gate's own false alarms

flagged truth
repos/docs detached REAL β€” #372; the commit exists only on the unmerged branch of noetl/docs#188, and docs main does not contain that file at all
repos/noetl.io "dangling" FALSE β€” the repo is private; an unauthenticated runner cannot fetch any SHA from it
references/chatui "dangling" FALSE β€” also private

The gate asserted "dangling, unpushed, or wrong SHA" for a cause it had never established. Prove access before blaming the pointer.

⚠ I compounded it: I called chatui public because gh api repos/mlflowio/chatui returned the repo β€” that call carries my token. The probe that matches an unauthenticated runner is an anonymous git ls-remote, which fails. An authenticated probe cannot answer a question about an unauthenticated runner.

Verification coverage on main is 31 of 33, not 33. Two private submodules are permanently unverifiable without a secret, so the gate prints that count on every run rather than leaving the gap implied.

Blocking shape, so main cannot sit permanently red

PRs and pushes to main block on pointers that change; the full 33-pointer sweep reports, non-blocking. Failing main on legacy drift a push did not introduce would park main red, which is how a gate gets ignored β€” the exact failure this gate exists to prevent.

Proven green β†’ RED on a real defect β†’ green, in Actions

Not only locally. ai-meta: an all-f pointer plus a broken-link/unbalanced-fence markdown file turned both jobs red (check 1 0/1; files=1, links=1, 2 problems), then reverted β€” by git revert, no force-push, so the proof stays in the branch history. docs: reintroducing the real #364 MDX {#custom-id} produced Could not parse expression with acorn / micromark-extension-mdx-expression. doctor: a clippy lint failed the clippy step while cargo test stayed green, isolating the gated step.

Fleet CI audit β€” reachability, not existence

The question asked was "does anything trigger on pull_request?", not "are there workflow files". 16 repos examined.

  • ⚠⚠ clippy is || true on server, worker, cli, tools, gateway β€” it cannot fail, while costing its full runtime on every PR. It passes any "do we run clippy?" grep. ehdb and signal-mesh gate it with -D warnings. #374
  • No pull_request trigger at all: doctor, ops, e2e, apt, docs, ai-meta. #375
  • ⚠ noetl/noetl runs no pytest anywhere, and check-pr.disabled sits on its default branch β€” a check deliberately switched off, reason unrecorded.
  • ⚠⚠ *.wiki.git repos cannot host Actions. Platform constraint, so "add CI to the wikis" is not achievable as stated; the option is a scheduled ai-meta job running check-docs.sh over the wiki submodules.
  • βœ… #232 is stale-corrected: cargo test --all-targets does run on PRs on all seven Rust repos. My memory index had asserted the opposite for months.

⚠ Measure the baseline before gating

doctor: test exit 0 and clippy -D warnings exit 0, but cargo fmt --all --check exits 1 with 396 diff lines. Copying ehdb's ci.yml wholesale would have landed a job that was red on arrival. fmt ships report-only and announces itself when clean. Same discipline for docs β€” typecheck and build were verified exit 0 on the base first.

Self-traps worth keeping

  • The first script printed unbound-variable errors and still exited 0 β€” a vacuous pass in the gate meant to prevent vacuous passes. Fixed with a COMPLETED marker and an EXIT trap that turns any abort into a failure.
  • Three separate counts were wrong while the verdict was right. My harness grepped βœ“ on main and reported "ancestry 3 of 5", missing the two wikis that track master. A workflows=0 column was broken by a failed zsh glob. And the wiki reconciliation first reported 84 stale rows / 67 missing β€” both wrong, because the measurement crossed into Recently closed and could not see the prose links in Active umbrellas. The real numbers were 12 and 19.
  • comm silently produced garbage in both directions because I fed it sort -n when it requires lexicographic input.
  • ⚠ CHECK 2 needs deliberate isolation: a side-branch bump also trips CHECK 3, so the old pointer must be the side branch's merge-base β€” then the fast-forward check passes and only CHECK 2 can fail.
  • ⚠ A false-alarm fix can weaken the alarm. Every control was re-run after the ref fallback went in, to confirm an all-f SHA still fails.

Wiki

Home's Also open table was reconciled against gh issue list --label ai-task: 12 closed issues were listed as open, and 19 open issues appeared nowhere. Both fixed; the table is now 54 rows and matches the query exactly.

2026-09-30 (later) β€” #362: the chain is ordered by LINKS, prod armed, gate pending

βš™οΈ Shipped: ehdb v0.4.5 (#377) + server v3.118.0 (#477) + ops #317. Pointers bumped.

The defect. event_id is a snowflake minted BEFORE the insert, so commit order is not id order. An event can commit into the middle of an ORDER BY event_id read and shift every position after it. order_by_links never compares two event_ids: it indexes the set, finds the single root, and walks successors β€” so an interior insert is just a link.

Every failure mode is its own variant (MultipleRoots, NoRoot, Fork, Dangling, Unreachable), because they need different responses and collapsing them into one "cannot answer" is how the previous two divergence classes went unexplained.

(b) hydrator off max(event_id) onto the link tip; new HeadAmbiguous, which still returns a head because stamping NULL on ambiguity adds another root. (c) events_materialize stamps the link it never had β€” it wrote 10 columns and prev_event_id was not among them, so every row became a root. project_events deliberately unchanged: it is a replication path and re-stamping would make the projection disagree with its source. (d) one-root invariant as a metric + gated endpoint + a sampler, because a gauge only written when a human calls an endpoint sits at 0 β€” the shape of a healthy system. It ships with sampled_at, pinned at 0 = never.

⭐ The forced-restart gate PASSED

prev stamped roots
RED β€” no hydrator, restart mid-emit None 2
GREEN β€” link-tip hydrator the real tip 1

It forces the condition by constructing a fresh ChainHeads, because a restart's only effect on this path is an empty in-memory map. The pod-delete version is weaker: it only fires if an execution happens to be emitting across the window, and #227 records that in-flight executions often stall. That is the same "wait for it instead of forcing it" failure that let #472 ship and never fire β€” prod recorded head=0 across 4,270 hydration decisions.

⚠⚠ A capability is removed, and the cost is real

#357 could serve a post-restart execution with several NULL-prev rows by recomputing the edge from log order. That worked β€” and it was the same trusting of id-order that caused the false divergence. A forked execution is now refused and falls through to Postgres. Safe, but the store covers fewer executions until the write path stops producing forks: 2 of 63 recent plus the whole legacy era.

⚠ So comparator-green is not sufficient on its own β€” a source that refuses everything also reports zero divergence. The gate is green and coverage.

⏳ Prod: armed, gate NOT yet met

Rolled 05:41:15Z, armed 05:48:45Z. First reading 16 engagements / 0 divergence / 0 refusals, so it is engaging. The duration-sized re-ramp is running and must span several ~hourly cycles before anything is called green β€” the last time a 772-comparison ten-minute window was called green it failed an hour later. Rollback is a ~17s three-gate flip, and is preferred over an image roll because every build below v3.118.0 orders the chain by event_id.

Found along the way

  • ⚠ /api/events/batch returns {"status":"ok","event_ids":[…]} while persisting nothing β€” the insert 500s on event_catalog_id_fkey (#363).
  • ⚠⚠ Two of my own guards were decorative on first write: one matched link_batch in the comment above the call; the other matched a bare route path, so …invariant-DISABLED still contained it.
  • ⚠ A fixture bug (a terminal event with prev = NULL, a two-root execution by accident) and four tests seeding unlinked events, which made them measure the refusal path instead of what they were named for.
  • ⚠ cargo fmt -p <pkg> -- <one file> reformatted 39 files and broke a test. The path argument does not scope it.

2026-09-30 β€” the chain store ramp is ROLLED BACK: a third mechanism, and my window was too short

⚠⚠⚠ CORRECTION 2026-09-30 00:15Z β€” THE GREEN DID NOT HOLD AND THE GATES ARE ROLLED BACK. The 772/0 reading was real but the WINDOW was too short: an hour later the same deployment read 13 divergences over 839 engagements (98.45%) and the three gates were removed (~16s env flip, env 67 β†’ 64). The prod cause is a THIRD mechanism β€” an event committing into the MIDDLE of an ORDER BY event_id read, because a snowflake event_id is minted before insert so commit order is not id order (#362). The store was missing one interior row, proven by position arithmetic (store held at position 72 what the log holds at 73). So #360's two defects were real and its fix stands, but it was not what was breaking prod, and the earlier 5-in-79 was most likely this too. A large denominator over a short window is not a sustained result. #360 is REOPENED.

Full detail of the mechanism in #362. What #360 still owns: its own two defects (a behind snapshot is staleness; coverage is monotonic), both merged and independently proven.

⚠⚠ Two more of my own checks lied while diagnosing this. GET /api/executions/{id} returns ORDER BY created_at DESC LIMIT N β€” the most RECENT N β€” so "neither conflicting id is present" was computed on a truncated tail. And created_at is stamped at MINT time, so "0 inversions between created_at and event_id order" can never detect commit reordering; I ran it as a refutation and it refuted nothing. ⚠ services/execution.rs also points readers at /api/executions/{id}/events for the full history β€” that route does not exist.


2026-09-29 β€” the chain store was armed on prod and read 772/772 (SUPERSEDED β€” see above)

⭐ The ramp that failed at 6.3% now reads 100.00%. Two defects behind the false divergence, each proven by its own RED control, and prod re-ramped with all three gates armed.

What was wrong. The earlier ramp reported 5 divergences in 79 comparisons and was rolled back β€” with nothing actually wrong: 60 partitions, 0 persistent mismatches, 24/24 executions COMPLETED. Two causes:

  1. populate_from_log classed "the store holds more than my snapshot" as Diverged. Under concurrent reads of one execution the task that reads first can reach the store lock second and find a fresher snapshot already applied; the extra events are real, committed, and the better answer. New FromLog::StaleLog β€” benign, partition stays servable.
  2. Coverage was not monotonic, so a stale snapshot could record a smaller total and make the partition unservable through the guarded read β€” a false divergence becoming a false refusal. Fixing only the classification would have moved the bug, not removed it.

⚠⚠ My first fix weakened the #358 alarm. "Store longer than my snapshot" is also exactly how a second writer looks, and my reasoning β€” "populate_from_log is the sole writer" β€” assumes that case away. The #358 regression test failed on the first run with Got StaleLog { stored_len: 4, log_len: 3 }. From one snapshot the two causes are genuinely indistinguishable, so the verdict moved to where a second read is possible: chain_for re-reads the log and escalates to diverged when the log does not catch up. The populator now reports StaleLog as unresolved rather than deciding.

⚠ And the escalation introduced a new false alarm of its own β€” releasing the store lock between populate and the guarded read let another reader advance the store, so the chain came back longer than the terminal-flag vector: 2 length_disagreement refusals in 800 concurrent reads. Safe (it declines rather than answering wrongly) but the same false alarm one step downstream. Fixed by holding one lock across both. Diagnosing it needed refusals counted by reason β€” a bare refused == 0 gave no way to tell a transient DB error from a residual symptom.

Mechanism CONFIRMED, on the intermediate non-atomic build:

build stale_log diverged refused result
pre-fix classification 0 10 12 FAILS
fixed 9 0 0 passes

10 false divergences in 800 β€” same mechanism and direction as prod's 5 in 79.

⭐⭐ And then the shipped build stopped exhibiting it at all β€” stale_log=0, and the pre-fix classification also passes the burst. Making populate and the guarded read one lock hold lengthens the critical section enough that readers queue in read order, so the reordering largely stops happening. A real improvement, and precisely why that test can no longer discriminate: it is documented as a soak, with the discriminating evidence in deterministic tests.

⚠⚠ An INERT test found in the very path being ramped. tests/chain_advance.rs::with_the_flag_on_and_a_source_the_chain_decision_is_used ended }); instead of }).await;, so the async block was built and dropped β€” the one test asserting the chain decision is actually used, the entire reachability claim, asserted nothing. rustc emitted unused implementer of Future that must be used on every build and it went unread. Armed (it passes), plus a guard that fails on an unawaited block, with a RED control proving the mutant compiles β€” a non-compiling mutant reads as caught. Two further tests carrying no #[test] at all β†’ #361.

⚠ Gate 1 is now INERT on its own. Since the #358 emit-path call site was removed, the populator's only caller sits behind the source gate, so the old "arm populate, measure, then arm source" sequence has no measurable first stage. Verified on prod at 17:25Z: populate armed, source off, every chain_populate outcome stayed 0. The order still matters β€” NOETL_CHAIN_SOURCE=chain resolves to OFF without populate, deliberately.

⚠ And this issue postdates the commits that cite it. "noetl/ai-meta#360" was written into a merged ehdb commit, a merged PR body and the pushed v0.4.4 tag before the issue existed; gh issue create revealed it by handing me #360 for something else. The issue was written to match the immutable history, and the other one re-homed to #361. A straight violation of Issue Tracking Rule 1.

PROD RE-RAMP β€” all three gates armed 17:30Z on server v3.117.2:

comparisons 772
failures (any kind) 0
agreement 100.00%
concurrency up to 6 concurrent executions
executions 80/80 COMPLETED
pod ready, restarts=0, 0 ERROR in 2549 log lines

versus 5/79 (6.3%) on the rolled-back ramp. Postgres authoritative throughout; rollback is a ~17s three-gate env flip, cheaper than an image roll β€” and safer, since v3.117.1 predates this fix and rolling to it restores the false-divergence behaviour.

Landed. ehdb #376 β†’ 720c1d2, v0.4.4. server #476 β†’ f630e9a6, v3.117.2, both ehdb pins moved together. ops #317 records the armed gates as-built (env 64 β†’ 67, verified against live in both directions, volumes and PVC compared because that is the #323 outage shape) and admits a ledger gap rather than backfilling rows it cannot verify β€” the ledger had not moved since v3.109.3 on 2026-09-16. Not applied; prod was changed with set env / set image, single-field edits, per Deployment Validation and apply-safety.

1304 DB-free tests + 14 + 15 DB-gated against a real Postgres fixture.

2026-09-29 (later) β€” the #357 stack is merged and green on main

Headline. All four PRs merged in order, ehdb v0.4.3 cut, both pins moved together, and CI verified the tagged combination. The only remaining step is the prod flag flip, which is owner-gated because it is the one step that touches live decide().

repo PR merge commit
ehdb #374 guarded read refuses on marker/content disagreement b2c6e01
ehdb #375 populate_from_log + coverage record c1b78e9
server #471 emit-chokepoint call site, shadow, default-off 72e4941
server #472 chain-head hydration + log-sourced source + repoint proof 78c055a

Tag v0.4.3 β†’ c1b78e91, annotated, on the CI-green main tip, verified by object: populate_from_log, the coverage gate, the watermark tripwire, ChainError: Display, LogEvent carrying no prev field, and FORMAT_VERSION still 1 so the on-disk layout gate does not refuse an existing store.

Both pins together. ehdb-feed and ehdb-l0 β†’ v0.4.3 in one commit (3bd3289). A split pin puts two ehdb-l0 versions in one graph; the compile error is the lucky outcome, two engine versions on one layout is the unlucky one.

⭐ The local paths override was removed before that commit, so #472's CI went green against the published tag rather than a local source tree β€” the one gap the kind proof could not close. The kind suites reproduce against the tag too (11/11 + 15/15), as do 1170 lib tests.

Prod is untouched and the code is inert there. Swept all 6 prod workloads for the four chain env vars: 0 set. The ops IaC does not set them either.

Two records worth keeping.

  • ⚠ noetl/server's tag and Cargo.toml disagree as steady state β€” v3.113.0β†’v3.116.0 all carry version = "3.112.3", and every release-server run passed anyway. Pre-existing by five releases. Do not "fix" it with a manual bump; that is the 2026-08-03 incident. Read the tag.
  • ⚠⚠ Three false-greens from one gh family, all caught before they mattered. --json conclusion yields "" not null while a run is in flight; --limit N spans several pushes so a success can belong to an earlier commit; and --commit <sha> can return 0 rows while --limit shows runs with that headSha β€” an empty list satisfies "0 not completed", so an until loop exits instantly and prints a blank that reads exactly like a pass. Every green reported was re-verified by pulling --limit, filtering headSha client-side, and requiring a non-empty match set. The merges and the tag were each re-checked this way after the fact, and all held.

2026-09-29 β€” the chain-store root problem, both halves

Headline. The owner chose "both" for #357. Both halves are implemented and kind-proven; the repoint holds for the explicit prod-canary go. Four stacked PRs, none self-merged. Nothing flipped in prod.

PR what
ehdb#374 guarded read refuses on a marker/content disagreement
ehdb#375 populate_from_log + the coverage record
server#471 the emit-chokepoint call site, shadow, default-off
server#472 chain-head hydration + the log-sourced source + the repoint proof

The fix, in one line. Recompute the chain edge from log order β€” the log's own prev_event_id is never an input, because it is NULL on 643,420 of 645,677 kind rows β€” plus a coverage record that makes populated from the beginning distinguishable from populated from the middle, plus chain-head hydration so a cold in-memory map stops stamping prev = NULL on running executions.

Kind proof. 11/11 in the new suite, 15/15 in the pre-existing one. RED control (revert the edge recomputation) fails 5 of 9. ehdb battery 8/8, server battery 6/6. Flag-off writes 0 files. The poller path proven with all three gates on: chain_source_from_env yields log_sourced_chain_store and poller_action advances the post-restart execution that used to freeze.

Live-path RED→GREEN, twin fixtures, identical scenario: without the hydrator a running execution's next event is stamped prev=NULL → 2 null-prev roots; with it, prev=2053 → one root and hydrate{head}=1.

Lessons worth keeping.

  • ⚠ The root signal cannot be an event-type name β€” only 62 of 595 executions start with playbook_started; 533 use playbook.initialized. The first guess would have been inert for 90% of them. The root is "position 0 in the log".
  • ⭐ Two mutants survived first, and each named a missing test. M7: nothing exercised the log path's failure case. S5: the Pg hydrator mapping a read error to Empty changed no test β€” the G4 lesson, documented in a comment and never tested. Both closed, S5 against a real database lacking the table, with a positive control that it really is absent.
  • ⚠⚠ Six metric labels were not pinned, found by reading /metrics on a running server rather than the code. A guard now computes the recorded set from the source and prints the denominator (13 recorded, 16 pinned).
  • ⚠ Two pins that depend on each other are one decision. ehdb-feed pulls ehdb-l0; bumping one put two ehdb-l0 versions in the graph. The compile error was the lucky outcome.
  • ⚠ A proof I could not re-run. The fixture was hand-built in a scratch database that was later deleted, leaving chain_source_pg.rs with no fixture β€” its failures then read like a code regression. Now committed as tests/fixtures/chain_fixture.sql, with 1002's deliberate hole documented.
  • ⚠ I nearly shipped a second copy of one error message as a new Display impl beside the existing message() β€” the drift this repo's rules are about, self-inflicted. And the module-level doc still asserted the pre-fix watermark ordering, one level above the guard that already pinned the function doc.
  • ⚠ A wiki row I wrote yesterday was wrong: NOETL_CHAIN_POPULATE is not strict == "true" (it accepts 1|true|yes|on), unlike NOETL_EHDB_EMBEDDED one row above, which is. Corrected β€” the divergence matters exactly when reading an armed state off a manifest.

2026-09-28 β€” the chain-store populator is proven; the repoint is not

Headline. The populator call site landed and was kind-proven, and the same proof blocked the step it was meant to unblock. Two defects make a reader of the chain store wrong on a running execution β€” the exact cliff the authorized sequence existed to avoid β€” so the repoint is on hold (#357). Nothing was flipped in prod.

Landed.

what where
populator watermark ordering β€” never create authority out of a failure ehdb#372, merged 13b54fe
tags v0.4.1 (do not adopt) and v0.4.2 annotated, on CI-green commits
the emit-chokepoint call site, shadow, default OFF server#471 β€” open
the guarded read refuses on a marker/content disagreement ehdb#374 β€” open
env vars + what a zero on each series means server wiki deployment-specification
the chain store's design, and why it cannot serve yet ehdb wiki Design-Execution-Chain-Store

Kind proof. Real noetl-control-plane against the kind cluster's Postgres, kind server scaled to 0 so exactly one writer existed, forward identity proved by matching count(*) against an in-cluster query, probe carrying its own negative control, and the forward killed at the end to confirm the probe died with it.

  • flag off β†’ 0 files written. Inert, measured rather than asserted.
  • all seven metric labels pinned at 0 before anything fired.
  • fresh execution β†’ Postgres 6 events correctly chained; store 6, seqs [1..6], watermark 1:6, guarded read all six, root correctly prev=None. Exact agreement with the independent oracle.

What it found. Shared cause: prev_event_id is stamped from an in-memory head map with no DB hydration (server/src/state.rs:260), so after a restart a still-running execution's next event carries prev = NULL and the store cannot tell that pseudo-root from a real one. 534 of 595 kind executions already carry more than one null-prev root.

  • mid-flight arming truncates, confidently β€” Postgres 3, store 1, authoritative first=1 through=1, guarded read Some(1) with root_prev=None. first=1 is true of the store and false of the execution, and the answer is indistinguishable from a complete chain. Open.
  • a restart freezes the chain while the watermark advances β€” Postgres 7, store 6, watermark 1:7. Every later event is rejected for the same not-the-head reason, so the prefix is permanently stale and still authoritative. Fixed in ehdb#374: that disagreement is the only evidence the store has that the log moved on without it.

Lessons worth keeping.

  • ⚠ The root marker cannot key off playbook_started β€” measured, only 62 of 595 executions start with it; 533 start with playbook.initialized. Building on the first guess would have been inert for 90% of executions.
  • ⚠ Two pins that depend on each other are one decision. Bumping ehdb-l0 alone put two ehdb-l0 versions in the graph. The compile error was the lucky outcome; the unlucky one is two engine versions opening one on-disk layout.
  • ⚠ A doc comment that contradicts its code is worse than none β€” populate's doc still described the pre-fix ordering after the fix landed, and is now source-guarded.
  • ⚠ cargo test --lib <filter> returned a green 0 passed for an integration test β€” the populator's tests live in tests/, so the filter measured nothing and reported ok.
  • ⚠ gh project item-list --limit 200 returns exactly 200 as a CAP, so "not on the board" was an unreliable zero; at --limit 1000 the item was there.

2026-09-24 β€” ⚠⚠ The #315 give-up cap shipped inert a THIRD time: the budget is reset by event arrival

noetl/server v3.112.7 (#462) reached prod by digest at 06:32:24Z and was measured inert, exactly as v3.112.6 (#461) had been. The reconcile give-up cap has still never fired in production.

Measured 34 minutes after the deploy, against a pre-deploy baseline taken on an 80-minute-old pod (not a restart):

pre-deploy +34 min
reconcile_giveup{max_noops} 0 0 β€” and no tombstone_* series exists at all
orchestrate_drive{offserver_retry} 24.0/min 24.7/min β€” no decay
orchestrate_in_flight_executions 101 101

Root cause. dispatch_offserver_stateless_drive calls DriveTombstones::clear() as the else if condition, so it runs for every real event whether or not a tombstone exists β€” and clear() removed the budget along with the tombstone. retrigger_recorded ran at 79.6/min across ~101 in-flight executions: one real event per execution every ~76 s, against a budget needing 1800 s of uninterrupted no-ops. Unreachable by construction.

This is the same defect three times β€” the budget living somewhere another path resets it. #461 moved the give-up record out of the evictable orch_cache slot; #462 moved the budget out of it; both were necessary and neither was sufficient.

Fix: noetl/server#463 β€” clear() drops the tombstone and leaves the budget alone. A real event is not progress; an applied drive is, and that already zeroes the budget via reconcile_decision(advanced = true). PR open, not merged β€” the standing authorization covered #462 only.

⭐ What made this round catchable: the new guard was run against the unfixed code first and failed with after 901 polls (90 real events) the execution is STILL being driven. That negative control is what the two previous rounds never ran. A positive control is included so "preserve the budget" cannot be satisfied by making clear() a no-op. Suite 1213/0.

⚠⚠ Tooling trap worth keeping: a podman COPY --from=builder was served from cache while the builder stage genuinely recompiled for 2m44s, so a rebuilt binary never entered the image and two gate arms were byte-identical to a 45-hour-old build. Fixed with an ARG CACHEBUST before the COPY, plus a content control asserting the arms differ in the right way.

Wiki: noetl/server reconcile-give-up (new) + the NOETL_RECONCILE_MAX_NOOPS row on deployment-specification, which now warns that a giveup of 0 with offserver_retry climbing means the cap is inert rather than idle.

2026-09-21 β€” 🧩 signal-mesh split out of ehdb, wiki populated, registered in the ecosystem

The A2A/ReAct signal-mesh POC and its two design documents left noetl/ehdb for https://github.com/noetl/signal-mesh (public, Apache-2.0, main). It was a consumer of EHDB, never part of it β€” it folds an EHDB-shaped log and uses ehdb_core::plan::ReadConsistency, and no EHDB crate uses it back. In-workspace, every EHDB build compiled a POC nothing there depended on, and every EHDB release tagged a component with its own lifecycle.

history preserved with git filter-repo β€” 6 commits, original authorship and dates, not a squashed import
crate renamed ehdb-signal-mesh β†’ signal-mesh, at the repo root (one crate, one repo)
the pin ehdb-core = { git = …, tag = "v0.3.0" } β€” not a branch. A ReadConsistency change now breaks the mesh at a deliberate tag bump instead of silently
ehdb side noetl/ehdb#367 merged, CI green β€” member removed, docs/moved/signal-mesh.md + README line left as the pointer
new repo CI fmt Β· target hygiene Β· test Β· clippy -D warnings Β· a run of the demo. Green, and verified from a fresh clone
wiki 6 pages at https://github.com/noetl/signal-mesh/wiki β€” front door, links into the repo docs rather than forking them
tracked #349 (closed), board 3 = Done

Notes worth keeping

⚠⚠ A ported guard can go vacuous on a layout change without failing. ehdb's hygiene guard tests src_path.contains("/src/bin/"). In a single-crate repo the relative path is src/bin/demo.rs with no leading slash β€” the src/bin control would have passed over an empty set. Replaced with an is_src_bin predicate plus a test pinning it against both layouts and the near-misses. The population floors also had to drop 20/100 β†’ 3/10; borrowed floors fail for the wrong reason. The guard then fired for real, reporting its own file as untracked before it was staged.

⚠ The src/bin control that stayed behind in ehdb survives only because crates/ehdb-reference/src/bin/ still exists β€” confirmed by running it, not assumed.

⚠⚠ Three mermaid defects the source could not show. GitHub accepted all seven fences as mermaid, so every source-level signal was green. Rendering the pages found: a hard failure (periods inside a -.label.-> dotted-link terminate it early β€” the blueprint's dotted labels survive only because they happen to contain none); a diagram that rendered and was illegible (two wide subgraphs scaled to micro-text); and two overlapping edge labels. "It rendered" and "a reader can use it" are independent questions.

⚠ And a near-miss in my own instrument: I first measured all five blueprint diagrams stuck at the iframe's 180px floor and nearly rewrote five working diagrams. The heights were read before the cross-origin resize completed; a control against the same diagrams on repo markdown showed 971/261/388/624/180. The measurement was wrong, not the diagrams.

⚠ gh project item-add returned rc=0 printing nothing, and gh project item-list --limit 500 then did not list the item it had just added (285 scanned). A GraphQL query on the issue's projectItems showed it present with Status Done. Same shape as the gh issue list --limit N cap: the listing was the lying instrument.

⚠ git push over HTTPS is refused for .github/workflows/* when the gh OAuth token lacks workflow scope. SSH pushes fine. There is no GitHub PAT in any of the 8 GCP projects' Secret Manager.

⚠ Repo Map had no noetl/ehdb row at all, despite ehdb being a production repo since 2026-07 β€” found while registering signal-mesh, fixed in the same change set. The page is a representation, and nothing forced it to agree.

2026-09-08 (step 5) β€” βœ… the engine is embedded on prod, in shadow, flag off

server v3.106.0. The server opens an ehdb-l0 event-log engine in-process, appends what Postgres accepted, and compares counts. It serves nothing β€” Postgres remains the system of record. The serve flip is a later checkpoint.

Dependency moved rev β†’ TAG (ehdb v0.2.0): the first git-tag-semver pin, closing the 70-commit skew between consumers. FORMAT_VERSION fires at this open.

kind, flag ON opened=1, agreed=24, diverged=0, append_failed=0; dispatch still COMPLETED in ~5 s
prod, flag OFF all six pinned series 0 β€” engine never opened; dispatch COMPLETED; 0 ERROR lines; 0 unexpected restarts
the gate full-spec kubectl diff --server-side was two lines: image digest + server-assigned generation. Nothing else.

Notes worth keeping

⚠ A kubectl diff --server-side appears in the audit log as a deployments.patch β€” it is a dry-run apply. I nearly read my own diff calls as an outside actor.

⚠ A test that cannot tell "disabled" from "broken" is not testing the flag. The flag-off test first pointed at the /data default, where the open fails anyway β€” None came back for the wrong reason and a mutation deleting the flag check passed. Fixed with a writable temp dir and a positive control.

⚠ System-pool restarts (2 and 26) are the known #322 liveness-kill pattern β€” exit=0 reason=Completed, last kill 92 min before the deploy, both Ready since. Pre-existing, unrelated, not caused by this.

Docs: server wiki [Embedded EHDB Shadow], ehdb wiki [first consumer], memory topic updated β€” per the new change-documentation.md mandate.

2026-09-08 (program) β€” ⭐⭐ EHDB embedded-state: foundations landed, foca adopted, D8 found already built

#332. Reorientation from hardening EHDB-as-a-networked-service to embedding it per shard. Three RFCs, six merged PRs, nothing deployed.

The recurring finding

Three components this program needed were already built and never wired β€” the ehdb-l0 projection engine, D8 RuntimeDataset, and the affinity routing layer. EHDB was built ahead of its consumers, so "does it exist" and "does it work" are independent questions, and only the second is ever answered by shipping. Each was exercised before being built on; all three worked.

Landed (all inert)

server#416 fail-closed forwarding β€” a failed forward is a 503, not a local write that forks the owning shard's log
server#417 partition table, P = 4096, bit-identical to the old mapping because it is implemented 3Γ— across 2 repos
ehdb#353 on-disk FORMAT_VERSION gate β€” the corruption vector the 70-commit git-rev skew creates
ehdb#355 stored per-shard cursor β€” recovery reads the tail (20 records, cursor 15 β†’ replays 5)
ehdb#356/#357 D8 exercised, then append-time validation + the OpOrigin seam
ehdb#358 foca 2.0.0 adopted β€” transport only, membership state in D8
tools#98 ToolResult #[non_exhaustive] β†’ v4.0.0
cli#86 member-version guard β€” a green release that published nothing new, 2 of the last 8 runs

Decisions

Embed, not sidecar β€” API and storage are one workload, so shared fate is correct. Topology lives in EHDB as D8, an event-sourced projection reusing the same cursor/snapshot/replay machinery. Gossip = library: foca, chosen over chitchat because chitchat is Scuttlebutt + phi-accrual and does not do SWIM indirect probing β€” the property that distinguishes "I cannot reach X" from "X is down". Registry = git-tag semver, no public crates.io names.

Principle codified: self-sufficiency.md β€” no external database, not no dependencies.

⚠⚠ Four guards that caught nothing on first write

Recorded because the pattern is the point β€” in each case a guard was written to prevent a failure class and then exhibited it:

  1. the #[non_exhaustive] guard matched its own doc comment β€” prose counting as code;
  2. the format guard's blanket Err(_) => Ok(None) turned every read failure into "absent" β†’ re-initialise, and no test had a substrate whose read fails;
  3. the OpOrigin seam test called the authorizer directly, so deleting the call from the append path stayed green β€” a seam nothing calls;
  4. a rename-order test asserted the end state, which is identical either way.

Plus two redundant checks found by mutations passing (a whitespace rule the charset already covered; an unreachable is_some()), and ⚠ a mutation harness that misreported six catches as compile errors because grep '^error' matches cargo's error: test failed.

Held

foca bring-up on prod, step 5's prod checkpoint (server opens a local engine). Prod untouched throughout β€” server v3.104.3.

2026-09-08 (reorientation) β€” ⭐⭐ EHDB as embedded state: the networked topology is the root, not the bugs

#332 Β· design + gap analysis in docs/rfc/ehdb-embedded-state.md. Read-only; nothing built.

We have been hardening EHDB as a separate networked service. That topology β€” not a series of unrelated bugs β€” is the shared root of #320 (relay loss), C5 (election), ehdb#332 (remote tier on the writer's own PVC), #325/#326 (two stores to compare), and the phantom-tier-volume incident earlier the same day.

⭐⭐ The headline: EHDB is already a library

All 11 crates are libs; there is one binary in the whole workspace. The thing running as noetl-cmdbus-writer is the noetl-worker binary in writer mode β€” our own process wrapping a library, not a separate product. Every engine has open(config, substrate) / cold_load(...) over a DurableSubstrate seam.

The gap is one seam. The server links ehdb-l0 and never opens an engine β€” D1EventLog is only a type parameter to PublishRouter, which holds PhantomData and can only connect(shard_count, addrs) over TCP.

Recommendation: embedded, behind a trait, dedicated storage runtime

Decided on the contention question from two code facts:

  • the engine already runs replication/manifest/upload on its own named OS thread behind an mpsc β€” "The append path never does this" is in the source;
  • the API is 100% sync (0 async / 236 sync), so placement cannot be accidental β€” the type system forces the isolation decision rather than leaving it to discipline.

The sidecar's kernel-enforced isolation is a real advantage, stated honestly and rejected because it keeps an IPC boundary on the hot path β€” the same shape that produced #320's two mechanisms and #325/#326's two oracles. The purpose is to delete a boundary, not improve one. Reversibility kept by adding the local router as a second impl of the existing seam.

The fold is real; the recovery driver is not

ProjectionStore already describes itself as an append-only log of projection snapshots whose latest is the state. ⚠ Two snapshot layers that must not be conflated: the manifest (which parts exist β€” the thing that filled the PVC on 09-01) and the projection (proj_seq, the Flink-checkpoint analogue). cold_load is restore; replay-tail and a durable per-shard cursor do not appear to exist β€” the piece most likely to be underestimated, because the primitives existing makes it look done.

2026-09-08 (incident) β€” ⚠⚠ 55-minute bus outage from a manifest apply: proving one field and applying the whole object

What happened. Applying the reworked prod manifests (ops#301, #323) left noetl-cmdbus-writer-0 unschedulable for ~55 minutes:

FailedScheduling: persistentvolumeclaim "noetl-ehdb-tier-0-data" not found

That pod hosts both the command bus and the event bus, so every worker pool sat in EHDB claim connect failed and dispatch stopped.

Root cause. The manifest declared a fourth volume for a PVC that has never existed in prod. The live StatefulSet had three. The volume was cruft: the tier writes to /data/eventbus/ehdb-tier, a subdirectory inside the existing eventbus-data volume, so the storage was already there and the extra claim duplicated a path that needed nothing.

The two errors, both in the pre-apply reasoning

⚠⚠ 1. Proved one field, applied the whole object. The gate was a server-side dry-run across five workloads showing the image digest preserved β€” with a negative control proving the method could detect a change. Genuine evidence, about image. It was generalised to the object. kubectl diff against live would have shown the added volume outright, in the same second, at no cost.

A dry-run answers only the question you ask it. The gap between what you verified and what you changed is where the incident lives.

⚠ 2. Predicted a shape-only apply would be restart-free. Three of five workloads rolled. restartedAt and the watchdog annotations live under spec.template.metadata, so removing them changes the pod-template hash and rolls the workload by construction. The mechanism was visible; it was asserted away rather than checked.

Recovery

Owner patched the live StatefulSet to drop the phantom volume and mount. Writer scheduled and went 1/1 in ~50 s. The system pools showed CrashLoopBackOff with 11 and 17 restarts β€” not crashes: exitCode=0, SIGTERM received, liveness kills accumulated during the outage (the #322 pattern), with log timestamps predating the writer's recovery. They drained their backoff and reached 1/1 unaided within ~6 minutes.

No data lost, no rollback. All three real PVCs stayed Bound; all five image digests matched pre-apply exactly. Dispatch verified end to end afterwards (probe execution COMPLETED in ~18 s), 0 ERROR lines, 0 5xx.

What was institutionalised

  • agents/rules/apply-safety.md β€” a gate: diff the full rendered object against live before any prod apply, never the field you changed. Plus: if the diff touches spec.template at all, the workload rolls β€” state what will restart, and check it.
  • drift-audit.sh manifest-vs-live β€” flags anything declared in a prod manifest and absent from live (volume, claim, mount, container), and checks that claimed PVCs exist. Verified both ways: it fires on the pre-fix manifest with three findings naming the phantom volume, and passes clean on corrected main with 5 workloads compared. It also prints the rollout prediction, so error 2 is detected too.
  • ops#302 β€” main corrected to prod's actual three-volume shape, verified field-by-field with a YAML parse. Until it merged, anyone applying from main reproduced the outage.

2026-09-05 (close) β€” #326 closed: fixed in the writer that runs, validated on the live path

server v3.103.5. services::internal::project_events now persists parent_execution_id and worker_id β€” additive, no schema change; the emitter already published both and they survive into the JSONB through the envelope's flattened extra.

Which writer ran β€” measured, not assumed

events_projected_total    0 β†’ 60      (exactly the parent+child's 30 + 30)
events_materialized_total 0 β†’  0

Then on a fresh child: widened comparator match, 0 content divergences; fold-diff's own SELECT diff_paths: 0; parent clean as negative control. All workloads Ready, 0 restarts.

⚠⚠ The fix exposed a defect that had been invisible

First post-fix run reported authoritative="354748240001769472" ehdb=354748240001769472 β€” the same value in two spellings. My comparator stringified the i64 while the tier carries a JSON number, and it would have flagged every child as divergent from now on.

The absent values had hidden it: the field was never present on both sides at once, so the spelling never came up. A defect that only becomes visible once the upstream fix lands β€” fixed in v3.103.5, with the same accommodation ehdb_projection_fold already carried for worker_id.

The guard, widened to the property that matters

No INSERT into noetl.event may persist fewer columns than the row carries β€” mirroring or not. The old invariant was stated in terms of a behaviour the writer happens to have, and the live writer did not have it.

Two sites stay truncating as asserted exclusions (each must still truncate, so a stale entry cannot excuse a future offender): events_materialize (10 cols, not live) and ⚠ db/queries/event.rs::insert_event, which drops error and prev_event_id β€” the one-level chain link, so a row written there is not walkable. New finding β†’ #327.

⚠⚠ Which writer is reachable is a configuration question, not a code question. The first fix targeted two writers that genuinely had the defect but are not on prod's path; a counter settled it, not reading.

Scope

2,370 executions compared, 0 errors: still exactly 15 missing (14 real). The count did not grow across 18 new executions including three post-fix validation children β€” which is the evidence new children carry it. Backfill held.

#315 is unblocked for new executions; the 15 historical ones still read as unparented until backfilled.

2026-09-05 (deploy) β€” #326 fixed the wrong writer, and a counter is what said so

server v3.103.3 deployed, healthy, 0 restarts β€” and the validation failed to produce the column. Reporting that plainly, because the deploy itself was clean.

Validation

/api/execute accepts parent_execution_id, so a child was made cheaply rather than running the planner:

PARENT 354547251244179456
CHILD  354547308949413888
event 354547309033299968  parent_execution_id: authoritative=<absent>  ehdb=354547251244179456

Two independent readers agree it is absent from Postgres β€” the widened comparator and the fold-diff's own separate SELECT. The parent execution is a clean match with 0 content divergences: a proper negative control.

Why β€” the live writer is a third path

emit_events: "Gate-off does a single multi-row INSERT; gate-on publishes each row". Prod's bus is EHDB, so the direct INSERT never runs.

noetl_events_projected_total     60   ← exactly the parent+child's 30 + 30
noetl_events_materialized_total   0

That is services::internal::project_events, which lists 21 columns and omits parent_execution_id and worker_id. Filed as #327.

⚠⚠ The method lesson. I identified writers that could drop the column and treated the guard-covered ones as the ones that run. The guard's invariant β€” "a writer that mirrors must persist what it mirrors" β€” is sound, and the two sites it covers genuinely had the defect. They are simply not the path prod takes; project_events does not mirror, so it sits outside the invariant exactly as events_materialize does. Which writer is reachable is a question about configuration, not about code, and only a runtime counter answered it.

The invariant is too narrow. The real property is an INSERT should not persist fewer columns than the row it is given carries, mirroring or not β€” stated that way it points at project_events immediately.

v3.103.3 is still correct, additive and healthy; it closes the gate-off path and the in-tx command.claimed site. Not wasted β€” just not sufficient. Rollback (8a222c8f) staged and unnecessary.

Measured scope

All 2,352 executions compared, 0 errors:

missing parent_execution_id in Postgres: 15
  automation/agents/mcp/hotelbeds  10
  automation/agents/mcp/firestore   4
  test/simple_loop                  1   ← the validation child

14 real, one event each. A backfill is small β€” and #325's widened comparator is what verifies it, since it is what detects the condition at all.

2026-09-05 (end) β€” #326: it is loss, and it silently defeats a safety guard

Read-only. The residual #325 found turns out to matter more than its size suggested.

Where the column is dropped

noetl.event has three writers and they do not persist the same columns:

writer columns parent_execution_id
event_write::insert_rows 17 βœ…
events::handle_batch_events 12 ❌
internal::events_materialize 10 ❌

handle_batch_events also mirrors β€” and mirror_rows serialises the full EventRow. So it sends 17 fields to the tier and persists 12. The tier is not enriching; it is faithfully copying a row Postgres then truncates.

⚠ There is already a guard, every_in_tx_event_insert_is_mirrored, counting INSERT sites against mirror sites so a new writer cannot bypass the mirror. It checks that each INSERT mirrors β€” nothing checks it persists the same columns the mirror sends. That is the gap this fell through.

⚠⚠ It is read, and one reader is a safety guard

Not latent: 8 SELECTs in db/queries/event.rs, the projection fold (:173/214/377/931, compared at :1485), the orchestrator sets it when spawning children β€” and nonconvergence_sweep.rs:556 uses it for skipped_parent_active:

-- `parent_execution_id` is carried on the child's own events, so the parent id
-- comes from there

The comment states an assumption the data does not satisfy. NULL column β‡’ subselect yields nothing β‡’ EXISTS false β‡’ active false. The guard fails in the unsafe direction: a child with a live parent reads as having no active parent and becomes eligible for termination.

That lands squarely on the pending decision to arm the sweep for the 100 stuck executions. Those 100 are test/simple_loop (top-level, not at risk), but the affected children are automation/agents/mcp/* β€” real agent work. Cross-linked to #315.

Fix shape

Surfaced, not built β€” persisting the missing columns is a write-path change (narrow: values already on the row, column already exists, no schema change). Inert and offered: a guard asserting every INSERT site persists the column set the mirror serialises β€” the check that would have caught this.

Scope unmeasured: 5 of 200 sampled, but the affected population is child executions specifically, so the per-child rate is likely far higher.

2026-09-05 (later still) β€” 15% β†’ 2.5%: two of the three classes were representation, not divergence

server v3.103.2, still inert. Same sweep each time (newest 200, narrow vs widened, 0 errors):

build flips fields
v3.103.0 30 / 200 (15.0%) worker_id 762, context 622, parent_execution_id 5
v3.103.1 29 / 200 (14.5%) worker_id 757, parent_execution_id 5
v3.103.2 5 / 200 (2.5%) parent_execution_id 5
  • context β€” the PERMANENT_LOG_LEAN externalisation: authoritative holds {"__context_ref__":…,"__context_bytes__":…}, the tier the inlined object. The same reference-vs-inlined class as result, in a different spelling, which is why the first collapse missed it.
  • worker_id β€” a location difference. The column is often NULL while meta->>'worker_id' carries the value and the mirror copies it top-level. Read off a prod record, top-level and meta.worker_id are the same string. A missing value that was never missing.

⚠⚠ I had to correct my own previous conclusion

I reported that the fold's differ already classified all six field diffs as benign, because input_event_diffs_post_normalisation was 0. It is not that: that list compares created_at only β€” (a.created_at != b.created_at).then(|| …) β€” so it is 0 by construction whenever timestamps agree. normalise_event_precision truncates timestamps and nothing else. No existing normalisation reconciles those fields. I read a narrow check's zero as a broad claim, which is the exact shape representation-drift.md exists to catch.

The residual is real β†’ #326

All 5 are playbook_started on a child execution, where the tier holds a parent_execution_id and noetl.event does not β€” and it is not in meta or context, so it is not another location difference. The system of record knows less than its mirror, and it is the first genuine thing the widened comparator has found.

Still held: flag off, alert untouched. 2.5% is not zero, so enabling still needs flag + alert to move together.

⚠ Build note: publish-image (arm64) failed and publish-manifest was skipped on this release, so GHCR has no multi-arch image β€” but publish-ar (Cloud Build) succeeded independently. The deployed AR image was verified architecture: amd64 against amd64 prod nodes before rolling, rather than assumed.

2026-09-05 (late) β€” the widened comparator, measured: HOLD at 15%, and none of it is corruption

server v3.103.0 deployed inert. It adds ?content=true on the on-demand parity route so the widened comparison can be measured without arming the sampler β€” the env flag arms the process, the process includes the sampler, the sampler records, and recording is what pages. There was no way to find out what enabling would surface without first paging on it; now there is.

The number

Newest 200 executions, compared twice each, 0 errors:

narrow  : match 53   divergent 146
widened : match 23   divergent 176
MATCH -> DIVERGENT: 30 of 200 = 15.0%
fields: worker_id 762   context 622   parent_execution_id 5

βœ… The collapse works β€” ⚠ and it is not the whole story

result never appears, so the reference-vs-inlined collapse does its job. But three other classes do, and none is corruption:

  • context β€” authoritative holds {"__context_ref__":"noetl://…/__command_context__/…","__context_bytes__":1011} against the tier's inlined content. The same pattern under different key names β€” the NOETL_PERMANENT_LOG_LEAN externalisation, which the collapse does not recognise.
  • worker_id, parent_execution_id β€” authoritative absent, tier populated.

The proof it is benign is a measurement, not a hope: the fold's own differ reports exactly these six field diffs on the same executions and then input_event_diffs_post_normalisation: 0. The platform already classifies them as equivalent; the comparator merely lacks that normalisation.

So the two oracles disagree in a second way β€” not just narrow-vs-wide, but different normalisation. Enabling today would page on 15% of executions over differences the system already knows are equivalent, which is the exact outcome the flag exists to prevent. Held. Alert untouched.

Fix before it can be enabled: teach the collapse __context_ref__; port normalise_null_json / normalise_event_precision; re-run this sweep.

#315 β€” already armed; my note was wrong

NOETL_RECONCILE_MAX_NOOPS is unset, so the default 225 applies (225 Γ— 8 s β‰ˆ 30 min). ⚠ My earlier "the reconcile bound is OFF on prod" conflated it with NOETL_NONCONVERGENCE_SWEEP_ENABLED, which is a different, execution-terminating thing and stays off. noetl_reconcile_giveup_total{max_noops} is present and 0 β€” present matters as much as the value.

The waste shape is not recurring: 8 drive dispatches in ~3 h. There are 100 non-terminal executions up to 97 h old, and they are not the #235 frozen-column artefact β€” sampled ones have 3 events, last command.issued, last activity 2026-09-01. Inert residue of the truncate incident, not an active loop. Clearing them is the nonconvergence sweep's job, which terminates executions; not enabled under discretion.

Closed

#313, #314.

2026-09-05 (night) β€” the comparator now compares content, behind a flag that is OFF

server v3.102.0, merged inert. Not enabled anywhere.

#325 built: MirroredEvent deserialised five fields and payload_divergence compared three, so match meant "the identifying projection agrees", not "the tier can stand in for Postgres" β€” and the comparator, not the fold, is what feeds the paging alert.

NOETL_EHDB_CROSSSTORE_PARITY_CONTENT (default off) widens it to context, result, meta, error, worker_id, parent_execution_id. Off, every verdict is byte-identical and the widened columns are not even SELECTed β€” an unconditionally-widened query would put whole tool payloads on the wire every parity tick.

The correctness core, and why the negative control is the load-bearing one

Postgres stores a result as a reference where the tier stores the inlined context, systematically. Comparing those raw would report a divergence on essentially every execution and mean nothing. collapse_result_representation collapses an object carrying exactly one of the two to a marker β€” and leaves an object carrying both alone, because that is not a representation choice. null / absent / {} / [] all agree, because the authoritative column is a SQL NULL while the mirrored payload carries {}.

⚠ The collapse asserts the two forms are equivalent, not equal β€” the pointer is never dereferenced. A tier whose inlined payload does not match what its reference resolves to is still invisible.

Two controls, content and result_representation, both driven through compare_cross_store_with_content, which forces the comparison on regardless of the env β€” reading the flag there would make the controls silently vacuous on exactly the deployments that have not armed it. Five mutations, each caught.

⚠⚠ Enabling is not merging. Arming the flag reclassifies a large share of today's match as divergent and pages on a condition that has been true for as long as the mirror has existed. Procedure lands in the same change set: playbooks/325-content-parity/ALERT-RETUNE.md β€” measure with the Inspect-only route first, re-point the alert off kind="content", then arm the sampler. #325 stays open until that has run; the Closes keyword auto-closed it on merge and it was reopened.

Closed

#314 on its stated goal β€” iteration_command_ids is already a BTreeSet. #313 β€” the duplicate mechanism is structurally prevented by the tier's event_id dedupe, with the 8 surplus events out of 60,673 written down rather than waved away as the reopen trigger.

2026-09-05 (evening) β€” which parity oracle to trust, and why the other is blind

Read-only. The answer is the fold, and the comparator is not wrong β€” it is narrow by construction, which is worse, because its verdict reads like a guarantee.

The mechanism

MirroredEvent (ehdb_parity.rs:188) deserialises five fields; payload_divergence compares three β€” event_type, node_name/step, status. It never parses context, result, meta, error, worker_id or parent_execution_id. So a match verdict means "the identifying projection agrees", not "the tier can stand in for Postgres".

What actually differs, and it is systematic

GET /api/ehdb/projection-fold/diff/{id}?source=tier β€” a parameter that exists precisely because the WAL differ could not diagnose this β€” returns the same shape on every disagreeing execution:

/steps/<step>/result/context/result/context    (only in tier)
/steps/<step>/result/context/result/reference  (only in postgres)

Execution 354320837223915520 shows it across four steps. Reading the tier record directly confirms it: the tier holds the inlined payload (content, control_data, …) where Postgres holds a reference. build_result_object (events.rs:1560) selects between the two by result_kind, so the stores are recording one logical result in two representations.

⚠ input_event_diffs_post_normalisation is 0 β€” the differ's own normalisation makes the inputs equal while the folded states still differ, so the raw-input diff understates it. The fold is what surfaces this at all.

Why it matters

The comparator is the weaker oracle and it is the one wired to the paging alert β€” a tier could diverge in every payload and still report match everywhere. The event-log tier is primary-serving, so a tier-served read returns inlined content where Postgres returns a pointer. And it defeats NOETL_PERMANENT_LOG_LEAN=true inside the tier, which is retaining exactly the payloads that policy exists to keep out.

Filed as #325. The inert fix β€” widen the comparator to cover content β€” is proposed with the caveat that it converts a large share of match into divergent and would page on a condition that has been true all along, so it needs a flag or a re-tuned alert in the same change set. Whether the mirror should instead capture the post-substitution row is a design question about what the tier is for: surfaced, not proposed.

Also

MEMORY.md compacted 24,900 β†’ 19,973 bytes (5,013 headroom, was 86). All 61 topic links preserved, zero dropped, zero broken; long lines 10 β†’ 3; prod state and the owner-gated list refreshed against the cluster rather than copied.

2026-09-05 (later) β€” #321 closed, and #314's premise corrected before building the wrong thing

⚠⚠ #314: the stated cause was already fixed, and my own earlier comment was wrong

iteration_command_ids is already a BTreeSet (orchestrate-core/src/state.rs:198, changed by server#388), so the order-sensitivity this issue was filed for no longer exists.

Worse, I had commented on #314 that the ~951 order-only executions from the cross-store sweep were "the same shape as this issue". They are not, and that error propagated into a plan to fix them by canonicalising iteration_command_ids. Classifying all 1,325 order occurrences by their detail string across the 2,138-execution sweep:

A: "tier records were not returned in ascending global_sequence order"      0
B: "shared events differ in order β€” tier […] vs authoritative […]"       1325

Zero type A. All type B β€” the tier genuinely holds shared events in a different relative order than the authoritative log, most likely via the mirror queue's own documented rung 3 (queue_full_inline reached 404 in one load run). Nothing to do with a set field inside WorkflowState.

⚠⚠ And the live residual is a third thing again. The fold equivalence on prod reports 9 disagreed of 39 comparable, n=14 vs n=14 β€” same version, same event count, different digest, disagreements_explained_by_missing_context = 0 β€” while the cross-store comparator calls one of those same executions a match with zero payload divergences. Two parity surfaces disagree with each other, and ordering is already handled on the tier side (ehdb_projection_fold.rs:346 and :699 both sort by event_id). That is a real investigation and it should not inherit #314's now-false title.

βœ… #321 closed β€” after the premise needed correcting

"Nothing reads it" was true of the worker and false in general. The server reads NOETL_OFFSERVER_ATTACH_TAIL through envy into AppConfig.offserver_attach_tail β€” no string literal exists to grep, which is exactly why it looked unread β€” with four readers on the hot path. A blanket delete would have removed a live server flag, and the flag it imitates is the #156 tail-attach flag, deliberately off because turning it on broke auth.

Four worker-side declarations removed (two per manifest β€” it had been duplicated into the wait-for-api initContainer), server declaration kept, live env aligned, both pools rolled and healthy. Inert: the removed value was false, which is also the serde default.

#300 β€” verified, surfaced, not touched

POSTGRES_PASSWORD is unbound β€” and so is every other key in noetl-secret, and so is the Secret itself: zero secretKeyRef/envFrom bindings across every workload in the namespace, consistent with the Secret Manager cutover.

Not acted on: NOETL_ENCRYPTION_KEY sits in the same object, and #309 already records that a DR apply would replace the live encryption key. "Unbound" is not "unused" β€” a key nothing binds today may still be the only surviving copy of something that decrypts existing data. Disposition belongs with #309, as a whole-object decision, after confirming the key exists and matches in Secret Manager.

2026-09-05 β€” the residuals: IaC declared, #322 re-diagnosed, the append timeout split

Follow-on to the #320 close. Three residuals worked, one of them corrected.

#267 β€” six live env vars that no manifest declared

ehdb-env-drift-check.sh (which prints its own positive control) found six variables live on prod and declared nowhere, i.e. a DR re-apply would silently remove them. The dangerous one: NOETL_EHDB_EVENTLOG_MIRROR_DRAIN_CONCURRENCY=1 β€” losing it reverts to the code default 8, the configuration that produced ~99% relay delivery failures. Next worst: NOETL_EHDB_SEAL_MAX_AGE_MS=5000, which has an alert on the seal trigger not firing, so losing it disarms the behaviour and the alert watching it.

Declared in ops#299, verified both ways: the LIVE but NOT declared class is now empty and three of four workloads report agree. Placement was checked structurally β€” the first attempt put one value into the wait-for-api initContainer, which a by-eye review would have missed.

⚠⚠ The manifests are still not safe to apply. Every image pin is behind prod: server v3.90.0 vs v3.101.3, writer v5.122.2 vs v5.131.0. Applying would roll back past the #320 fix and past v5.129.1 β€” reintroducing the unbounded cmdbus manifest growth that caused an 8-hour silent dispatch outage. Filed as #323.

⚠ #322 β€” my own "14Γ—/day liveness kills" was wrong

Corrected by separating the classes over all 50 lifecycle events: of 15 Killing events, 5 were preceded by Unhealthy on the same pod (genuine liveness), 10 were rollouts or evictions. Three causes, only one a probe issue:

  1. 5 real liveness kills β€” and the probe is not misconfigured in the obvious way. I expected the classic "idle pool looks wedged" bug; claim_loop_wedged is deliberately keyed on failures, not claim recency, with the reasoning written out at metrics.rs:2732. That trap was already found and fixed.
  2. Rollouts β€” noetl-state-builder-watchdog (a 3-minute CronJob with cooldown + flap-stop + MAX_ATTEMPTS) doing designed remediation, plus deployments.patch by [email protected] from outside this session.
  3. ⚠ Preemption under capacity pressure β€” shard1 shows Preempted + 24 FailedScheduling. That is capacity (#318), not configuration. ⚠ Found only after noticing --limit is a cap: the first query fetched 1000 events that excluded shard1 entirely.

Still real and surfaced, not applied: readiness is a tcpSocket probe while liveness is HTTP, so a listening-but-wedged pod stays Ready and keeps receiving mirror traffic for the full 90 s before the kill.

#324 β€” one 2 s timeout served reads and appends

Its rationale called this "an auxiliary verification path" that must not add "latency on the caller's hot path". Both premises are stale: NOETL_EHDB_EVENTLOG=primary makes the tier primary-serving, and the mirror has been async since #155. The constant outlived its reasoning.

The two have opposite risk β€” a slow read should give up fast; a slow append that gives up early means the record was not written. Split in worker#310 (v5.131.1, deployed to the three Deployments; the writer StatefulSet deliberately not rolled). Four mutations, each caught; the behavioural gates measure on the wire against a listener that accepts and never replies.

⚠⚠ Loss is reduced, not eliminated

Over the 11 hours after v3.101.3: 35 events short across 2 real muno/itinerary-planner executions at 17:44, with no pod churn in the window β€” the writer-slowness class, which is exactly what v5.131.1 targets. Repaired 35 β†’ 0, idempotent (already_complete / repaired: 0).

So the floor is: 63 executions, 1663 mirror-expected, 2 affected, now 0 short β€” but a sink outage longer than the retry window still loses events, and the single-endpoint relay makes such outages routine. That part is #322 / #318 and owner-gated.

Audit findings closed against itself

drift-audit.sh reported two gaps this session created or exposed: the new attempt-label set read NO-GUARD (pinned_sets.py cannot read a computed label β€” reaching for "the next quoted string" yields dropped, a terminal outcome, which the first draft of the replacement guard did), and the env-docs check demanded the spec document two NOETL_TEST_* fixtures. Both closed; the fixture filter prints its count, because an earlier attempt at that same filter had a syntax error that made the comparison emit nothing while printing OK.

2026-09-04 (evening) β€” #320 closed: the mirror stops losing events, and the 16 real ones are back

Three releases and a repair. The clean floor the session was aimed at.

Root cause was two mechanisms, and only one was the obvious one

  1. No retry. deliver POSTed once and returned () β€” structurally unable to report failure β€” and all three failure arms ended in a warn! and a discard. Concurrency was never the mechanism, only a rate multiplier: measured over six hours with the queue completely idle (enqueued 144 = drained 144, queue_full_inline 0), 9 of 144 batches β€” 6.25% β€” were dropped.
  2. ⚠⚠ Stale pooled connections. relay_client() was reqwest::Client::new(), which reuses idle keep-alive connections for 90 s. The relay target is a ClusterIP Service in front of a single pod killed by liveness several times a day (#322), and mirror traffic is bursty β€” so the pooled socket from the last burst often points at a pod that no longer exists, and every retry draws another dead one from the same pool. Retry alone could not fix it: after v3.101.1 a batch still dropped with all five attempts failing inside four seconds against a pool Ready for 100 minutes. This is what finally explained the 2026-09-03 muno losses, which happened at light load with no pod churn at all β€” a window the earlier diagnosis had to report as unpinned.
  3. A third, upstream: under load the worker's tier append returns 502 … "timed out after 2s" (serve_state=no_durable_service). Absorbed by the retry window, not fixed.

The observability half β€” the recurring defect class, closed

dropped is now a terminal counter meaning these events will never reach the tier. It had to exist because discards were filed under unavailable, and none of the 18 prod alert policies referenced it: all four mirror alerts watch ..._queue_total β€” the enqueue ladder whose own module header says "never a drop" β€” and not the delivery step that drops. The queue read perfectly healthy for days while events were lost. Drops now log at error with the event_ids, so a loss is repairable rather than only discoverable by diffing both stores.

Validation

arm result
pre-fix baseline 6.25% of batches dropped, at idle
v3.101.1, realistic load 11 dropped, all the 502 timed-out class; zero transport drops
v3.101.3, code defaults, no override 42 executions, 1202 mirror-expected, 0 events short, 131 recovered

The 131 recoveries are the point: the retry path was exercised, not idle β€” a clean run with no failures would have proven nothing.

⚠ The widened window trades against drain throughput. With DRAIN_CONCURRENCY=1, a batch retrying for 80 s blocks the drain for 80 s: a 42-execution burst backed the queue to 446 batches and pushed 404 to inline delivery (rung 3, i.e. back onto the request path). It drained fully with zero loss β€” but I first read that backlog as a 468-event regression before checking pending_events. Widening further without raising drain concurrency would make it worse.

⚠ Defaults were raised to the measured values (7 / 500 ms) rather than left below what prod ran by hand β€” a default quietly worse than the deployed value is the #267 shape, where a DR re-apply silently reinstates a losing config.

The 16 real events are repaired

354018056919326720  32/27 short 5  muno/hotel-cards      ->  32/32  short 0
354008288930570240  32/30 short 2  muno/hotel-cards      ->  32/32  short 0
354005901364961280  32/30 short 2  muno/hotel-cards      ->  32/32  short 0
353750965720981504  14/ 7 short 7  system/scheduled_...  ->  14/14  short 0
TOTAL 16                                                 ->  TOTAL  0

Zero missing_event remains; the surviving order divergences are the #314 class. A second call returns already_complete / repaired: 0. The ~5,409 synthetic test/simple_loop events were deliberately left. ⚠ With the async mirror the endpoint's own after-count is taken before the queue drains, so it honestly reported partial on a repair that completed seconds later β€” the outcome being keyed on the post-repair missing count is what made that visible instead of a false success.

Still open: #322 (the liveness kills that trigger it), the writer's 2 s append timeout under load, and #267 (neither retry var is declared in any ops manifest).

2026-09-04 (later) β€” the deficit widened past the cap: 5,425, and #320 is mitigated, not fixed

The cap was not a cap. GET /api/ehdb/executions accepts offset, so the whole population is reachable read-only. ⚠ It serves 100 per page even when limit=1000 β€” the body's limit echoes the request, not what was served β€” so a loop that stops on a short page stops at 100. That is the entire reason the morning's figure was a bound.

Swept the real comparator over all 2,138 executions: 434 s serial, 0 errors, every row controls_ok=true / metrics_recorded=false / tier_query_source=service. Safe to sweep only because #264 was fixed on that route β€” otherwise 2,138 probes would have inflated the divergence counter they measure.

authoritative_expected  60673      ehdb_count  55256
pending_tier (STRUCTURAL)    0      surplus          8
DEFICIT  5425 events / 548 executions   (25.6% of executions, 8.94% of events)

Self-consistency: the newest-100 slice reads 316/51 today vs the 318/52 reported this morning β€” same window, same answer.

The decomposition is the finding

class execs events short
clean 631 0
order ONLY (complete, mis-ordered) 951 0
missing_event (real loss) 541 5412
extra_event 8 0
missing_execution 7 13
  • 99.7% of the loss is test/simple_loop β€” synthetic load traffic. Non-synthetic loss is 16 events across 4 executions (three muno/playbooks/hotel-cards, one system/scheduled_cleanup). Sizing a repair off 5,425 overstates real exposure by ~340Γ—.
  • 951 order-only executions are 100% complete and 100% test/simple_loop, spread evenly across all three days β€” the #314 shape, corroborated on a second independent surface. They are what makes the raw "1,507 divergent" look alarming: any gate keyed on divergent is dominated by ordering, not loss (2.75Γ— false-signal on this population).

⚠⚠ The mitigation cut the volume, not the mechanism

DRAIN_CONCURRENCY=1 is live (rolled 2026-09-03T19:28Z). After that cut, 3 of 9 muno executions on prod lost events, and a run today at 11:22 lost 6 of 30. #320 should not be closed on the mitigation β€” the 04:00 load hour was 422/422 executions affected (4,674 events), but the residue outside it is still happening. n=38 post-cut, so the rate is not estimable; the existence is.

Correction

The morning reading attributed 6 events to 09-04; that execution is clean now, so it was in-flight lag, not loss β€” the "structural gap: 0" was slightly optimistic. With a 30 s tolerance and an async mirror, sweep executions that are hours old, not minutes. The 318/52 headline was unaffected.

Also

MEMORY.md compacted 28.7 KB β†’ 23.0 KB (it was truncating on load at a 24.4 KB limit); all 58 topic links preserved, 1 added, 0 broken.

Held: the repair run stays owner-gated β€” it is a write into the primary-serving tier. The endpoint (v3.101.0) remains inert and unrun; prod stays on v3.100.4.

2026-09-04 β€” the deficit is 318, not ~12k, and the repair tool is inert

Two things were asked for and both are done, read-only and inert respectively.

1. The measurement β€” a real number replaces a quoted one

The figure in circulation was "~12k dropped records". That number came from failed batch deliveries during the #320 window, which is not a completeness measurement β€” a delivery can fail and be retried, and a batch is not an event. Measured against the cross-store comparator instead, over the 100 executions the parity API exposes:

mirror-expected events:   2432
tier holds:               2114
STRUCTURAL gap:           0      <- measured, not assumed
REAL DEFICIT:             318
executions affected:      52 of 100 (52%)
executions with SURPLUS:  0      <- no duplicates
by day:   2026-09-03: 51 executions / 312 events
          2026-09-04:  1 execution /   6 events
04:36 cluster (#320 load-run window): 48 executions / 303 events

The separation the owner asked for holds cleanly: structural mirror lag is 0 in this window, so the entire 318 is real loss rather than in-flight lateness. ~95% (303/318) sits inside the #320 load-run window, so the loss is #320-attributable rather than ordinary operation.

⚠ 318 is exact for the visible window and a lower bound overall. The parity API caps at 100 executions regardless of limit, and the affected set cannot be enumerated from logs β€” the server pod is 4h56m old and the #320 window predates it. Anyone quoting a total should quote this bound, not extrapolate from it.

2. The repair endpoint β€” built, gated, merged, not run

POST /api/ehdb/repair/executions/{execution_id} (server#399, v3.101.0) re-mirrors only the event IDs the comparator reports absent. CrossStoreReport gains missing_event_ids (skip_serializing_if empty, so the wire shape is unchanged when nothing is missing) β€” that list is the endpoint's input.

Four properties, each held by a gate that a mutation breaks. All four mutations were applied, verified to fail the intended gate, then reverted:

property mutation gate that caught it
scoped re-mirror the whole execution scoping gate
non-inflating ParityRecording::Record instead of Inspect inspect gate
honest subtract unrecoverable from missing unrecoverable gate
honest key the outcome on rows delivered after-count gate

⚠⚠ The non-inflating property is the one worth carrying forward. #264 already recorded that the parity endpoint writes the counter its own paging alert reads, so investigating a divergence inflates it. A repair endpoint that compared under Record would do that twice β€” the divergence counter would climb during a successful repair, and the operator watching the alert would read the fix as the problem getting worse.

Suite: 1040 passed, 0 failed; clippy 0 errors; CI green; merged as v3.101.0. Inert: new route, no caller, no sampler, no change to any existing path. Not run against prod. Prod stays on v3.100.4 (digest 03cecf3f, imageID-confirmed on the running pod).

Traps hit this session

  • ⚠⚠ An in-pod /metrics probe reported build_info lines=0 on four separate ports. All four zeros were false: curl is not in the container. kubectl exec … curl fails with "executable file not found", and piping that into grep -c yields a confident 0 that reads exactly like "the metric is absent". Only the positive control (-w http=%{http_code}) surfaced the real error. The prod version was resolved from the Artifact Registry digest instead.
  • ⚠ rustfmt given a path recurses into newly-declared modules; given stdin it cannot. A fmt baseline that pipes git show HEAD:file and compares against rustfmt --check file is not like-for-like β€” mod.rs jumped 28 β†’ 1019 diff lines purely from adding one pub mod line. The measurement was wrong, not the code.

Held / owner-gated (unchanged)

Tier re-aim backfill run, further worker rolls beyond those done, the cutover flip, C5 election, #332 migration, quorum / replication / double-writes, system-pool autoscaler enable, further Cloud SQL changes, IAM / rotation, and the pgbouncer duplicate-identity fix.

Next decision for the owner: whether to repair the measured 318 (52 executions), and whether to widen the measurement past the API's 100-execution cap first.

Pointers: server#399 Β· ai-meta@a42b823 (gitlink-only bump to a0d4278) Β· server wiki deployment-specification Β· #313 Β· #320

2026-09-03 β€” the tail was the mirror, and my published diagnosis was wrong

Headline: server v3.100.4 is LIVE on prod. The p95 tail and the run-to-run bistability on POST /api/execute were the event-log mirror falling back to inline delivery β€” not the command-publish retry budget I had named in #319 Β§4.

⚠⚠ The correction, first, because it is the durable part

Β§4 identified the 10 s publish retry as the tail. It was assembled from constants read in source β€” a 10 s deadline, 32 attempts, an exponential backoff. That supports a claim about what could happen; it was published as what was happening, with no instrument that could contradict it.

server#393 shipped the fix (v3.100.3) and the counter it added read:

noetl_ehdb_command_publish_deferred_total 0

Zero, across a full load run β€” the publish always succeeded inside 500 ms β€” and p95 stayed at 14.9 s.

A hazard visible in the source is not the cost visible in production. Build the instrument before publishing the conclusion. server#393 stays as a correct defensive change that fixes nothing that was happening.

What it actually was

emit_mirror measured 1.68 s mean on the request path; everything else was noise (emit_publish 50 ms, emit_should_publish 2 Β΅s). MIRROR_ASYNC=true was set and the queue was armed β€” and still:

queue_total{outcome="enqueued"}             2463
queue_total{outcome="enqueued_after_wait"}   327
queue_total{outcome="queue_full_inline"}     908    <- 24.6%

A quarter of all batches went back onto the request path, at up to 10 s each (5 s enqueue timeout + 5 s POST), three events per request. Mean queue lag 170 s; only 56 of 2,790 batches drained inside 10 s.

deliver_pass coalesced per execution and then POSTed strictly one at a time β€” throughput 1 / relay-round-trip regardless of how many independent executions waited, while its own comment already said different executions are independent. It explains the bistability better than Β§5 did: queue fills β†’ submits go inline β†’ requests slow β†’ more in flight β†’ queue stays full.

What landed

PR what release
server#393 P3 β€” on-path publish bounded to 500 ms, background continuation carries the remaining deadline v3.100.3
server#394 P2 β€” four reads of one noetl.catalog row collapsed to one v3.100.4
server#395 P0 β€” mirror drain delivers independent executions concurrently v3.100.4
  • Ordering is safe by the queue's shape, not by assuming order does not matter: a pass holds at most one batch per execution_id and passes do not overlap. NOETL_EHDB_EVENTLOG_MIRROR_DRAIN_CONCURRENCY=1 is the rollback.
  • P2 introduced no cache β€” the row is read fresh every request, so a re-registered version is picked up on the next one. A negative control fails the suite if content is ever retained.

Validation

v3.100.3 v3.100.4
queue_full_inline 908 (24.6%) 88 (2.8%)
emit_mirror mean 1.68 s 0.38 s
mirror lag mean 170 s 24 s
rig c=1 p50 / p95 510 ms / 14,910 ms 120–150 ms / 2,820 and 12,050 ms
rig c=2 p50, ok/err 5,730 ms, 17/2 190 ms, 47/0

Mutations, each verified to fail the intended guard: reordering without coalescing (the existing end-to-end ordering test), a silently-serial spawn loop, ignoring the concurrency env (the negative control), a re-added snapshot catalog read, a request-spelled system-path memo, and an accidental catalog read-cutover.

Live freshness control on prod: 25/25 recent executions resolved test/simple_loop to the highest of its four versions.

⚠⚠ The bistability is NOT gone, and my "after" p95 was one rep

Full clean curve, 1/2/4/6/8/10 Γ— 2 reps, nothing probing the server:

rep conc submit ok  err p50ms  p95ms  sched
1   1    46     46  0   120    2820   5s
1   2    46     46  0   140    11010  6s
1   4    20     8   12  30040  30080  6s
1   6    0      0   0   -      -      64s   <- SKIP, no generator scheduled
1   8    91     81  10  3430   30040  49s
1   10   36     7   29  30040  30050  54s
2   1    19     19  0   150    12050  6s
2   2    35     34  1   150    8000   6s
2   4    32     20  12  7550   30150  6s
2   6    18     0   18  30030  30110  6s
2   8    24     0   24  30040  30210  5s
2   10   102    84  18  7230   30030  49s

c=8 goes 81 ok β†’ 0 ok and c=10 goes 7 ok β†’ 84 ok between two runs of the same configuration. That is the original cliff, not a softened one.

I had reported p95 14,910 β†’ 3,070 ms at c=1 from a single rep; the repeat read 12,050 ms. One sample of a bimodal distribution, published as the distribution β€” the same error as Β§4, one level down. p50 is the part that is genuinely reproducible (510 β†’ 120/150 ms at c=1; 5,730 β†’ 140/150 ms at c=2).

Run-wide deltas: inline fallback 24.6% β†’ 8.3%, emit_mirror 1.68 β†’ 0.78 s, lag 170 β†’ 67 s, publish_deferred +0. Everything ~2-3Γ— better; nothing eliminated. The queue was still pinned at 512 ten minutes after the load stopped.

So the tail is now cleanly P1-bound. More drain concurrency cannot help β€” 8 parallel POSTs still land behind one writer taking ~4 ms fsync per append.

⚠⚠⚠ And then the improvement turned out to be partly an artifact

Checking the mirror's success counters after the run:

v3.100.3 (serial)         mirrored=3541  unavailable=70    degraded=84     ->  4.2% fail
v3.100.4 (concurrency 8)  mirrored=101   unavailable=10087 degraded=1882   -> 99.2% fail

The relay is a single-pod headless service and does not tolerate 8 concurrent POSTs; a direct probe from the server pod succeeds instantly, so it is the concurrency rather than the relay being down.

This is why the drain looked fast. queue_full_inline 0 and emit_mirror 0.000 s read as the fix working; the drain was fast because deliveries were erroring out quickly. A failure path faster than the success path flatters every latency metric that measures it β€” and there was no success-rate series beside the latency one, so an improvement and a fault were indistinguishable.

⚠ Compounding it: deliver handles a relay error by logging and dropping the batch β€” no retry, no re-queue. Pre-existing, but this moved it from 4% to 99%, so roughly 12,000 batch deliveries were lost from the tier in that window. That matters for anything reading the tier as an oracle ([#258], [#263], [#313], [#314]).

Filed as #320. Not an active incident β€” prod idle, queue depth 0, zero deliveries in 60 s, and the tier is not serving reads on this server. Mitigation is NOETL_EHDB_EVENTLOG_MIRROR_DRAIN_CONCURRENCY=1 (keeps server#394) or an image rollback to v3.100.3; applying it needs an operator β€” the change was blocked for this session.

⚠ Two rig defects surfaced: a SKIP row reads like a collapse (0 0 0 with - latencies is the only tell), and levels run back-to-back with no drain interval, so each inherits the last one's backlog β€” the two best rows both had ~50 s of Autopilot scheduling in front of them, which is free drain time.

⚠ Two traps I walked into

  • I perturbed my own measurement. Pulling a 75 MB catalog listing off the server pod mid-run collapsed that run's c=4 level. Discarded and re-run clean, touching nothing during the run β€” the harness rig exists to stop exactly this, and the probe was outside it.
  • cargo fmt -- <paths> formats the whole workspace, not those paths. It rewrote main.rs into a shape auth_gate_wiring cannot parse β€” the same failure as server#390. Reverted; touched files were checked against their rustfmt baselines instead, which were already non-zero.
  • Two new scan idioms were wrong on first run and said so loudly: a doc comment counted as a SQL query, and &'static str matched a search for retained static state.

Still bounded

P1 β€” the ~4 ms per-append fsync β€” is parked behind ehdb#322. The drain cannot outrun the writer it feeds, so the queue still saturates under sustained load and ~2.8% of batches go inline at higher concurrency. DRAIN_MAX_BATCHES (more records per POST, fewer fsyncs) is the likelier next lever than more concurrency, but it risks the tier's page cap and wants measuring first.

Pointers: ai-meta@baae005 (gitlink-only) Β· server@8b39283 (v3.100.4) Β· noetl-server-wiki@72f7696 Β· Umbrella: Request latency

2026-09-01 (evening) β€” the writer is rolled; the leak is closed on prod

Owner lifted the hold for the rollout. statefulset/noetl-cmdbus-writer moved to @sha256:a6e578c6… (v5.129.1, verified pullable HTTP 200 before rolling β€” never roll to a tag or an unbuilt digest).

before after
running version 5.129.0 5.129.1
manifest snapshots 172 32
manifest bytes 870.9 MB 168.8 MB
/data/cmdbus used 943.8 MB 242.3 MB
available 18.6 GB 19.3 GB
manifest_versions_pruned β€” 188
manifest_versions_retained β€” 32 (the configured bound)
ingest_append_failed absent present, 0

Space was reclaimed, not merely held. drift-audit.sh volumes went from 2 DRIFT to all OK β€” the same mechanical check, before and after.

Bonus: the eventbus L0 engine pruned too (manifest 356 MB β†’ 28 MB). Both engines in the writer process share the retention policy.

⚠⚠ Pruning is not visible immediately after a roll and that is not a defect. At 1 append it read pruned 0 / retained 0 and the snapshot count had risen 172 β†’ 173. The sweep runs once per retain manifest writes, so it had not fired. A 120-execution burst (108/120 at p50 277 ms, no 500s) drove it, and pruning completed. Drive traffic before concluding the fix is inert. Note also that retained is only written by the sweep, so until the first sweep it reads 0 β€” indistinguishable from "0 retained".

⚠ The burst's backlog briefly took system-pool lag to 107 and it grew across three samples before draining. That reads exactly like the #315 re-drive loop; it was not. Only 2 re-drives in 5 minutes, both old pre-truncate execution ids. It resolved on its own: 100/100 executions terminal, lag 0.

Dispatch verified before and after the roll. Pod Ready, 0 restarts, all listeners bound, 0 errors. Rollback to @sha256:1c7d5387… was staged throughout and not needed.

2026-09-01 (later) β€” worker v5.129.1 published; the writer is NOT yet running it

The owner lifted the publish hold for worker#304 only. Merged as 5e8d006; semantic-release produced v5.129.1 (fix(ehdb): β†’ patch), verify-version passed, and all seven release.yml jobs succeeded including publish-ar.

Verified rather than assumed: the tag contains the merge commit, the ehdb pin inside the tagged build is 0980183 (both ehdb#346 and ehdb#347), and 11 metric render sites are present. The AR image @sha256:a6e578c623dce… is linux/amd64, matching the prod nodes β€” and the current v5.129.0 is single-arch too, so that is the established AR pattern rather than a defect (the multi-arch index lives on GHCR).

⚠⚠ Publishing changed nothing on the running system. The writer StatefulSet is pinned by digest to @sha256:1c7d5387… and still reports noetl_worker_build_info{version="5.129.0"}. The pruning code only executes inside the writer process, so /data/cmdbus continues to grow at the measured ~12 GB/day against 18.6 GB free β€” about 36 hours from 07:00Z. Until the rollout happens the hand-relocation stopgap is still required, and it buys ~36h per application.

⚠ Worth recording: the fleet is already split β€” the three worker Deployments run digest 42145c01… (v5.124.0) while the writer runs v5.129.0. Rolling the writer alone does not widen that.

The rollout itself was NOT performed β€” it is a separate owner decision. Blast radius is one pod; rollback is one kubectl set image back to 1c7d5387….

2026-09-01 β€” an 8-hour silent dispatch outage, its cause, and the reason nothing paged

A prod outage, found and fixed. Every POST /api/execute returned 500:

EHDB command publish: EHDB publish failed: ehdb publish connection closed before ack

/data/cmdbus was 100% full, 0 bytes free. The actual command data was 71.8 MB; the manifest was 19.4 GB. ehdb-l0 writes a full manifest snapshot per version, nothing ever reads one (LATEST is the only manifest the engine loads), and nothing pruned one β€” so snapshot size grows with part count while snapshot count grows with write count. 6,770 snapshots, 75 KB β†’ 5.0 MB each. Quadratic.

⚠⚠ The reason it took hours: the writer looked perfectly healthy. serve_ingest answered append_batch failure with a bare return β€” error discarded, socket dropped, no log, no metric. Pod Ready, restarts=0, all nine listeners bound, 0 ERROR and 0 WARN lines. Diagnosis burned rounds ruling out DNS, endpoints, selector drift, pod-IP staleness and image mismatch, all of which checked out fine, because the one component that knew was silent. A full volume and a serde-incompatible record produced byte-identical symptoms.

⚠ I first told the owner my writer restart caused it. It did not β€” the manifest growth long predates it. Read the timeline off part/manifest mtimes and controllerrevisions, not off "what did I just touch".

Mitigation (during the incident): 6,747 superseded snapshots relocated, not deleted, to /data/eventbus/cmdbus-manifest-archive-20260901. 100% β†’ 1%.

Then a second problem, which the clean-slate truncate caused. Dispatch worked but the system pool sat at lag 78–86 and never drained; a burst degraded to 37/60 with 23 timeouts at p50 1997 ms. 53 pre-truncate executions were in an infinite re-drive loop β€” WAL chain incomplete; returning no-op, server reconcile will re-drive β€” with freshly issued command ids each cycle. spawn_orchestrator_reconciler polls orch_cache.active_executions() every 8s with no attempt cap, and OrchStateCache is an unbounded map whose only removal is evict() on terminal. Its own doc comment carries the broken assumption: "progress is guaranteed" β€” true only while the missing events are still coming. Fixed by a server restart after verifying nothing rehydrates the cache from the 4,647 surviving noetl.execution rows. Lag 82 β†’ 0; next burst 30/30 at p50 199 ms.

Equivalence on the clean slate β€” 30 executions, prod v3.99.2:

measure result
same_version / applied_count / input field census 30/30 (100%)
input events equal post-normalisation 30/30 (100%)
folded state identical / digest equal 8/30 (26.7%)

Data equivalence is 100%. All 22 divergent executions diverge at one path, /steps/loop_test/iteration_command_ids/N, and a multiset compare proves all 22 are pure permutations β€” same ids, reordered, genuine divergence zero. The fold digests an order-sensitive array whose order is not deterministic across stores (#314). Until canonicalised the oracle cannot gate a serve-flip: it reports ~73% divergence on identical data, and a real divergence would be lost in that noise.

⚠ I nearly reported 0% β€” the real keys are /postgres/* and /comparand/*, and input_event_diffs_post_normalisation (empty) is distinct from the raw list. Dump the key structure before computing a rate off a nested response.

Landed

  • ehdb#346 β€” bounded manifest retention. L0Config::manifest_retain (default LATEST + 32). Growth goes quadratic β†’ linear in part count. ⚠⚠ A mutation disproved my own design note: I documented the O(1) per-write delete as what bounds growth and the sweep as "only for backlogs". Removing the sweep leaves 6 snapshots instead of 4 after 40 writes, with stragglers arbitrarily old (v6, v49 among v77–v80) β€” two producers write versions, so a file can land after the delete meant to evict it looked. The sweep is the guarantee.
  • ehdb#347 β€” tracing::warn! + ingest_append_failed / ingest_decode_failed at both silent return sites, counted separately because the remedies are unrelated. Logs throttle 1-in-256; counters never throttle, with a test.
  • ops#294 β€” three alert policies declared (not applied: the metrics do not exist in GCM until worker#304 ships, and applying over absent series is the same "absent looks healthy" failure this whole thread is about).

Held at the merge gate (each fires a publish)

  • worker#304 β€” ehdb pin 229ab6a β†’ 0980183 + four series rendered unconditionally so they appear at zero. CI green, 736 tests pass.
  • server#386 β€” #264: the parity endpoint stops writing the counters its own alert reads. Also gates the control counter and the ratio-feeding outcome counter, and the response now says "metrics_recorded": false. 968 tests pass.

Opened: #314 fold-digest ordering, #315 unbounded reconcile re-drive, #316 projection-parity has no sampler β€” its divergence series only moves when a human queries it, so a real divergence there is invisible today.

Method notes worth keeping

  • ⚠⚠ A mutation that fails to apply reports a false green. One LATEST-protection mutation hit a Python AssertionError on a wrong indent assumption and the suite returned "5 passed" β€” the unmutated result. Assert the mutation landed in the source before trusting what the tests say.
  • ⚠ A sanity control caught a broken command: a repo-wide grep returned 0 for a symbol I knew existed (zsh glob-expanding --include), which would otherwise have read as "the codebase is clean".
  • ⚠ Election/fencing is not the gap it looks like. ShardElection has no call sites β€” but that is already deliberate and documented, and made legible on the scrape as ehdb_election_active 0. Fencing is wired in the worker. My first grep missed it by being scoped to the ehdb repo alone.

2026-08-31 (later) β€” the archive lands, the truncate parks, and the 4.6% alarm dissolves

Deployed. Prod is on server v3.99.2. Three fixes landed and one was gated back off:

  • worker_id / context reader asymmetry (server#380) β€” the tier read worker_id only as a JSON string and filtered a jsonb null context that the Postgres side kept. Fixed symmetrically.
  • created_at producer unification (server#384) β€” the column is microseconds, a DateTime<Utc> is nanoseconds, and the two writers reduced differently: sqlx's binary bind TRUNCATES, the SQL text cast in project_events ROUNDS. Now reduced once at construction, so both writers and the mirror carry the identical value and the fold rule is a no-op. ⚠⚠ This reversed a code comment that forbade reversing it β€” and both prior measurements were right; the question simply had two answers.
  • ⚠ The sink mirror is gated OFF (NOETL_EHDB_SINK_MIRROR, server#385). server#383 closed #307's missing-event class and introduced the opposite defect: on one execution the tier held 45 records against 30 authoritative β€” 11 duplicates plus 4 orphans. Mechanism not pinned; #313 tracks it. Post-gate executions verify clean (auth == tier == matched, zero divergences). A tier holding more than the system of record is the worse failure, so the missing-event fix was traded away deliberately.

The archive is complete; the truncate is parked

gs://shastaratech-noetl-prod-eventlog-archive-20260831 β€” UBLA locked, public access prevention enforced, 0 public bindings. 6.95 GB dump carrying 1,020,651 event rows across all partitions, catalog 1,469 (cross-checks live exactly), plus the three MD5-verified tier stores.

⚠ The first export reported DONE with no error and contained ZERO rows β€” 1,974 bytes. noetl.event is PARTITION BY RANGE, so --table=noetl.event dumped the parent definition and none of the data. Trusting the exit status would have deleted a million rows against an empty archive.

The TRUNCATE is owner-run and not executed β€” there is no SQL write path from an agent session (/api/postgres/execute is disabled, psql has no password, the admin surface offers only report/rehearse). SQL, FK analysis and the projection vs projection_snapshot hazard are in playbooks/event-history-archive/.

The "tier holds 4.6% of the log" alarm was not a defect

95.6% of the 1,020,651 events predate the tier's ~2026-08-13 enablement. Within its own window the tier holds 47,060 against 45,109 authoritative β€” 104%, a surplus matching #313, not a shortfall. Retention/GC and selective-mirroring were both ruled out with evidence.

⚠ Three magnitudes were reported wrong before this landed β€” "19 days", "78 days" and "5,001,000 events" β€” each from measuring a copy (the tier) or a broken paginated count whose cursor never advanced. The reliable number came from the pg_dump output, i.e. the source.

Also

noetl 4.23.1 and noetl-executor 0.9.0 published to crates.io β€” the executor needed a version bump because cli#82 added a json-schema feature that the published 0.8.0 did not have, and the workflow's skip-guard therefore never republished it. ⚠ v4.23.0 is tagged but never published and never will be; the registry went 4.22.0 β†’ 4.23.1.

server#382 widened the #263 insert guard to walk src/**/*.rs at test time rather than one file, and server#379 brought the platform DDL into the server repo behind a drift check. The SAFE-INTERNAL naming pass landed as worker#303 (42 symbols) with the env-var tier retracted to zero, and two dead GCPToken* types were deleted after a cross-repo reachability check.

2026-08-31 β€” the Python platform is gone, and #307's real blocker turns out to be a mirror gap

Landed. The legacy Python platform is retired: noetl#697 (the macOS fork crash fix), #698 (stop building the ghcr.io/noetl/noetl image) and #699 (523 files, 132,650 deletions) are all merged. What remains of noetl/noetl is the thing it was supposed to be β€” the maturin/PyO3 CLI wrapper (packaging/pypi, PyPI noetl 5.0.0) plus schema_ddl.sql, which the Rust deploy still provisions from. The wheel was rebuilt from merged main and installs clean: noetl/ntl --version -> 4.21.0, import noetl -> 5.0.0.

⚠ Deleting the source did not close the DR path β€” that was the surprise. ghcr.io/noetl/noetl:v2.8.9 and :latest are still pullable (verified, HTTP 200), and the fresh-stack playbook deploys a published image; it does not build one. So ops#290 was the thing that actually closed it, with explicit refusals rather than silent repointing β€” the real deploy_noetl step body was extracted and executed, exit 1. ci/manifests stayed byte-identical, so nothing production applies changed.

A machine-readable playbook schema exists again (cli#82): noetl schema [--output], draft 2020-12, generated from a real Rust document model in noetl-executor β€” 22 new types covering the policy/output/loop surface that had no Rust type anywhere, while policy: appears in 61 of 202 real playbooks. 279/279 real playbooks validate; a malformed one fails 11 of 12 mutations. A staleness guard regenerates and diffs the artifact, so it cannot silently rot. ⚠ Step.next is documented, not constrained β€” the legacy shorthand forms still parse, so a malformed next: is not caught.

The D1 durability window is observed on production, not asserted. Idle it reads 0.000 forever. Under a bounded 180-execution burst it sawtooths and resets, peaking at 5.251s against SEAL_MAX_AGE_MS=5000. ⚠ The bound is ~5s plus one sweep interval, not a hard 5.000s ceiling β€” alert above it, not at it. Catalog verify agreed 180/180. No writer/server/pgbouncer restarts; the 12 client timeouts were KEDA scaling the user pool 2 -> ~20 and cold-starting.

#307 is reframed, and neither fold fix is the blocker

Two content-level fold defects were found and merged, both inert on prod:

  • The tier reader disagreed with the Postgres reader on the same value β€” worker_id serialised as a JSON number read as None, and a jsonb null context that only one side filtered. Fixed symmetrically.
  • ⚠⚠ The microsecond normalisation was reversed, against a comment that forbade reversing it. That comment cited one execution showing truncation and said "re-run the diff endpoint before changing this". It was re-run: across 11 executions / 263 events, 9 round, 1 truncates, 1 mixed. Both observations are correct β€” noetl.event.created_at is written by more than one path and they disagree. Rounding is chosen because it is right for 9 of 11, and the minority execution is kept as a live assertion. The earlier sample could not distinguish the rules because every event it examined had a remainder below 500 ns, where the two agree.

⚠⚠ The real blocker is a mirror gap, not the fold. ~60% of disagreements are missing events β€” Postgres n=30, tier n=29 β€” pinned to event 352701148234289152 on execution 352701018137436160, with pending=0 and unmirrored_by_design=0, i.e. a genuine drop. Cause: three noetl.event INSERT sites never mirror, and one of them, services::internal::project_events (the projector sink), had written 5,088 events on prod with none reaching the tier. The #263 guard could not see it because it counts inserts only in events.rs.

The durable fix landed (server#382): the guard now walks src/**/*.rs at test time, so a new file is caught, and every insert site must be registered as mirroring, mirrored-by-a-named-caller, by-design, or pending-fix β€” with no default. UNMIRRORED_BY_DESIGN is deliberately empty. The actual mirror (server#383) is staged and unmerged: it changes runtime behaviour on a hot path, so it wants a human awake. Its deploy runbook is on the PR.

Also

ops#292 finished the deprecated-verb sweep in ci/bootstrap β€” ⚠ scope widened deliberately, because a verb-only substitution would have shipped commands that still do not run: noetl run takes no trailing --host/--port (they are global flags before the subcommand) and the adjacent register lines were broken the same way.

⚠ noetl/cli has a v4.23.0 tag with no publish. semantic-release tagged it on the cli#82 merge and release-cli (which runs cargo publish) failed; crates.io is still on 4.22.0. Not retried β€” left for the owner.

2026-08-30 β€” the durability window is measured, and it found three things

Landed. The EHDB #324 remediation set: five inert PRs (ehdb#333–#337) closing F1–F5, plus worker#291/#292/#293 putting the instrument on prod (v5.125.0 β†’ v5.125.1 β†’ v5.125.2).

⭐ The finding, measured on prod. First reading of ehdb_l0_unreplicated_age_seconds on the command-bus engine: 387 β†’ 404 β†’ 451 β†’ 497 s, monotonic, 4 acknowledged records on one disk, replicated_lag_seconds_count 0 β€” nothing had ever reached the substrate. At the same instant ehdb_feed_total_lag 0 and ehdb_l0_out_of_order_appends 0 reported perfect health. F3 stopped being an argument from reading should_seal() and became a number.

⚠⚠ Two of the three findings were in my own work, and both came from deploying rather than reading.

  1. The window shipped on the wrong log. The writer hosts two L0 engines β€” /data/cmdbus (:9102) and /data/eventbus (:9106) β€” and v5.125.0 instrumented the command bus while the tier that is primary on prod is the event one. Both render ehdb_l0_* and share the same helpers, so a grep finds one wiring and stops. Fixed in worker#292.
  2. Crash-recovered records were invisible. The tracker was seeded only by append_record, and recovery does not go through it β€” so after an unclean exit a shard reported 0 pending. The instrument was quietest at the moment of greatest risk. Fixed in ehdb#338 + worker#293. Notable: PartWriter already restarted its age clock on recovery, so the two halves had drifted and one had to be wrong.

⚠ A self-correction worth recording: my first probe of :9106 got Connection refused and I nearly filed that as the defect. It binds ~37 s after start, after the three group resumes β€” a timing artifact. The real finding was the missing families.

Everything ships inert. Fencing counts and refuses nothing; the election issues tokens without being authoritative; the age-seal trigger is default-off; no prod storage changed.

πŸ”΄ Four gates remain, owner-gated, three touching the live writer path on an already-primary tier β€” playbooks/324-four-gates/. Two ordering hazards recorded: enforce-before-election is an outage, not a degradation, and G4 would fail on prod as it stands (the tier dir is nested inside the writer dir on one PVC).

Also: playbooks/261-durability-soak/ β€” ⚠⚠ a saturating soak cannot detect the defect it is run to measure, so the design mandates a deliberately quiet arm; ehdb wiki invariants refreshed (the page claimed "detected by nothing" for two invariants that now have detectors).

2026-08-29 (later) β€” the catalog gets a log, a relation, and full coverage; #311 closed

server v3.94.0 Β· worker v5.124.0 Β· cli v4.22.0 Β· gateway guards (ai-meta pointer bdf2fda1). Everything inert by default.

#311 β€” resolved, and then proved. pgbouncer now seeds its userlist from Secret Manager via a CSI mount before the image entrypoint runs, so the stale password in DATABASE_URLS is never consulted. Hours later Autopilot rescheduled the pod again β€” the exact trigger of the 12-hour outage β€” and the new pod re-seeded "noetl" β†’ 44 chars with 0 auth failures and 0 workload restarts. The hourly cleanup CronJob's 18:00Z run also succeeded, confirming the earlier Error pods were pre-recovery residue.

Catalog step 2 + 3. noetl.catalog was never event-sourced β€” register is a direct INSERT β€” so the relation had nothing to fold. Registration now emits into StoreTier::Catalog, deliberately not the event log: a catalog record has no noetl.event row, so appending one there would make the cross-store parity comparator report extra_event, which pages.

The backfill, and the trap it avoids. Re-posting the existing catalog through register would run MAX(version)+1 and create a second version of all 2,528 entries β€” describing the catalog by doubling it. POST /api/catalog-log/backfill emits each row's existing version and never touches noetl.catalog. Coverage went 4 β†’ 2,528 / 2,528, fold_missing 0, 2,528 digests compared with 0 mismatched, and a re-run emits nothing.

Three truncation defects the gate caught, each reporting a wrong answer as a smaller one: the worker's scan hardcoded limit=100 so a fold over 2,528 records folded a 100-record prefix (and the server's own cap-check could not see it β€” a cap the requester cannot observe is worse than a small one); a whole-log scan cannot work at all against the 1 MiB frame cap on a 6,553,796-byte log, so the fold pages adaptively because one entry can be 267 KB; and verify_endpoint did its own unpaged read, becoming a verifier that stopped working at exactly the coverage it exists to confirm.

CLI + gateway. noetl catalog load <paths…> bulk-loads a tree in batches with a per-file outcome and refuses credentials rather than registering secret material as catalog entries. The gateway needed no routing change β€” the proxy is generic /noetl/{*path} β†’ /api/{path} β€” so the property is now tested rather than assumed, including that the proxy stays auth-gated. ⚠ The first mutation run there proved nothing: noetl-gateway is binary-only, so cargo test --lib runs no tests and the mutations appeared to pass.

2026-08-29 β€” #307: the projection fold stops being unmeasurable; prod live in verify

server v3.92.0 (server#369, ai-meta pointer 938d92d5). Option 2 of #307: the recovery fold now falls back to the durable EHDB event-log tier when the worker's in-memory spine cannot answer.

Why it read as healthy. Recovery folded only from the state-builder index, which holds in-flight executions and evicts them on completion β€” while the comparator by its nature asks about completed ones. Coverage was ~0 by construction, not by failure, and nothing said so except a zero, which is indistinguishable from quiet.

The blocker that made it more than a source swap. mirror_payload never wrote context, and WorkflowState::apply_event reads it β€” it is where workload, path and version come from on the start event. A tier-sourced fold was therefore guaranteed to digest-differ from the Postgres fold. Simply repointing recovery would have reported divergence on every execution. Records written before the fix now refuse with their own reason (payload_too_old) rather than folding short and being scored as diverged β€” a false alarm that would have fired hardest immediately after shipping, on the one signal the comparator exists to make trustworthy.

Kind gate β€” controls before and after, one variable at a time. Baseline (3.91.1) reproduced spine_refused; the same new binary in spine mode refused identically, so the binary was not the variable; verify folded a completed execution from the tier while the verdict and served_tier stayed put; the equivalence sweep read 4 agreed / 0 disagreed with 9 pre-v2 records refusing. Two positive controls proved the gate can fail: an injected phantom event gave equivalent=false, and an injection at a lower event_id (version unchanged, digest moved) gave digest_mismatch, is_fault=true. Plus eight unit mutations, each restored.

Prod β€” inert default proven first (v3.92.0 with the mode var unset: mode gauge spine=1, coverage all-zero, e2e COMPLETED 13 events), then verify. Coverage moved off zero on completed executions; equivalence 5 agreed / 0 disagreed (10 pre-v2 refusing as payload_too_old, filling in as new executions land); served_tier 0; READ_SOURCE=wal, event-log primary and mirror source all unchanged; restarts 0; cross-store parity resumed after the roll (65 events, 5 executions, all match, 0 divergence, all 10 self-test controls firing).

Two things deliberately not claimed. served_tier could not move in kind β€” projection_read_total is 0 across all outcomes there because load_latest is never called by those playbooks, so the read path is unexercised regardless of the change. And the in-path verdict machinery cannot see tier-vs-Postgres divergence: by design it never consults Postgres, and wal_projection_state materialises then refolds, so it re-materialises from a divergent tier and agrees. Two other layers do catch it, both demonstrated: the continuous cross-store parity comparator and the equivalence sweep. That is why the flip to tier serving is held as a separate owner decision.

Also fixed in passing: the recovery tier fold ran on a 20s timeout inherited from the diagnostic endpoints (now 2s); the tier is append-only behind a retrying drain and apply_event is not idempotent, so duplicate records are now deduped by event_id β€” Postgres cannot produce that, which is why it had to be handled on the way out of the tier rather than assumed away; fold_from_wal_spine removed as now-uncalled; the projection record's mirror_source names the source that actually answered instead of a constant.

2026-08-27/28 β€” Secret Manager becomes the sole source; then the IaC sweep finds a login outage waiting to happen

The migration (complete)

NOETL_ENCRYPTION_KEY, POSTGRES_PASSWORD and NOETL_INTERNAL_API_TOKEN now reach prod only as CSI-projected Secret Manager files. All four secretKeyRef bindings removed across noetl-server-rust and both system pools. server v3.90.0, worker v5.123.0; ops#275, #276, #277.

Two things made it safe rather than lucky:

  • Rolled inert first. Each binary went out with no _FILE variable set and reported source="env" on prod. That is the control: it shows the metric can report either value, so the later source="file" is evidence rather than a pinned constant.
  • Digest-checked before removing the fallback. Every mounted file was compared by sha256 against the secretKeyRef value it replaced, computed inside the pod. All five matched β€” so removing the env could not change the effective value, and the removal was a change of plumbing, not of content.

Verified functionally, not by provenance alone: the Postgres pool is created after the three source="file" boot lines, credential decryption returns structure on a two-minute-old pod (fetched fresh, not a warm cache), and a real execution reached COMPLETED. The three variables are now absent from the container environment entirely, which is the actual proof the file is the only source.

Then the #267 completeness sweep β€” seven findings, three invisible to kubectl diff

finding why it survived
the gateway manifest would have deleted 13 live env vars on apply, incl. all three GATEWAY_AUTH0_* β€” a login outage β€” and added two NATS_* vars for a transport deleted in T5 nothing owned it: the objects are labelled managed-by: Helm, but helm list -A reports no releases
ServiceAccount/noetl-server-rust declared nowhere β€” referenced by name everywhere, defined in no manifest a clean diff says nothing about an object no manifest declares
three EHDB PVCs declared nowhere (the StatefulSet mounts them by static claimName) a DR apply would produce pods that can never schedule, with nothing saying why
the watchdog script declared nowhere β€” CronJob in the repo, its 5,224-byte script only in the cluster deleting the ConfigMap would have destroyed the only copy
rbac.yaml declares an unapplied ClusterRole binding the worker SA to cluster-wide delete on namespaces/secrets the DR procedure itself is the escalation vector β†’ #306
rbac.yaml would also have stripped the worker's iam.gke.io/gcp-service-account annotation live and undeclared; three-way merge cannot warn
the secret gate was blind to CronJobs β€” walked spec.template only, so it returned clean for every CronJob and printed env=0 for one with 11 a fail-closed gate failing open on a whole resource kind

The durable lesson: a no-op diff and a completeness check answer different questions. The diff asks "would an apply change anything"; it cannot see an object no manifest declares, an annotation absent from last-applied, or a declared object that was never applied. Verification is now both checks.

#303 β€” the token is enforced, just not where it matters

Correcting an in-session claim: the internal token is enforced β€” /api/internal/outbox/pending-count gives 403/403/200 on prod. The mechanism is a per-handler extractor, so it is opt-in, and the handlers that forgot are indistinguishable from ones that decided not to. /api/credentials/{id}?include_data=true answers 200 with decrypted data and no Authorization header, reachable from the user worker pool, with zero NetworkPolicies. Two /api/internal/* routes are open for the same reason β€” which makes the prefix a representation of protection that nothing enforces.

server v3.91.0 adds a router-level Layer (which a handler cannot forget) across 14 privileged groups, in shadow β€” it rejects nothing, recording what it would reject. Mutation-proven: removing the credential_routes layer makes the structural guard fail.

#307 β€” projection recovery has ~zero coverage, and nothing is broken

With READ_SOURCE=wal live: every refold refused, served_tier=0, and the projection comparator has compared 0 events against 8,476 on the eventlog tier.

The reason is spine_incomplete, which reads as transient β€” and is not: executions nine hours old refuse identically to ones seconds old. A positive control found the mechanism:

t+4s   complete=True   count=6    indexed_executions=1
t+8s   complete=False  count=0    indexed_executions=0

The spine walk works; the window closes when the execution finishes. state_spine_handler reads the in-memory index and never calls the bounded rehydrate path the drive uses β€” though REHYDRATE_ON_MISS=true and the WAL holds 38,611 events. 2,281 incomplete builds against 7 rehydrate attempts. So the comparator asks a structure that holds in-flight executions for completed ones: coverage is ~0 by construction, not by failure. Owner decision; three options in the issue.

Landed

server#362 #363 #364 Β· worker#286 Β· ops#275 #276 #277 #278 #279 #280 Β· draft ops#281 Β· ai-meta pointer bumps Β· new issues #304 #305 #306 #307.

2026-08-24 β€” travel queue: hotel drop-off coords, currency guard, provider inlining (#293 #292 #286)

Three issues landed on prod, each proved by real executions. travel#107/#108/#109 β†’ itinerary-planner v95 β†’ v97, ai-meta@865e70d.

#293 β€” transfers quote the picked hotel. No extra provider call was needed: HotelBeds already returns per-hotel lat/lng and the card already kept them; they just were not carried across the pick. ⚠⚠ The bug only execution found β€” slot state round-trips through Firestore and stringifies the numbers, so isinstance(x, (int, float)) rejected a good location on every turn after the pick while picked_hotel_location sat in state looking correct. Coordinates are coerced, not type-checked. Proof: to_code 48.8566,2.3522 (city centre) β†’ 48.87389793495718,2.347064647742627 ("priced to Peyris Opera").

#292 β€” no cross-currency cap. No FX source exists in the catalog, and inventing a rate would be the same defect class as inventing inventory, so the cap is not applied when currencies differ and the turn says so. budget.currency_explicit mirrors dates_explicit_range: a bare "under 300" still filters.

#286 β€” provider hops inlined. A kind: playbook hop costs ~9s of orchestration regardless of work. hotels 9.3sβ†’3.0s, activities ~9sβ†’1.9s, transfers ~9sβ†’1.7s; hotels turn span 24.9sβ†’17.8s. ⚠⚠ Lifted from the registered catalog content, not repos/ops β€” the two had drifted (hotelbeds 47,295 chars live vs 37,945 in the repo), so the repo copy would have shipped stale provider code with every test green.

Validated the three together end to end, not just individually: hotels β†’ pick β†’ transfers still resolves to the hotel's coordinates after inlining.

New follow-up: #295 β€” the provider code now exists in two places (three counting repos/ops); which copy is authoritative is an owner decision, filed rather than left implicit.

Wiki: noetl-travel-wiki@10f539a.

2026-08-23 (docs) β€” travel documentation consolidated into four guide pages

Documentation only; no prod, playbook or behaviour change. Four new pages on the travel wiki, cross-linked into Home and the sidebar rather than duplicating the existing per-page detail:

Page Covers
travel-user-guide Orientation β€” the two playbooks (itinerary-planner v93, flight-planner v4), the five providers, the Vertex/Gemini extraction pass, deployment incl. guest mode + the keyed build
anti-fabrication The integrity property from #289/#290, the three rules, and where a disclosed default is still legitimate
flight-use-cases F1–F10 behaviour + coverage matrices + the Duffel roadmap (#291)
validating-the-travel-flows Runnable checks, expected behaviour per prompt, and the known follow-ups

Two things worth keeping from writing it up:

  • ⚠ The travel wiki's deployment page listed a NATS deployment in a nats namespace. Checked against the prod cluster before "correcting" it β€” the namespace genuinely does not exist, so the line was stale and is now fixed. Verifying before editing is the point: the same instinct applied to the agent memory index two weeks ago would have caught #238's stale text.
  • The anti-fabrication page documents the positive-control method explicitly, because it is the part most likely to be got wrong later: a nonsense city gates before the provider, so a clean run tests the gate, not the render branch.

Wiki: noetl-travel-wiki@e982b88.

2026-08-23 (later) β€” the fabrication was never flight-specific: all four remaining surfaces (#290)

Completed what #289 started. Landed travel#106 (ab430a9) + ai-meta@25f6d39. Live on prod: itinerary-planner v93.

The synthetic fallback was a house pattern, not a flight bug. Every surface invented inventory when its provider returned nothing, and reported COMPLETED:

Surface invented now
places synthetic-miami, 4.6β˜… / 1,200 reviews error_card Β· no_place_results
hotels "Stay in <city>", 8.4 score, 4β˜…, EUR 180/night no_hotel_results
activities "Top experience in <city>", EUR 45 no_activity_results
transfers EUR 44.40 "Shared shuttle" no_transfer_results

⚠ They invented the inputs too. Hotels and activities defaulted dates to today+30 for four nights, pricing a stay nobody asked for. Activities are the subtlest defect found in either session: the tours returned are real, just real on the wrong dates β€” every field genuine, nothing reading as wrong.

⚠⚠ Transfers fabricated three inputs at once: the outbound date; the drop-off, using city-centre coordinates from a five-city hard-coded map even when the user said "to my hotel"; and outside those five cities the pickup, using a city code as an IATA airport code alongside an empty destination string. Now gated, with the city-centre drop-off disclosed rather than implied β€” a picked hotel stores only its id, so re-quoting against a real property is not yet possible.

⚠ Method note β€” proving an empty-provider branch. Asking for a nonsense city proves nothing: it gates at region and never reaches the provider, so a clean run is a claim about the gate, not the render branch. Proved instead with a positive control β€” the same code at a throwaway catalog path with the provider list forced empty. This is the reachability lens again: existence of the branch is what a naive test measures.

One apparent flights regression was not one β€” removing the fabricated return leg dropped the fare 86.11 β†’ 38.37, so an "under 50 EUR" cap stopped being impossible. Re-verified with under 10 USD.

  • Follow-up: #292 β€” the budget cap is compared across currencies with no conversion; it only looks correct because Duffel happens to return USD.
  • ⚠ muno/playbooks/_probe-empty-results is still registered on prod; /api/catalog/delete returns 403 (protected since the soft-delete incident), so it needs an operator to archive. Nothing calls it.
  • Wiki: noetl-travel-wiki@67241ba

2026-08-23 β€” the travel planner was inventing flights, and inventing the search too (#289)

Validated the owner-supplied Flights Training Use Cases spec (10 rules, scenarios F1–F10) against what the planner actually did, then implemented the MVP. Landed travel#105 (d259981)

  • ai-meta@912d5da. Registered and verified live: itinerary-planner v88, flight-planner v4.

⚠⚠ The headline is a fabrication, not a gap. When Duffel returned nothing, show_flights rendered {"offer_id": "synthetic-offer-1", "price": {"total": "220.00"}} and the UI presented it as bookable inventory. The execution reported COMPLETED, the widget rendered, and nothing anywhere said the fare was invented. It also fabricated the inputs: a search missing origin, destination or date became NYC -> PAR departing today+30 (SFO -> MIA on the guided branch), so a user who never named a city got a plausible route back.

Implemented per the spec: required-input gating, platform-default origin with explicit override, three result groups (best value ≠ cheapest — lowest price-per-hour over the faster half), the duration→radius heuristic as a ranking hint only, budget as a post-filter that empties the set rather than silently dropping the constraint, stale-result invalidation, and lap-infant passenger mapping.

⚠ Two defects only execution found β€” a read of the playbook missed both.

  1. A single date derives a checkout at +4 days (right for a hotel stay), and that derived value was sent as the flight return_date β€” pricing a round trip the user never asked for. On the same query: 86.11 β†’ 38.37 USD. The durable shape is representation-drift: check_out_date was a copy with no provenance, holding two different facts ("the user stated a range" / "we derived an end date") in one field, so every reader downstream had to guess. Fixed by storing the provenance, not by teaching readers a heuristic.
  2. "actually make it business class" carries no flight keyword, so the correction left the flight branch entirely and asked for travellers β€” abandoning the search being corrected. Only visible on a second turn of the same thread.

Proof is real executions on prod against live providers for F1–F9 plus a regression pass over places / hotels / activities / transfers β€” no unit tests. The corrected F9 turn is also the clearest evidence the ranking works: Cheapest 342.10 (1 stop) Β· Fastest 475.60 (nonstop 2h25m) Β· Best value 378.59 β€” neither of the other two.

Not faked: multi-city, baggage-aware filtering and fare-rule semantics are not expressible on Duffel's offer_requests (no price parameter, no baggage filter) and are recorded as roadmap.

  • Coverage matrix: playbooks/travel-slm/flights-use-case-coverage.md
  • Wiki: noetl-travel-wiki@94dae43
  • Follow-ups: #290 (the same synthetic fallback still fabricates places / hotels / activities / transfers β€” flights were the only surface cleaned), #291 (flights roadmap).

2026-08-19 (evening) β€” prod's alerting was undeclared, half of it was inert, and the stale note was MINE (#238)

⚠⚠ Correction, added after the fact and the most useful thing here: #238 was already CLOSED β€” on 2026-08-05, two weeks earlier. It was never open during this work. The "ZERO alerting on prod / 0 notificationChannels" text I acted on came from the agent memory index, which had carried #238's pre-fix description ever since and was never updated when the issue was fixed. I propagated that stale claim into ops#262 and two ai-meta#155 comments before checking the live project.

The drifted representation was the notes about the system, not the system β€” representation-drift.md applied to the very file used to decide what to work on. The work itself still stood up (the six async-mirror policies were genuinely missing, and all eight pre-existing policies were genuinely undeclared), but the framing was wrong for a day. Memory corrected; a topic file now records the verified state.

Delivery is PROVEN, not inferred. A temporary firing policy produced a real email at [email protected]; the policy was then deleted (404 on a direct get). That test was necessary rather than ceremonial: verificationStatus is absent from notificationChannels.get and not selectable, so an unverified channel and a working one are indistinguishable through every readable signal β€” both report enabled: true and attach to policies happily β€” and Cloud Monitoring does not log incidents to Cloud Logging, so there is nothing to grep. Attachment is not delivery.

Headline. ops#264 declares prod's Cloud Monitoring alerting as code and wires the six async-mirror alerts. Applied: 6 created, 8 updated, 14 declared, 0 without a channel.

⚠⚠ #238's premise was wrong, and I had repeated it. The issue says prod has "0 GMP rules, 0 alertPolicies, 0 notificationChannels". Checked rather than inherited: prod had 5 Rules objects, 8 alert policies, 2 enabled email channels, and the server was being scraped. I quoted the zero-channels claim in ops#262 and in two #155 comments before verifying it. The real defect was narrower and one layer down β€” which is the whole lesson of representation-drift.md: the issue text is a copy, and nothing forced it to stay true.

⚠⚠ The finding that outranks the rules: prod has TWO alerting paths and only one delivers. From inside a repository they are indistinguishable.

path state
alertPolicies β†’ notificationChannels working β€” every policy attached and paging
GMP Rules β†’ managed Alertmanager inert

The GMP OperatorConfig references configSecret {name: alertmanager} in namespace gmp-public, and that namespace contains no secrets at all. Five applied Rules objects evaluate happily and their alerts go nowhere β€” including the ones added in ops#262 the same day, which were written as Rules on the assumption that applying them would make them page. They would not have. Anything that must reach a human belongs in alertpolicies.json until a receiver credential (SMTP / Slack / PagerDuty) is provisioned, which nobody has.

The undeclared-infrastructure half. The eight live policies and two channels existed only in the running project, in no repository β€” a rebuild would have silently lost prod's entire alerting. Same shape as #267, where the deployment manifests omit 84 live env vars. They are now exported and declared, applied by an idempotent script that matches on displayName and never deletes: a policy dropped from the JSON stays live and is reported, because silently removing a paging rule is worse than leaving a stale one.

⚠ Unresolved and honest about it: delivery is unconfirmed. verificationStatus is absent from the notificationChannels.get response and the field is not selectable, so the API cannot say whether the email channel is verified. An unverified channel accepts configuration and silently fails to deliver β€” #238 one layer deeper again. A temporary firing policy is live pending inbox confirmation; attachment is not delivery, and the README says so rather than assuming.

Also live today: server v3.83.1 confirmed on prod (7h, 0 restarts) with the async flags intact (async_enabled=1, window 30 s), match 173 / divergent 0, conservation 243 = 243, and the paging-counter fix verified by six in-flight probes leaving divergence_total unmoved at 0.

2026-08-19 (later) β€” the async mirror is LIVE on prod: measure, then size (#155)

Headline. Both stages shipped. noetl-server v3.83.0 + user-pool noetl-worker-rust v5.120.0; async mirror ON with a 30 s comparator window. emit_mirror went from 78.6–87.6 ms/call to 0.1 ms/call β€” ~6.0 s of inline mirror per Muno turn down to 0.007 s. Median warm turn 16.9 s β†’ 13.0 s (βˆ’22%).

⚠ Both numbers reported, not the flattering one. Removing 6.0 s of mirror bought ~3.9 s of wall clock, because not all 76 mirror calls sit on the turn's critical path. The mirror cost is gone; the turn-level win is smaller than the mirror's raw size.

The window was measured, not chosen. Async went on first with a deliberately wide 300 s window (both flags in one rollout, so there was never an instant with async armed and the window at 0). Real prod lag over 241 observations: mean 84 ms, p50 67 ms, p95 374 ms, p99 699 ms, max < 1 s. The window was then set to 30 s β‰ˆ 43Γ— p99 β€” deliberately below the sampler's 120 s settle, so the background sampler compares every execution exactly as before and the window only guards the on-demand endpoint. Verified on the flip: settled turns went pending_mirror β†’ match 63/63/63, 0 held back.

Gates all clear. Conservation 241 enqueued = 241 drained, 0 pending, 0 depth; queue_full_inline 0; enqueued_after_wait 0 (backpressure never engaged at prod's load); controls 10/10 expected; served_primary 684 across replicas with every demote outcome 0; sampler match 14 / divergent 0.

Deployment scope was deliberate. The cmdbus-writer StatefulSet stayed on v5.119.1 β€” it carries the durable log, rolls as its own watched step, and the Option 1 runtime cache lives there; rolling it risks the known in-flight-step re-execution for no benefit here. System pools stayed on v5.118.2, not being in the mirror path. Option 1 verified holding and better than reported: emit_mirror 78.6–87.6 ms/call against 1141 ms/call pre-fix (yesterday's write-up said 110 ms).

⚠⚠ A defect found ON PROD within minutes of the cutover β€” #264 in a second place. Probing an in-flight execution through the on-demand endpoint returned pending_mirror (correct) and still left divergence_total{count}=1, {extra_event}=1 behind β€” the counter ops#257's EhdbCrossStoreDivergence pages on. So investigating a running execution inflates the number its own alert reads, while reporting no divergence. Two causes: compare_execution recorded report.kinds() unconditionally (an untaken comparison is not a finding), and the divergences were read skew β€” the authoritative page and the tier page are separate reads, so a still-emitting execution legitimately has a tier record the authoritative page never saw. Fixed in server#354 β†’ v3.83.1: recording guarded on a Divergent verdict, and tier records above the authoritative page's maximum id excluded (sound because the log is append-only with snowflake ids). ⚠ The bound must come from the full page, not the comparable prefix β€” when the window holds a whole execution back the prefix is empty, which is exactly the observed case (auth=0 ehdb=1); written the wrong way first and caught by re-reading against the observation. Positive control included: a real extra_event below the page maximum must still report, so the fix is not "ignore anything unexplained".

Final prod flag state: NOETL_EHDB_EVENTLOG_MIRROR_ASYNC=true, NOETL_EHDB_CROSSSTORE_PARITY_LAG_TOLERANCE_SECS=30. Revert is one flag.

⚠ #238 is now the blocking follow-up: prod has zero notificationChannels, so every alert guarding this pages nobody. Fine while a human watches the metrics; it must be wired before this is left running unattended.

2026-08-19 β€” Option 3: the mirror comes off the hot path, and the comparator learns a bounded blind spot (#155)

Headline. The event-log mirror ran inline on the event-write chokepoint, costing a measured 110 ms per call Γ— 76 calls β‰ˆ 8.4 s per Muno planner turn on prod β€” after ehdb#316 had already cut it from 85.6 s. It now goes through a bounded in-process queue drained by a single task (server#353 β†’ v3.83.0). Both flags default off; prod is NOT rolled β€” the prod rollout was blocked on permission and awaits the owner's go.

Why it could not ship as just a queue. The cross-store comparator is the event-log tier's only correctness evidence, and it had no recency bound β€” correct while the mirror was synchronous, because an event was in the tier before emit_events returned. Async breaks that: an execution sampled while its newest events are still queued reports missing_event. That is #263 inverted β€” #263 was a tier reporting completeness it did not have. So the change is a queue and a lag window, together.

⚠ One correction to the scope as previously written. Checked rather than assumed: the comparator has exactly three consumers β€” two HTTP endpoints and the sampler β€” and emits metrics and logs only. There is no automatic code path from a cross-store divergence to primary_serve::decide, which reads parity_held from the worker's own per-append sequence check. The hazard is real; the mechanism is one step less direct than "the serve policy demotes".

The window is a latency concession, never a coverage one. A prefix cut at the first event newer than the window, excluded from both sides so a fast mirror is not scored as holding extras. The horizon is MIN(event_id) - 1 over the recent events, not MAX over the old ones β€” the two differ when event_id and created_at order disagree, and only the first cannot leave a still-queued event inside the compared set. An execution wholly inside the window scores pending_mirror, not match.

What gives it teeth: two controls on ONE fixture, driven twice with only the horizon moved. lag_within_window and lag_beyond_window. A comparator taught to ignore everything passes a "clean parity under tolerance" test perfectly; only the pair asserts a discrimination. Both run on every sampler tick, and both were verified 10/10 expected in the running pod.

Kind validation, on built images. Flag off: inert, every series pinned at 0, 61/61 match. Async on under a 30Γ— burst: 3,270 enqueued + 390 enqueued_after_wait = 3,660 drained β€” exact conservation, zero loss, backpressure engaged and queue_full_inline 0, so the one reordering-capable rung was never needed. Settled: 68/68 executions match at 61/61/61 (4,148 events), byte-shape identical, event_id and global_sequence both strictly increasing. Worker serve path unaffected: parity_mismatch 0, primary_divergence 0 across all 3,660. SIGTERM with 174 events in flight: 25/25 match, tier count equal to authoritative count for every execution.

Forced real divergence, end to end on one execution. Relay killed mid-run so 61 events genuinely never reached the tier: at t+21 s (inside a 120 s window) pending_mirror, 61 held back, not match; at t+148 s divergent with missing_execution + missing_event naming all 61 ids. The window delays detection by exactly the window and then reports forever.

⚠⚠ The queue's own first implementation lost events, and every counter still read correctly. The backpressure path used tx.send(batch), which moves the batch into the future, so the timeout cancellation discarded it β€” 32 of 60 records never reached the relay, while queue_full_inline incremented by exactly the right amount, because the events were counted at the moment they were lost. tests/mirror_queue.rs phase 3 caught it; reserve() fixed it. That failure is also the mutation proof that the test has teeth.

⚠ The window is not a free parameter. Driven hard enough that mean lag hit 5.76 s against a 5 s window, the comparator did report divergence β€” 42 samples β€” and was right to: the tier really was behind by more than the declared bound. The window must be sized from measured mirror_lag_seconds, which is why the tolerance is published as a gauge and ops#262's alert compares against it rather than a constant.

Two pre-existing defects surfaced, both of the same family. The #263 mirror guard (the_chokepoint_mirrors_before_it_forks) has been failing on main since server#352 hoisted should_publish into a let for phase timing β€” and because no cargo test runs in CI on any Rust repo (#232), a red guard and a green one were the same observable. And append_batch β€” worker#281's entire premise, "async will produce multi-record batches" β€” was unfalsifiable from /metrics; worker#282 adds noetl_ehdb_tier_append_records_total{path}.

⚠ Three guards caught themselves before catching anything else. The batch-path counter counted its own search literals (4 != 3) β€” the same trap as the #263 INSERT counter and its doc comment. The batch metric's first version rendered inside the tier_service_up gate, making it invisible on exactly the workers that serve the mirror, while its own doc comment already claimed it rendered unconditionally; the second version broke the stronger render_lines()-must-be-empty invariant and had to become a pin at the handler's bind site.

Alerts. Six, plus a recording rule, in prod-GMP and kind-VMRule twins with byte-identical spec blocks. EhdbMirrorQueueStalled watches the pending gauge, not the lag histogram, because a stalled queue emits no observations at all and its histogram reads exactly like an idle healthy system. ops#263 adds promtool unit tests, both ways β€” a rule that has never fired is indistinguishable from one that cannot, and the negative cases (silent at tolerance 0, silent when merely busy) are the ones with teeth. ⚠ These alerts currently page nobody: prod has zero notificationChannels (#238).

Artefacts. server#353 β†’ v3.83.0 (publish-ar green, in AR at sha256:17a281e8…); ops#262 merged; ops#263 + worker#282 open; server wiki deployment-spec (Rule 2a); ai-meta pointer #280. #155 added to the roadmap board (it was absent) and set In progress.

2026-08-18 β€” Muno turn latency: measured, and the obvious answer was wrong (#155)

Headline. A 10-hop probe playbook put 79% of a turn in two command-dispatch gaps (~510ms p50), paid twice per hop because the orchestrator itself is dispatched as a worker command. Four hypotheses argued from code shape were all refuted by measurement β€” the 250ms claim poll (16–33Β΅s), NATS round-trips (NATS is deleted), the writer's fsync posture (0.39ms p50), and the unmerged #205 group-commit branch, which describes this exact symptom but whose model predicts ~5ms at this load.

What landed.

  • worker#279 β†’ v5.119.0 β€” noetl_worker_command_pickup_phase_seconds{phase} (park vs claim_outcome). Settled the attribution in one run: every outcome >250ms vs ~40ms for an ordinary event emit. ai-meta pointer #277.
  • noetl#696 β€” index noetl.command (event_id). The claim endpoint resolves by event_id alone and every index leads with the partition key execution_id β†’ parallel seq scan of all 16 partitions, 88,199 buffers per claim. Kind: 20684ms β†’ 4333ms p50, variance Β±40% β†’ Β±1%. Merged; NOT applied to prod.
  • server#351 β†’ v3.81.2 β€” re-drive on in-flight clear, behind NOETL_ORCH_RETRIGGER_ON_CLEAR (default false).

The correction that matters. The index does not transfer to prod. There the same query is 54ms, not 295ms β€” the heap is fully cached (29,300 buffer hits, zero reads) and the 6.1GB is mostly TOAST the query never touches. It is worth ~1% of an 89s turn. The real prod cost is the event emit at ~780ms each (worker histogram, both pods, all three event types) against ~40ms in kind β€” three per hop, which is the uniform ~0.9–1.4s/transition users report. Prod runs NOETL_OBJECT_STORE_BACKEND=gcs + PERMANENT_LOG_LEAN=true while kind uses the Postgres object store, so that cost was structurally invisible to the harness. Splitting the 780ms (Postgres insert vs GCS put vs EHDB mirror vs parity) is instrument-only and pending.

Method notes worth keeping.

  • An interleaved A/B was required for the stall fix: non-interleaved arms showed a 25% "regression" that a large monotonic drift with run order fully explained. Interleaved: +0.7%, noise.
  • Not every skipped_in_flight is a lost wakeup β€” a parallel fan-out produced 22 drops and zero stalls (one drive covers a loop's completions). The harmful variant is the linear one, where the dropped trigger carries a newer head.
  • The stall proof is mechanistic, not statistical: 5 drops β†’ 5 re-drives, 1:1 on the same execution_id, 637–1603ms after the skip. Counting rare stalls (3/40 vs 0/30) would not have been conclusive on its own.
  • A kind gate image put the binary at /usr/local/bin while the ENTRYPOINT is ./noetl-worker from WORKDIR /app β€” it would have silently measured the old binary. Verify the entrypoint path, not just that the image built.

Prod state: unchanged. Nothing deployed; the index is un-applied (and blocked β€” noetl is not the table owner, and PgBouncer runs POOL_MODE=transaction, so CREATE INDEX CONCURRENTLY needs a direct cloud-sql-proxy session). EHDB mirror, serve config and tiers untouched throughout.

2026-08-14 β€” the projection tier's authoritative store is not the table the charter names

#265 (new umbrella) Β· worker#277 Β· server#350 Β· ai-meta 61a376f9 + c467adf7

Phase A of taking the EHDB projection tier (tier 2) to the state the event log reached the day before: proven in kind, shadow-capable, flip-ready and not flipped. Both PRs land inert; nothing was deployed and prod was verified read-only on exit (no NOETL_EHDB_PROJECTION* on any workload, server image unchanged at sha256:02b6ff2b…).

The survey changed the design four times, and each finding is a way this could have been built to pass its own gate while measuring nothing.

  1. ⚠⚠ noetl.projection is a DEAD TABLE. The charter names "the projection
    • projection_snapshot tables"; half of that is wrong. 0 rows, and no writer anywhere in the Rust control plane β€” its only writer is the retired Python projection_store/postgres.py. A comparator pointed at it would have compared an empty table to an empty tier and reported perfect parity, forever, on a tier holding nothing. Same shape as the frozen noetl.execution.status (#235).
  2. The real authoritative store is noetl.projection_snapshot, with exactly one INSERT site (services/orch_snapshot.rs). A stronger chokepoint than emit_events ever was, so the mirror goes inside the writer rather than beside its callers β€” a bypass would have to be a second INSERT, and the guard counts them.
  3. The tier's existing parity check compares EHDB to EHDB β€” shadow_project folds a window and checks it against a second worker-side fold. That is the exact insufficiency #258 ruled out for the event log; it never reads the incumbent, and per data-access-boundary.md it cannot.
  4. Snapshot rows outlive their events 138:1 (3,320 of 3,344 in kind), so a whole-table presence check would report ~3,300 missing on day one and drown any real divergence.

Content parity here is stronger than the event log's: orch_snapshot::save already computes sha256(snapshot) for the row it stores, and the mirror carries that digest verbatim β€” so the comparator compares two copies of one value rather than two derivations of one input. The event-log comparator cannot do this at all (the server rewrites and sanitises event bodies, so byte-identity is defined to fail).

Kind gate: 9 arms, 3 replicas, 0 failures. substrate 20/20 Β· 19/19 Β· 12/12; end-to-end 25/25 Β· 24/24 Β· corrupt 20/20 Β· drop 19/19 Β· bypass 19/19. Controls expected=9, unexpected=0 before and after every arm. Established: tier addressing is real and closed (kv/vector refused 400; a 390-record event-log scan holds no projection record, both stores non-empty so isolation is not emptiness); it is one store (3/3 replicas return the same record); primary reaches a serve decision (served_primary 0 β†’ 4, pinned at 0 and present in shadow, two-sided on the single flag that moved); demotion is loud (502 + unavailable, never served_primary); and three mutations are each caught as themselves β€” a rewritten checksum as checksum and not stale_version, an emptied store as missing_execution, a disarmed mirror as a divergence on a fresh execution. ⚠ Tier 1 never moved: every arm ended with the event-log comparator at match 13/13.

⚠⚠ The finding that changes phase C: a 13-event execution completes and noetl.projection_snapshot gets NO ROW. The orchestrator's self-write is gated on total == Some(cache.applied_count) β€” a throttled consistency COUNT having run in the same pass β€” and on a short execution the two never coincide; the rows that exist came from the reconcile poller revisiting long-lived executions. The tier can only hold what the incumbent writes, and the incumbent writes sparsely, so the prod soak must measure coverage, not only divergence β€” "0 divergences" over a population the mirror was never offered is precisely the vacuous pass this design was shaped to avoid.

⚠ Two guards caught themselves before they could lie. The INSERT-counting guard counted its own doc comment as a second writer β€” and the direction that matters is the reverse, since the count could otherwise be satisfied by deleting a comment while adding a real writer; it now strips comments and has a positive control that the stripper did not eat the real statement. And the first corrupt mutation compiled and did nothing β€” sed against payloads the store keeps as JSON byte arrays β€” reporting match on an unmutated store; only a store dump showed it, and the mutation now verifies the value changed.

Also recorded: SNAPSHOT_INTERVAL_EVENTS >= 500 compares snowflake ids, not counts, so it fires on essentially every trigger; the tier can legitimately read ahead of the incumbent mid-flight (the mirror is faster than the settle); and the two comparators answer in different envelopes (event-log flat, projection nested under .result) β€” worth knowing before writing an alert.

Not built: read-serve (B1). No reader resolves projections from EHDB; load_latest still answers from Postgres in every mode, and primary means the write path claims authority. Remaining to a prod flip: merge + pointers β†’ B1 β†’ B2 ops β†’ prod shadow β†’ zero-divergence soak with a coverage denominator β†’ explicit per-tier go.

2026-08-13 (later still) β€” a dead default that erased which thing was wrong

#243 (reopened) Β· worker#278 β†’ v5.118.2 Β· ops#259 + ops#260 Β· new: #266, #267 Β· ai-meta a6807c9f

De-risking four landmines an architecture review raised. Two were confirmed, one was the opposite of what was reported, and one was confirmed but is not simply deletable.

The worker's command bus defaulted to a transport that does not exist

CommandBusMode still carried #[default] Nats with _ => Self::Nats. NATS was deleted at T5, so "unset the flag to roll back" had become an outage.

Nothing was silently broken β€” the worker already refused to start, because !consumes_ehdb() bails unconditionally. What the default destroyed was which cause. On the released v5.118.1 image in kind, three distinct misconfigurations printed one line:

NOETL_COMMAND_BUS v5.118.1
unset Error: NOETL_COMMAND_BUS=Nats selects a transport that no longer exists
ehbd (typo) identical
nats (stale manifest) identical

An operator reads =Nats and greps the manifests. Nothing sets it. Each failure now names itself. Took option 3 from the issue body (required, no default), matching the server's already-fixed CommandBusMode and this crate's own EventSourceMode::parse_strict.

⚠ The predecessor test asserted the bug. It was named mode_parsing_defaults_to_nats and checked that "", "garbage" and default() all yield Nats. A green test spelling out the behaviour is part of why it survived T5.

⚠ #243 had been CLOSED with only the server half done, labelled repo:server. Reopened.

The check that was supposed to make it safe found something worse

#243's body said option 3 "wants a check of every manifest first". No ops manifest set NOETL_COMMAND_BUS at all β€” prod, kind or Helm. Every live workload carried it out-of-band.

Widening it: the three prod manifests omit 84 env vars the live Deployments carry (#267). A DR re-apply drops the command bus, events bus, the whole EHDB tier block, Auth0 and the object store. Some of that now fails loud; NOETL_OBJECT_STORE_* does not β€” unset falls back to Postgres with no error, so result blobs would quietly stop going to GCS.

The object-store report was wrong in the other direction

The review said the bucket referenced the retired project. It does not: live is shastaratech-noetl-prod-results, verified to exist in shastaratech-noetl-prod, and noetl-demo-19700101 appears in no prod workload. The defect is not a wrong value but no declared value at all.

The projector was retired, not stale

configmap-projector.yaml named NOETL_PROJECTOR_NATS_STREAM / noetl.events.> for a dead transport β€” but the whole component is gone: it ran python -m noetl.projector, no such workload exists in prod or kind, and the Rust materializer replaced it. Deleted rather than corrected; fixing its stream name would have made a dead component more convincing. Removed 9 env keys with no reader, and NATS_FILTER_SUBJECT β†’ NOETL_FEED_FILTER_SUBJECT, whose fallback had been removed from the worker on the stated grounds that "every manifest carries the EHDB-native name" β€” none did.

⚠ Reachability had to be checked two ways: the server reads config through envy::prefixed("NOETL_") over AppConfig, so an env::var grep alone would have returned a confident wrong zero. NATS_SUBJECT was deliberately kept β€” it has a live reader. And a first pass at the dev manifests generated shared for ehdb-flightsql, which declares noetl.commands.flightsql.> β€” caught by diffing every value against its pre-change form.

Affinity steering is inert, but not simply deletable

NOETL_STATE_AFFINITY_ROUTE=true on both prod system pools: one reader, zero callers, and sharding.rs has zero external uses. But ehdb/eventlog_backend.rs cites the dead AffinityConfig::from_env as the specification for its live shard ownership β€” a reachable behaviour anchored to unreachable code. Filed as #266 (not #242, which is closed and was about metric recorders).

Gate

Mutation-checked (reintroducing the fall-through fails both new tests on the exact assertion). Kind: released v5.118.1 as the before arm, three self-naming errors after, healthy path Running/ready/0 restarts, and an end-to-end hello_world through the gate worker β€” execution 346496000711462912, 3Γ— command.claimed/command.completed, playbook.completed, 28 log lines naming it on the pod running 243-after. Pool restored. helm lint + helm template + kubectl apply --dry-run=server for the IaC.

⚠ The first attempt at that e2e arm silently ran against prod, and was reported here as a kind result before being caught. Port 18082 was already bound by another session's kubectl -n noetl port-forward deploy/noetl-server-rust 18082:8082 β€” no --context, so prod. The --context kind-noetl forward failed with bind: address already in use into an unread log, and every curl 127.0.0.1:18082 traversed the pre-existing tunnel. Both clusters run server v3.81.1 and answer /api/health identically. The tell that was walked past: the catalog returned 1311 entries β€” prod's playbook count; kind has 2502. It created one real prod execution (fixtures/playbooks/hello_world, 346484743765762048, COMPLETED, 19 events) which stays, the log being append-only. A port-forward is a representation of which cluster you are talking to, and it fails open β€” the re-run pins a free port and compares the tunnel's uptime_seconds against the target pod's own before sending anything.

2026-08-13 (later) β€” the serving tier was missing every system-pool claim

#263 Β· server#349 β†’ v3.81.1, deployed Β· ai-meta bd26b0aa

The event-log tier had been primary and serving since earlier the same day. It held 11 of 13 events for every hourly system/scheduled_cleanup, and reported unmirrored_by_design = 0 while doing so β€” asserting a completeness it did not have. Both absent events were command.claimed. Found by the divergence alert wired up for #238, which fired on real data the moment it existed.

It was a branch, not an event type

test/simple_loop on the user pool carries 5 command.claimed and mirrors 29/29. So the mirror handles the type correctly; what differs is which branch writes the row.

claim_command forks on should_publish. True builds an EventRow and calls emit_event after the commit, reaching event_write::emit_events β€” the chokepoint server#348 had put the mirror on, on the stated premise that it is "the one chokepoint every authoritative event passes through". False writes noetl.event directly, inside the claim transaction, and never goes near it.

should_publish is false for every system-pool execution by construction β€” is_system_execution is one of its three false conditions, because system/* playbooks drain the event stream and so cannot also be fed by it. That made the bypass exactly coextensive with the system pool, which is why it presented as an event-type problem. handle_batch_events has the same shape and the same gap; scheduled_cleanup does not batch, so it contributed nothing to the reported numbers, but it is fixed too.

The premise was a doc comment with nothing enforcing it. The new guard counts INSERT INTO noetl.event sites against mirror_rows call sites rather than naming the two it knows about β€” a fourth writer added later is the failure it exists to catch.

Kind gate β€” on prod's exact configuration

The first pass had a gap worth recording: NOETL_EHDB_TIER_SERVICE_ADDR was omitted as "not needed to reproduce". It reproduced, but the worker answered tier_query_source: local with primary_unavailable=120 and the server's appends came back 502 PrimaryUnavailable β€” the gate was green on a degraded serve configuration prod does not have. Exactly the #257 failure mode: without those vars the soak reads green while silently downgrading to local. Rebuilt to match prod (writer StatefulSet 5.118.1 + TIER_SERVICE_BIND, both worker pools 5.118.1 + TIER_QUERY_SOURCE=service, server on the released 3.81.0) and re-ran every arm.

arm system/scheduled_cleanup test/simple_loop
before β€” 3.81.0, the build prod was running 11/13, missing = the 2 command.claimed 29/29 match
after 13/13 matched=13 holds=true 29/29 match
mutation β€” claim-site call reverted, image rebuilt back to 11/13 β€”

controls_ok=true on every run (8 in-binary controls, each unexpected=0), so a full match is a measurement rather than a comparator that saw nothing.

Prod β€” fix-forward, tier primary throughout

Rolled by digest sha256:02b6ff2b… from shastaratech-noetl-prod AR. No serve config was touched; a rolling update does not change it. A fresh system/scheduled_cleanup now compares auth=13 ehdb=13 matched=13 outcome=match with both command.claimed present, and test/simple_loop stays 29/29. served_primary 470 β†’ 523 across the two user-pool replicas, append errors 0, pod 1/1 with 0 restarts.

⚠ The fix cannot backfill. Executions that ran before it keep their short tier copy, so the parity sampler (every 300s, 1h lookback) keeps reporting them until they age out β€” the two executions the issue cites still read 11/13. The alert clears on that lag, not instantly.

The boundary, verified rather than assumed. Re-comparing every recent prod execution on v3.81.1 and classifying by era: all 17 pre-fix system/scheduled_cleanup runs sit at precisely auth=13 ehdb=11 β€” a code path always taken, never a race β€” and every post-fix run matches. The residual divergence is entirely historical and the boundary falls exactly at the roll. Those 17 tier copies stay short permanently; whether to reconcile them rather than let them age out is a backfill decision, not taken here.

⚠ Switching TIER_QUERY_SOURCE from pod-local to the writer's tier service changes which store is read; pre-cutover executions then read ehdb=0. That is a store cutover, not data loss.

The alert cleared, and the closing evidence was the cron itself

system/scheduled_cleanup fired on its own schedule at ~21:0xZ on v3.81.1 (346397993345228800): 13 events, both command.claimed present, and the comparator never flagged it β€” 0 DIVERGENCE lines naming it against a positive control of 29 such lines over the pod's lifetime. divergent held flat at 29 from 21:00 while match climbed 25 β†’ 30, which is what makes the flat line readable: a stalled sampler produces the same flat divergent.

⚠⚠ The parity ENDPOINT writes the counter its own paging alert reads. Mid-validation crossstore_divergence_total jumped 3 β†’ 21 in five minutes with no platform change β€” it was a diagnostic sweep of 20 executions, because compare_execution records unconditionally (ehdb_parity.rs:1185,1188) on the endpoint path as well as the sampler path. So the natural response to a divergence alert β€” check those executions β€” inflates it, invisibly. Filed as #264. Durable: before trusting a counter you are also probing, check whether your probe writes it. The per-execution verdicts stayed valid; the counter's shape over that window did not.

Drive-by: both_real_reply_shapes_parse carried no #[test] β€” a doc comment had landed between the attribute and its function, so the attribute bound to the next item and the test silently never ran.

2026-08-13 β€” the event-log tier goes primary: the first EHDB tier ever promoted

#257 Β· worker v5.118.1 (39cf235) Β· server v3.81.0 (7b81f59)

The tier is serving. NOETL_EHDB_EVENTLOG=primary on all three worker pools, reads resolving through the writer-fronted tier service on :9110, backed by the writer's eventbus RWO PVC.

Proven three independent ways rather than inferred from config: served_primary = 232 (8 executions x 29 events) with every demote outcome at 0; serve_state: served_primary in the tier endpoint reply; and the flip-time log line NOETL_EHDB_EVENTLOG=primary IS SERVING: the event-log tier answered authoritatively through the writer-fronted tier service (mirror_source=server, tier_query_source=service).

The pre-flip gate β€” 20 executions, fresh store

gate result
divergence 0 β€” 20/20 outcome=match, holds=true
full-set match 20/20 β€” auth=29 ehdb=29 matched=29, unmirrored_by_design=0
tier-service append errors 0 (append{ok}=580) β€” was 302
store integrity 580/580 lines parse, no oversized records
multi-replica all 5 replicas returned record_count=29, src=service
comparator self-test 8/8 controls pass on every comparison

The store was rotated fresh first, so the gate measured the fix rather than the previous session's corruption. Post-flip: 8/8 COMPLETED, comparator 8/8 match, writer append{ok}=812 / errors 0, log parses 812/812 (= 28 x 29).

Three findings worth carrying forward

  1. Six of nine merged PRs had targeted a parent feature branch, not main. worker #271/#272/#273 and server #346/#347 merged into each other, so releases v5.117.1 and v3.80.0 were cut green without the P0 fix in them. A merged PR is not evidence its code is on main; nothing in CI checks this. Consolidated via worker#274
  2. The first soak found a real defect and stopped the flip β€” #261: concurrent appends tore the tier log (302 of 348 appends failed; one transaction spliced inside another's payload byte array, invalid type: map, expected u8). Moving the mirror to the server collapsed N single-writer pod-local stores into one store taking concurrent appends, and that path had never been exercised. Fixed in worker#276; the preserved prod log is now the regression fixture.
  3. The serve-ready config needed three env vars the plan did not name β€” TIER_SERVICE_ADDR (without it TIER_QUERY_SOURCE=service silently downgrades to local with only a WARN), WORKER_QUERY_URL (the server mirror POSTs through this relay; the server had no EHDB env at all), and CROSSSTORE_PARITY_ENABLED (the comparator answers 501 without it). Any of the three missing would have produced a green-looking soak measuring nothing.

⚠ served_primary is per-pod. The mirror relay targets the noetl-worker-rust-metrics Service, so appends land on whichever app=noetl-worker-rust pod it reaches β€” all 232 on one replica β€” and the system pools receive none. Sum across replicas when alerting.

Also closed: worker #267/#268/#269 (rounds that shipped in the consolidation and were stale-open), and #261. New: #262 β€” torn-record read posture (fail-loud vs skip-and-report), deliberately not bundled into the corruption fix.

Rollbacks kept armed: primary -> shadow (instant, no image change), and worker -> v5.118.0.

2026-08-11 (late) β€” cross-store parity: the tier is finally compared against the log

#258 β†’ noetl/server#343 + noetl/worker#265 (both open, both default-off). Sub-issues server#342, worker#264.

The event-log tier's parity signal measured EHDB against itself: the worker's mirror passes authoritative_sequence: None, which reduces compare_shadow_parity to the engine's own record count and its own ordering. noetl_ehdb_eventlog_ops_total{outcome="mirrored"} therefore carried no evidence that the tier agrees with noetl.event β€” and that number was the one gating the first primary flip (#257 Β§3.4).

The comparator lives in the server, because per data-access-boundary.md the worker may not read noetl.* at all. It reads the authoritative log through the pools and the tier through the worker-query relay that already exists, so the control-plane guard is untouched β€” it only compares two reads the server could already make.

The measurement that matters for the flip

One tests/gate_fast_probe execution in kind:

authoritative events carry meta.worker_id actually in the tier
13 8 6

The tier structurally holds fewer than half the log. The mirror hook sits on the worker's emit chokepoint, so the seven server-authored events have no tier copy at all. Reported as unmirrored_by_design on every verdict rather than subtracted quietly: a clean parity verdict says the mirrored subset agrees, and explicitly not the tier could serve the log.

That middle column is also where the naive comparator went wrong. meta->>'worker_id' looks like the mirror boundary and is not β€” command.claimed is written by the server inside the claim transaction and carries the claiming worker's id. Taking it at face value produced two phantom missing_events on every healthy execution, and the kind gate is what caught it.

The other half β€” authoritative_sequence

The obvious reading of #258 ("stop passing None") produces a comparator wrong on every event: that argument is measured against EHDB's 1-based gapless global_sequence, and event_id is a snowflake. What the worker can supply is the authoritative identity β€” EventResponse.event_id, which emit_event was discarding. Load-bearing, not cosmetic: five live emit paths send event_id: None, so their mirrored copies had no identity at all.

Gate β€” 27 passed, 0 failed, on built images

Flag-off β‡’ 501 + all four metric families pinned at 0. Tier unreadable β‡’ ehdb_disabled, never match. Healthy β‡’ match, 6/6 matched, 0 divergence, authoritative_id{outcome="agreed"}=12 (a label absent entirely on the released worker). Five deliberate corruptions of the worker's live tier log each reported their own kind; an untouched execution still matched afterwards.

Arm F β€” the shipped signal caught a broken comparator. One membership check removed the way a careless refactor would: D1 stopped detecting a deleted record (24 passed / 3 failed), and the in-binary controls flipped to unexpected=2, controls_ok=false, self-test β†’ HTTP 500. Restored build back to 27/0. That is why the controls ship in the binary rather than in a test β€” noetl_ehdb_crossstore_divergence_total == 0 is only evidence when control_total{result="unexpected"} == 0 and expected > 0.

Wikis: deployment-specification on both noetl/server (5 vars) and noetl/worker (1 var).

No prod change. Kind restored to server:3.79.2 / worker:5.115.3-arm64 with zero EHDB env; no tier promoted.

⚠ Not covered, stated rather than implied: the stamped branch is unit-gated only (its emit paths are subscription/ingress and kind has zero subscription.* events), and cross-store parity is only meaningful against a store holding the whole tier β€” one worker replica, or NOETL_EHDB_TIER_QUERY_SOURCE=service.

2026-08-11 (evening) β€” worker 5.108.1 β†’ 5.115.3 rolled to prod

Executed the staged image-digest-only roll ( playbooks/257-worker-5115-noop-roll/, ops#256 merged as b13795b9). All four worker workloads are on 5.115.3 β€” user pool Γ—2, both system pools, and the cmdbus-writer StatefulSet β€” digest sha256:c50eff55…, resolved in shastaratech-noetl-prod and confirmed linux/amd64 against three amd64 nodes.

No tier flipped. NOETL_EHDB_TIER_SERVICE_* count 0. ops#255 not applied.

Verified from each process (noetl_worker_build_info), not from the Deployment's image tag: five pods 1/1, 0 restarts, 0 ERROR, 0 panic, CLAIM_UNRESOLVABLE 0, and served_primary / runtime_hook absent on every pod β€” with the EHDB shadow-mirror series present as the positive control, so that zero is evidence rather than an empty scrape.

Functional smoke, fixtures/playbooks/hello_world: 19 events on 5.108.1 before, and 19 events in 7 s / 8 s on the full 5.115.3 stack after. The hourly system/scheduled_cleanup cron fired inside the writer restart window (22:00:02) and completed at 22:00:05 β€” better evidence than the synthetic run.

Two findings worth carrying forward:

  1. The writer restart re-executed one in-flight step. The 22:00 cleanup completed normally at 22:00:05, then its end step ran again in full at 22:00:33 β€” a 30.3 s gap against NOETL_COMMAND_BUS_ACK_WAIT_SECS = 30. An ack was lost to the restart and the command was redelivered. At-least-once working as designed, harmless for an idempotent step β€” but any writer roll can do this to a non-idempotent one, and applying ops#255 also rolls the writer.
  2. A single slow execution right after a roll is not a regression signal. The mid-roll smoke took ~4 minutes; the same pods, settled, took 7 s. Re-run before concluding anything.

⚠ Also: a first pass counted "50–78 errors per pod" β€” a case-insensitive grep matching the structured field error= inside WARN lines. The case-sensitive count is 0, and the 49–80 WARNs per pod are all inside the 21:59 minute (the writer being down), with nothing after 22:02:44.

noetl-cmdbus-writer-1 (replicas: 0, old digest) was deliberately not rolled; its disposition is still open.

2026-08-11 (later) β€” EHDB go-live prep: four artefacts, zero flips

Mechanical prep for the first EHDB event-log tier flip, run in parallel with the cross-store comparator (#258, separate session). No tier promoted, no tier-service flag set anywhere, no prod manifest applied. Prod verified read-only on entry and exit: worker digest sha256:5dee5466… (5.108.1) on all four workloads, NOETL_EHDB_* tiers shadow, NOETL_EHDB_TIER_SERVICE_* count 0.

1. #259 β€” the flip-time signal lied at the worst possible moment β†’ worker#263. warn_primary_not_wired told an operator the flip had done nothing on a pod that served 48 ops as primary. Reproduced on the released 5.115.3 image in kind: runtime_hook/primary_not_wired 1 alongside mirror/primary_unavailable 21 β€” the second proves primary_serve::decide ran and demoted for the real reason. Now three outcomes (primary_not_wired / primary_no_tier_service / primary_armed), and the wired-tier list moved out of a doc comment into primary_serve::SERVE_WIRED_TIERS, re-derived from the tier sources by a test matching primary_serve::decide( with the paren so a comment cannot pass as a caller. Mutation-checked twice. ⚠ The after-half of the kind gate did not run β€” the podman VM has no container egress (Tailscale breaks gvproxy), so no image could be built; stated in the PR rather than glossed.

The RFC's cutover order is not runnable. KV β†’ object β†’ projection β†’ vector β†’ event-log names four tiers that cannot be flipped first, and the only wired one last. Corrected in docs/rfc/ehdb-primary-serve-path.md Β§3.4 and on the worker wiki; OQ1 is resolved by construction.

2. RFC PR 6 ops wiring β†’ ops#255, staged not applied. tier-service port unconditional (a containerPort opens no socket), BIND/DIR gated on tier_service_enabled default false. Two-sided render gate against kind, both arms server-dry-run clean. ⚠ Inert is not free: any template change rolls the single writer hosting both buses, and there is no restart-free path. ⚠ No PodMonitoring change β€” :9110 is framed TCP, and src/ehdb/tier_service.rs records no metrics at all β†’ #260. Treat that as a flip gate.

3. Open question 2 (availability) β€” brief written, decision with the user. docs/rfc/ehdb-primary-serve-availability.md. Recommends accepting replicas: 1: the tier serve path is the only writer dependency with a working fallback β€” dispatch, events, SSE, the gateway's KV and the WAL fan-out all simply stop, while a tier demotes to the incumbent. HA is blocked on storage regardless (all four writer PVCs are ReadWriteOnce on premium-rwo). Surprise finding: there is no PodDisruptionBudget in namespace noetl for any workload, on Autopilot.

4. Worker 5.108.1 β†’ 5.115.3 staged as its own inert step (playbooks/257-worker-5115-noop-roll/

  • ops#256). No migrations; every new flag default-off except b56e225, which parks poison commands instead of NACKing forever β€” a fix to a path that currently wedges a pool. v5.115.3 is already in AR as sha256:c50eff55…, linux/amd64 verified with crane config. Landmines named: b56e225 carries a 22-file cargo fmt sweep; the two prod worker manifests pinned the pre-migration project and a months-old digest (fixed in ops#256, rest of #234 still open); noetl-cmdbus-writer-1 is replicas: 0 on a much older digest.

Two decisions parked with the user: availability posture, and whether to apply any of the three staged changes.

2026-08-11 β€” The cooldown gated belief instead of retry

Prod untouched. No tier is primary, no flag flipped, worker v5.115.3 is released but not deployed.

The defect

EHDB tier reachability stored its verdict as a deadline and read it back as one:

pub fn is_reachable() -> bool {
    if EVER_SUCCEEDED.load(..) == 0 { return false; }
    let until = DOWN_UNTIL_MS.load(..);
    until == 0 || now_ms() >= until      // <- promotes on TTL lapse ALONE
}

So the passage of time alone restored belief. Five seconds after a tier service died, with nothing having contacted it, the tier decided it was authoritative again and primary served from a store that was not there. That is the same failure shape the module's own doc comment warns about for cached probes β€” a probe TTL wearing a different name.

The fix separates two questions that were sharing one variable:

question answered by keyed on
may I try the service? is_cached_down() the clock
may I serve from it? is_reachable() the last observed outcome

VERIFIED_REACHABLE is set only by an operation that actually round-tripped and cleared by any transport failure. is_reachable() reads no clock at all. OpVerdict::Rejected still does not demote β€” a service that answers, even to refuse a record, has been reached.

Also: a bounded startup-probe retry with backoff (5 attempts, ~3.75 s), because the writer is a single-replica StatefulSet and a worker booting into a writer restart lost its one probe; and noetl_ehdb_dataplane_ops_total{operation="reachability"} so the serve decision's input is observable.

Validation β€” and why the old gate could not see it

The kind gate's arm E previously asserted delta == 0 across the whole kill half. That is wrong in the other direction: the accepted one-operation lag means a bounded number of events legitimately serve before the first failed append demotes. Rewritten to assert demotion within a bounded 5 events, a positive control that the demotion was recorded, and then a second wave after a provable cooldown lapse that must add exactly zero.

Mutation-checked twice, each with its own rebuild / kind-load / re-gate:

build E1 wave 1 E1 wave 2 gate
restore timer-based promotion +3 served, +18 demoted +2 FAIL (3)
let a failed append still promote +21 served, +0 demoted +18 FAIL (3)
merged fix +1 served, +17 demoted +0 PASS

They fail by different assertions β€” m1 stayed inside the bound and was caught only by wave 2; m2 blew the bound and was caught by wave 1 plus its positive control. The restored build produced a bit-identical image ID to the original passing build while both mutation images were distinct, so neither mutation was a silent no-op.

The earlier arm-E failure was neither hypothesis

Not a worker restart zeroing statics: the pod is unchanged across every arm E (restartCount 0, identical startedAt) β€” arm E kills only the writer StatefulSet. Not the timer either: that path causes a spurious promotion, never a failure to promote. EVER_SUCCEEDED was simply never set β€” GAP 1 in 2f5d4cf, where probe_at_startup was spawned only inside spawn_event_writer_host, gated on NOETL_EVENT_BUS_HOST, which workers do not set. The timer defect was unreachable until that commit made promotion possible at all β€” which is why it survived four rounds of this work.

Artefacts

  • worker#262 β†’ worker v5.115.3 (merge 9398cea, main tip dfcf9d9)
  • ai-meta pointer 245bda68
  • #257 β€” board moved Todo β†’ In progress
  • Full suite 627 passed / 0 failed; kind restored to zero EHDB env

⚠ A unit guard written as its own #[test] raced the existing one over the module's process-global atomics and failed on its first assertion β€” cargo test does not serialise tests within a binary. Folded into the single test, per the convention the module already documented.

2026-08-09 β€” Adiona cluster retired, #211 closed, and a severance that wasn't

Prod: server 3.79.2, worker 5.108.1, gateway 3.12.1. All pods 0 restarts, gates 0,0,0.

The Adiona cluster is gone

noetl-cluster (project noetl-demo-19700101, adiona.org) deleted, with its 17 orphaned disks and both external static IPs released. Cloud SQL and the project remain β€” deliberately.

⚠ The database was never in the cluster. ns postgres held only pgbouncer plus a cloud-sql-proxy sidecar; enumerate by image, not pod name. And noetl-shared-pg exists in both projects β€” never omit --project.

DNS was cleared from the Cloudflare zone itself: mestumre.dev has zero A/AAAA records; gateway.mestumre.dev is a CNAME to <uuid>.cfargotunnel.com. ⚠ A proxied record would have hidden an origin IP from public DNS, so resolution alone could never have proven absence.

#211 CLOSED β€” and it was never IAM

GET /storage/v1/b/<proj>_cloudbuild   β†’ 200   buckets.get WORKS
GET /storage/v1/b?prefix=…&project=…  β†’ 403   buckets.LIST

gcloud builds submit lists buckets to find the staging bucket, and storage.buckets.list is project-level, while every role granted was bucket-scoped. Fixed with --gcs-source-staging-dir (server#339); publish-ar green on two runs with v3.79.4 in AR. crane retained as a documented fallback.

⚠ Six theories died first and two unnecessary IAM grants were spent on them. What broke it was instrumenting rather than guessing a seventh time. The error names the bucket and suggests serviceusage β€” that phrasing misdirected three separate investigations.

⚠ The severance that wasn't

Places (provider v13) and Firestore (provider v11) were repointed to shastaratech and each proven with a live payload test. A full catalog scan then showed 39 of 1,308 live playbooks still reference the old project, including planner v67, which carries its own gcp_project, Adiona secret paths, and inlined Places code. The provider tests passed because they invoked the providers directly, bypassing the caller's override.

Verify severance from the caller, not the component. The Adiona project is not retirable.

Continuous window β€” audit hardening and a sibling-repo miss

#211 was only half fixed. drift-audit.sh reported worker: publish-ar failed 3/3 recent releases β€” the --gcs-source-staging-dir fix had gone to noetl/server only. Fixed in worker#248; publish-ar green and AR now carries latest, v5.108.1 + Cloud Build's buildcache. The tag shape is the proof: Cloud Build writes the v-prefixed tag, crane wrote the unprefixed one. ⚠ A fix verified on one repo says nothing about its siblings β€” the check had been saying so in output I was already reading.

Two gaps in the audit itself (ai-meta@d575951b), both found by running it:

  • 6 pinned label sets were NO-GUARD β€” declared but never registered, so nothing checked their pinned values against the literals the recorders pass. CATALOG_DELETE_OUTCOMES, added the same day, was one. Five registered; SINK_GATE_OUTCOMES is an explicit exclusion (four zero-arg recorders each hardcoding a literal, plus one passing a variable).
  • The inert-test scan covered only worker/server/tools/cli β€” ehdb was never scanned despite 566 attributed tests. Both clean, but "clean" had been indistinguishable from "not looked at".

Reachability sweeps. 145 prod env vars against every Rust reader β†’ one unread: NOETL_COMMAND_BUS_INGEST_ADDRS, a rename leftover shadowed by _WRITER_ADDRS which carries the identical value (#253). cli: 83 clap variants, 0 undispatched. ehdb: 0 orphaned tests.

⚠ The cli sweep produced two false positives before the right answer β€” first scanning 3 files instead of 30 (45 "undispatched" incl. Commands::Execute), then an off-by-one threshold (43). A clap enum declaration never writes Enum::Variant, so one qualified occurrence is the dispatch. Both caught only by keeping a known-good control in the output.

Vertex β€” config done, code-blocked

travel/runtime cannot parse: classify_via_vertex_mcp uses kind: agent, which is not one of the 25 ToolKind variants and has no worker handler anywhere. Dead since written (v16 fails identically), hidden because ai_provider defaults to openai. #252 β€” needs-decision.

2026-08-07 β€” catalog soft delete, a 90-second outage I caused, and a harness that proves itself

Prod: server 3.79.2, worker 5.108.1 (all pools + writer), gateway 3.12.1. Gates 0 throughout except the incident below.

The incident, stated plainly

Building catalog soft delete (#237) produced a production regression. The release numbers trace the reasoning error:

release outcome
3.79.0 startup DDL used ? crash-loop β€” must be owner of table catalog. Loud, and kind caught it before prod
3.79.1 DDL made non-fatal started cleanly, then 500 on every execute-by-path β€” column "archived_at" does not exist
3.79.2 readers tolerate absence correct

Fixing the crash without making the readers tolerant converted a loud failure into a quiet one. Impact ~90 seconds, execute-by-path only, detected by the post-deploy validation execution and rolled back immediately.

The root fact: noetl.catalog is owned by role demo while the server connects as noetl, so the server cannot add the column. The tell was in the same log β€” event_chain DDL already skips for the identical reason on every boot β€” and I walked past it.

Two rules that came out of it

  1. A destructive endpoint must be tested on an ENCUMBERED row. The delete endpoint passed in kind against a freshly registered entry and failed in prod, because noetl.event holds an FK onto noetl.catalog and every real entry has execution history. The fixture had tested the only case that could succeed.
  2. When a schema object is OPTIONAL, every reader is part of the change's surface. Five read paths referenced the column; I had conditionalised one.

The harness that proves itself

playbooks/kind-regression-harness.sh runs the released image through 17 cases and exits with the failure count. Both rules above are first-class cases.

It was validated in both directions, which is the point: green on 3.79.2, and against the broken 3.79.1 it reported 5 failures, independently catching the exact incident.

Building it exposed a false PASS inside itself: len(d.get("entries", d)) counted dict keys on a 500 body and reported "2 entries" β€” green while the hot path was failing. Same class as the bugs it hunts. Two more self-defects surfaced later, both worse than any case in it:

  • The container probes swallowed every exception into a bare PROBE_ERROR, and one transient read aborted the whole probe instead of being retried.
  • It waited for the server rollout but not the worker rollout it triggers itself, so a run that changed the worker image raced its own restart and failed three unrelated cases β€” the harness reporting a defect in itself as a defect in the platform, which is the worst thing a gate can do. Fixed and verified by restarting the worker immediately before a full run: 17/17, against the same condition that had produced 15/2.

Container gating β€” the case that had to prove it could fail

C11 covers the #186 class: a container step whose terminal callback never arrives must not advance the DAG.

Kind has no noetl-k8s-watcher and the poll fallback (NOETL_CONTAINER_COMPLETION_POLL) is off by default, so a container step creates its k8s Job and never receives a terminal event. That is not a blocker β€” it is the #186 condition, so the case drives both sides by toggling the fallback in kind only:

k8s Job container terminal end entered
C11a fallback off succeeded never arrived no, after 88s β†’ gate holds
C11b fallback on succeeded call.done yes, after the terminal β†’ COMPLETED

C11a's teeth come from the Job succeeding while the DAG still refuses to advance. C11b is its control: without it, "end did not enter" could equally mean the fixture can never reach end, and C11a would be vacuous.

Both C11a failure branches were demonstrated before the case was committed, not assumed β€” run with the fallback enabled it returns TERMINATED (fail), and its detector returns ADVANCED_EARLY on a #186-shaped event stream.

Opt-in via HARNESS_CONTAINER=1 (~4 min; restarts the kind worker and restores its env afterwards). ai-meta 6e195af9 + 826a8f92.

Also landed

  • Soft delete is shipped but inert β€” the column does not exist, so every path behaves as pre-soft-delete. One operator ALTER (as role demo) enables it; no redeploy, since presence is detected from information_schema.
  • noetl/ops#254 β€” draft dedicated tier StatefulSet + shadow runbook for #247, applied to nothing, every tier flag off.
  • Verified negative: no dead metric recorders in ehdb/cli/gateway β€” gateway 7/7 live, and ehdb/cli have no prometheus dependency at all.

⚠ Three false zeros in one hunt

All from tooling, all reporting a clean result: a git ls-tree pathspec that matched nothing, strip_tests truncating at the first #[cfg(test)], and β€” the decisive one β€” zsh not word-splitting unquoted variables, so for f in $files iterated the whole multi-line string as one filename and produced a 0-byte file and "0 recorders". Each was caught only by a positive control: knowing the gateway had record_authz made a zero a broken instrument rather than a finding.

2026-08-06 β€” a poison command, a clean regression sweep, and three measurements that lied

Fleet now uniform: server 3.77.4 / worker 5.108.1 on every pool and the writer / gateway 3.12.1. Five alert policies live. All gates 0.

The find: one command with no DB row stops a whole pool

Standing up the kind e2e sweep, the user pool was completely wedged β€” 30 claim failures and zero successful claims in five minutes, with a freshly-issued command sitting at command.issued PENDING. Three commands from a previous session had 0 rows in noetl.command: a 404 claim can never succeed, is nacked forever, and blocks everything behind it. Throughput went to zero, not merely down (#249).

Two things make it interesting. #244's backoff works and does not help β€” the retries were ~10s apart rather than a full-speed spin, so it bounds the cost and not the blockage. And ClaimFailureSpin cannot catch it: that rule fires above 5/s, the wedge runs at ~0.1/s. The backoff behaving correctly is precisely what makes the failure quiet, so the alert built for this class is silent on the version that actually stops work.

Prod was never exposed. The semantics β€” drop, dead-letter, or park a poison command β€” stay parked, because they change delivery guarantees.

Detection shipped instead. There was no success-side counter at all: concurrent_dispatches is a gauge that returns to 0, dispatch_duration_seconds_count is labelled so an idle pod has no series, and pulls_total{outcome="claimed"} has no series in production at all because the EHDB claim path never calls record_pull. So worker v5.108.0 adds claim_succeeded_total, and WorkerPoolThroughputStalled is now live β€” validated both ways: empty on healthy prod, and a positive control proving the and actually joins rather than silently returning nothing.

The sweep: no regressions from ~40 merges

36 fixtures against the released server 3.77.5 / worker 5.107.1 β€” 21 pass, 14 fail, 1 exec-error, with every failure classified rather than counted. Twelve are environmental (external HTTP unreachable from kind, GCS credentials, absent files), one is a fixture designed to error, and two were platform-shaped.

Both platform-shaped ones were malformed tool config (pre-dispatch deserialization) from a nested auth: map where the schema wants an alias β€” fixture drift, proven not a regression: git log -S shows that line last changed in 99e2c66 (#68), not today.

What the sweep handed over

Those fixtures reported only malformed tool config (pre-dispatch deserialization). No field name β€” the cause had to be found by reading YAML by hand. anyhow::Error::to_string() renders only the outermost context and discards the chain (#250). Fixed at all four emit sites, because the pre-dispatch path emits a call.error and a command.failed, and fixing one leaves the other blind.

⚠ Three measurements that produced confident wrong answers

  1. GMP does not support =~ on __name__. It errors, my parser read the error as "no results", and I briefly had "32 of 32 applied alert rules are inert". False β€” caught only because I had queried two of those metrics successfully minutes earlier. A batched existence check needs a positive control, and an error must never parse as empty.
  2. getent hosts on macOS does not reliably consult DNS, which made an Amadeus hostname look unresolvable everywhere. dig confirmed the conclusion but the first control was unsound.
  3. Building string anchors from sed-prefixed output β€” the display prefix is not in the file. Four failed edits before switching to exact reprs.

Decision briefs

Four RFCs written so the parked decisions are one read rather than research: #247 tier host/storage (and a correction to my own earlier recommendation), #248 boundary compaction (prefer partition-and-archive), #242 wire-or-delete (3 of its 4 already resolved), #246 terminal behaviour (classify; credential-403 is permanent, 503 is transient).

Negative result worth keeping: all 32 metrics referenced by applied alert rules were checked, and the rules are sound. Absences are explained β€” the unscraped events bus, OQ5 rules for a retired feature, and event_emit_failed_total{event_type}, which is absent by design because a free-form label cannot be pinned. That is what build_info{version} is for.

2026-08-05 (part 9) β€” the first prod-change session: four dead providers repaired, the lean strip enabled, three cutovers refused

Prod changed today, under an explicit mandate, with IAM grants and secret handling carved out. End state verified from build_info rather than manifest tags: server 3.77.4, worker 5.107.0 (user + both system pools), gateway 3.12.1. The cmdbus-writer StatefulSet remains on worker 5.106.0 β€” a real, deliberate skew, since it carries the durable log and rolls as its own watched step.

The finding that justified the session

#234's stale noetl-demo-19700101 reference was assumed vestigial. It was breaking production. Four MCP providers had been failing every call on secret manager access HTTP 403, and repointing their secret reads to shastaratech-noetl-prod repaired all four:

provider before after
duffel v19 credential_unavailable 200, 20 places
hotelbeds v6 credential_unavailable 200, 10 hotels
hotelbeds-activities v2 credential_unavailable 200, 10 activities
hotelbeds-transfers v2 credential_unavailable auth OK

The fix is per-reference, not per-playbook. Places/Maps, Firestore and Vertex exist only in the old project; prod has only secretmanager + serviceusage. google-places was repointed and then rolled back to v12, byte-identical to v10 (sha256 verified) β€” prod cannot serve Places at all.

Nobody noticed because a broken provider reported success: every failure completed as playbook.completed COMPLETED with the 403 buried in _meta.error (#246). noetl_worker_tool_result_error_total (worker#243) now counts it β€” proven live in prod going 0 β†’ 1 while the execution still reported COMPLETED.

The lean strip, and the regression that nearly rode along with it

NOETL_PERMANENT_LOG_LEAN=true is live (slimmed_total 8, slimmed_bytes_total 140377, stage_failed_total 0).

It was very nearly enabled with a silent cost. Re-proving the "kind-proven" claim β€” which was itself stale, the flag unset and kind running ~v3.62 against a prod on v3.77.3 β€” showed /api/executions/{id} returning 1 row where 40 were expected. hydrate_result_references served the drive and recovery paths but not the operator-facing endpoint, while the module's own doc claimed every status API resolved references. Since noetl.event is append-only, that would have permanently degraded the exact view read during an incident. Fixed first (server#330 β†’ v3.77.4), validated on the released image in kind with the control that matters β€” raw row 707 B, reference_present=true, result_store holding all 40 β€” then enabled in prod, where a Paris query returned 23 IATA codes, identical to the pre-strip baseline.

Three cutovers refused

  • #247 β€” EHDB phase 6-10 tier flips. All five tiers are Off in prod and have never been shadowed, so primary would serve an empty log as authoritative; and the worker pods have no PVC while KEDA scales them 2β†’20. Even shadow is blocked for the same reason. The requested acceptance evidence is shadow-mode evidence and cannot exist yet.
  • #248 β€” sink-gated eviction. mark fires on is_sink_step; confirm needs is_success() && !pending_callback. A failed sink leaks an eviction block forever β€” the pending set has only insert/remove, no TTL, no sweep. ops#246 (the bearer) is unmerged; prod has 0 matching env vars.
  • Boundary compaction (~1.7 GB). No UPDATE/DELETE against noetl.event exists anywhere in server or worker. Needs an RFC, not a script.

Monitoring, from zero

4 PodMonitorings, 4 Rules objects, 4 alert policies all ON (including gateway authentication is DISABLED), 1 email channel.

Traps worth carrying forward

  • set image with a wrong container name is a clean no-op β€” and rollout status then reports success. A green rollout that changed nothing. Caught twice, both times only by reading build_info back.
  • A long opaque string is redacted before persistence, which dropped the first lean-log probe under the 512-byte floor so the strip never fired and the probe read as a null result.
  • #234's inventory is incomplete: references also exist by project number (1014428265962 is noetl-demo-19700101), invisible to a search for the project ID.

Gates at close: lag 0 on all three groups, cursor_errors 0, materializer_skipped 0, claim_failed 0, ready 1.

2026-08-05 (part 8) β€” the failure paths that logged but never counted

Prod untouched. server v3.62.1 / worker v5.95.3 / gateway v3.7.1, unchanged by digest. Every release below was validated on kind against the released image, then kind restored.

The scan, and the decision about where to stop

For every tracing::warn! / error! in server + worker, check whether any metric call appears within Β±6 lines: 97 worker, 52 server. Then classify by consequence, not by file β€” a flat list of 149 is unactionable and a sweep would be noise.

tier count rule status
P1 5 something is lost rather than retried closed
P2 26 dispatch path β€” a command or execution stalls closed
P3 82 startup notices, config echoes, debug aids deliberately not instrumented

P3 stays uninstrumented on purpose. A log is the right medium there; counters would dilute the signal the P1/P2 metrics now carry. The value curve flattened and the sweep stopped.

What the closed tiers found

  • The materializer's only data-losing outcome had no metric. It carried six counters β€” drained, projected, duplicates, acked, project_errors, cycle β€” and not one moved on a skip. A drained message with no event_id is never projected and is acked anyway, so under the publish-only gate (on in prod) it is an event permanently absent from noetl.event.
  • The EHDB claim loop was log-only on both feeds β€” measured 85 warnings in 24 h on one production pod, none of it visible to monitoring. The asymmetry is the tell: the NATS path, a transport that no longer exists, was instrumented; the EHDB path actually carrying production was not.
  • no_writers β€” with NOETL_COMMAND_BUS=ehdb and no writer routes, the publish is not attempted at all: every command silently undelivered, every execution stalled. Production runs ehdb.
  • Four loop exits that were indistinguishable. The cold-rebuild replay breaks identically for complete / deadline / max_messages / feed_error, and only the last means the rebuilt state is incomplete. The site's own comment already cited #227.
  • A plug-in that is never seeded β€” the startup line reports only the count that succeeded, so 3-of-4 and 3-of-3 read identically.
  • A silent sharding fallback β€” a config parse failure quietly runs single-pool; now a gauge set on both arms, so healthy reads 0 rather than being absent.

Shipped

server v3.72.0 β†’ v3.77.0 Β· worker v5.101.0 β†’ v5.105.0. Full list on Releases.

Also: the EHDB_CLAIM_RECONNECT counter gained a feed label (commands | events) β€” normally a breaking change, free here because the counter shipped hours earlier and prod runs an older image, so nothing had ever scraped the one-label shape.

Corrections made mid-stretch

  • A claim I had already reported was wrong. I said a missing noetl.command row blinds the orphaned-command guardrail. Both sweeps query noetl.event exclusively. The real consequence is different and still real: container_callback.rs resolves catalog_id from that table, so a missing row can break a container's resume.
  • A near-miss from a stale checkout. I briefly concluded the worker connects to NATS unconditionally β€” read from a local main that was 143 commits behind origin (server's was 120). Caught before reporting. The tooling fix: playbooks/lib/pinned_sets.py now reads origin/main via git show like every other check, not the working tree.

Tooling that came out of it

playbooks/lib/pinned_sets.py + drift-audit.sh pinned-sets compares every pinned label set against the literals its recorder is actually called with β€” 12 sets, all OK. It exists because #232 records that no cargo test runs in CI on any Rust repo, so the in-repo guards had no runner.

Its discovery rule was widened three times (*_OUTCOMES β†’ *_STATUSES β†’ *_REASONS β†’ *_FEEDS) before the obvious conclusion: a naming convention that must be extended per metric is itself a representation, and it drifted every time. It now matches any pub const NAME: [&str; N] and the registry is the only place relevance is decided.

2026-08-05 (part 7) β€” absence is the default state of a metric

Prod untouched. server v3.62.1 / worker v5.95.3 / gateway v3.7.1, unchanged by digest. Kind restored to localhost/noetl-server-rust:v3620.

The mechanism everything else followed from

Registry::gather prunes metric families with no children (prometheus 0.14, registry.rs:126). Registering a metric does not make it visible β€” a labelled metric is absent from /metrics until something increments it. So every counter's absence is ambiguous between "this has never happened" and "this binary is too old to have the metric", and the two want opposite responses.

Started from a narrow question β€” do the ops#252 alert rules key on metrics that exist on a healthy prod? β€” and the answer kept being no, for a different reason each time.

What that hid

finding detail
noetl_event_ingest_published_total has zero series on prod with its gate ON Looks exactly like the #212 transport failure. It is not: every label on the prod server is pool="system", so is_system_execution exempts every event. Establishing that took a source dive, three env lookups and a label inspection.
The prod gateway's /metrics returns 0 bytes All three of its metrics are labelled by subscription; with no delivery there are no series. A 200 with an empty body is indistinguishable from a broken exporter or a wrong port. The gateway wiki had concluded exactly that β€” it said the gateway "exposes no Prometheus endpoint". It does, on 8090.
The prod server exposes 10 metric families of 136 defined The worker, 38.
No binary carried a version on /metrics So "does this pod predate that metric?" was answered from the Deployment's image tag β€” a different representation, which can disagree with what is running.
Two *UnderGate alerts are guarded on the publish counter advancing Correct semantics, but it means the case where publishing itself stops is invisible to every rule in the file β€” the #212 shape, which was found by inspection because no alert could fire.

Shipped

Server v3.64.0 β†’ v3.69.0 Β· worker v5.98.0 β†’ v5.99.1 Β· gateway v3.8.0. Full list on Releases.

  • *_build_info{version} in all three deployed binaries β€” the general fix, because pinning label values only works where the label set is closed and event_emit_failed_total{event_type} takes a free-form String.
  • noetl_event_ingest_publish_skipped_total{reason} separates the no_transport fault from gate_off and system_execution, both healthy.
  • All seven non-convergence-sweep outcomes pinned. That sweep terminates executions; "has it failed anything?" returned nothing.
  • ops#252 gains EventPublishNoTransport β€” the only rule that can fire on #212 β€” plus its runbook section. Still a proposal; nothing applied.

#242 fully dispositioned β€” 4 dead recorders β†’ 1 deliberate hold

  • Deleted: the NATS consumer-lag pair (worker#234) and the JetStream tailer trio (server#317). Both measured subsystems T5 removed.
  • Held: record_affinity_decision. This issue guessed "wire, not delete". Wrong: AffinityConfig::from_env has no non-test caller, so no AffinityDecision is ever constructed β€” the metric is downstream of an inert feature. Deleting it would strip instrumentation while leaving the feature, so enabling affinity routing later would come up silently unmeasured. Resolve with #116 / #172, not here.

Every deletion checked first: 0 prod ScaledObjects, 0 prod Rules/ClusterRules, 0 ops references. Three inert consumers found and repointed β€” the GMP rule, its kind-side VMRule twin, and two Grafana panels rendering empty on a backlog dashboard.

Proven end-to-end, not just unit-tested

Pulled the released v3.68.0 image into kind (multi-arch now works) and compared against released v3.67.0 with EHDB deselected:

signal v3.67.0 v3.68.0
pinned reason series 0 5
noetl_server_build_info absent {version="3.68.0"} 1

A freshly-started v3.67.0 with EHDB deselected exposed two metric families.

Traps worth keeping

  • kubectl set image was silently rejected β€” the container is named noetl-server, not noetl-server-rust. The rollout that followed reported success because a different patch had applied, so the deployment sat on the old image while claiming a clean roll. Caught only by reading the image back.
  • Intermittent GHCR push denial β€” denied: permission_denied on server:3.68.0-arm64, six minutes after 3.67.0 pushed fine with the same token. amd64 was cancelled by fail-fast β†’ a tag with no image. gh run rerun --failed fixed it. Verify the image exists, not just the tag.
  • A pin behind a config branch is not a pin. server#315 pinned inside if event_bus_mode.publishes_ehdb() β€” absent on exactly the configuration whose no_transport reason someone would read. Fixed in #316.
  • The doc comment was the drifted copy. Pinning the sweep outcomes from the metric's own comment would have shipped six of seven, leaving skipped_parent_active permanently unreadable β€” the same defect, introduced by its own fix. A guard test now fails if a Disposition variant is added unpinned.
  • Gateway GHCR tags carry a v prefix (v3.8.0); server and worker do not (3.68.0).

playbooks/drift-audit.sh now distinguishes HOLD from DRIFT, so a decided item stops re-reporting on every run β€” a check that always fires trains its reader to skim it, the same failure as one that never fires.

2026-08-05 (part 6) β€” every env var is documented, and two prod flags do nothing

Prod untouched. server v3.62.1 / worker v5.95.3 / gateway v3.7.1.

The deliverable

drift-audit env-docs reports OK for all three components. 225 distinct NOETL_* variables β€” server 59, worker 151, gateway 15 β€” every one named on its component's wiki.

New: noetl-gateway deployment specification. Rule 2a names the gateway as needing one and it was the only deployed component without it. Three things it now records that were nowhere:

  • The gateway refuses to start without NOETL_EVENT_FEED_ADDR or NOETL_KV_ADDR β€” an explicit bail!. That is the opposite of the server and worker, whose equivalents fall back to a quiet default.
  • /health is a liveness check, not a dependency check. A gateway that has lost its events feed stays Ready; the only symptom is the log.
  • The gateway has no metrics endpoint and no PodMonitoring β€” namespace gateway has no scrape configuration at all.

What got written down beyond names is the fallback, because that is the failure mode: NOETL_STATE_INDEX_TTL_SECS / _MAX_BYTES discard 0 or a non-numeric value and revert to unbounded, restoring the condition behind #163. And two interactions a name list cannot carry: TTL and byte-ceiling are safe only together with REHYDRATE_ON_MISS, and the four *_SOURCE selectors are independent switches, so a cutover that misses one leaves a single consumer pointed elsewhere with nothing cross-checking.

Two production flags that do nothing

flag shape
NOETL_STATE_AFFINITY_ROUTE (worker, true) reader exists, has no caller β€” 14a3f1f deleted the steering block during T5
NOETL_EVENT_SOURCE (gateway, ehdb) no reader at all; EventSourceMode::from_env_value is called only from its own tests

Of 77 variables set in production, exactly these two. The narrowness is the result β€” the platform is otherwise wired.

They needed different sweeps: the first from asking whether each reader is reachable, the second only from the inverse β€” what does prod set that nothing reads. Neither would have found the other.

Both are most likely superseded, not lost: per-shard consumer groups subsume execution affinity, and the gateway is EHDB-only by construction post-T5. But the code cannot distinguish superseded from silently dropped, so both are recorded as decorative rather than as either working or broken (#242).

The gateway one leaves a stale promise in a doc comment: "anything unrecognised is nats, so a typo can never silently take the SPA's live updates off their working path." There is no nats path to fall back to.

Three ways my own counts were wrong

The worker's variable count went 48 β†’ 107 β†’ 151 across three corrections, each a different read idiom the scan did not match:

idiom missed
env_bool("X", …) and other typed helpers 55
const X_ENV: &str = "X" then env::var(X_ENV) 44

Server and gateway use the direct idiom exclusively, so each time the pattern was wrong, two of three components still reported consistently. A majority looking right was never evidence the pattern was right.

And twice I wrote family tables with elided names β€” …_BATCH, _MAX_READ_LIMIT β€” leaving 18 and then 8 variables unfindable by anyone grepping for the exact name in their manifest. Caught both times by re-running the check after committing, which is the argument for having it.

Pointers: worker-wiki@246c903, gateway-wiki@aebc494, server-wiki@da86105 (ai-meta@5327e110). #241 closed.


2026-08-05 (part 5) β€” three tests that could not fail

Prod untouched. Nothing deployed; prod stays on server v3.62.1 / worker v5.95.3. Everything below is released-but-undeployed.

Started as a routine "verify these PRs compile before merging, since neither server nor worker runs PR-level CI". That verification found the defects.

The family

repo what consequence
worker#226 command_completed_context_marks_a_parked_step carried no #[test] never ran
server#305 test_issue_89_scalar_renders_unaffected, same shape, in the wasm drive core never ran
worker#227 EnvGuard's unsafe set_var claimed cargo test serialises tests raced

The first two share a cause: a later test was inserted between an earlier test's doc comment and its fn, so the newcomer absorbed the #[test] (two attributes β€” a warning) and the original was orphaned as dead code. Both pass once enabled; the invariants held, they were simply unguarded.

The worker one is not incidental. Its own doc comment: "Without it… the sweep would terminate healthy parked work." That sweep ran against production the same day, terminating 3,183 executions, with its guard test inert.

The server one is worse in a different way β€” production code at template.rs:142 cited it as the guarantee and said it "has been RED on main". Both halves were false: #231's fix had landed, and the test was not running at all.

The third is a soundness bug, not a hygiene one

EnvGuard justified an unsafe std::env::set_var with "process is single-threaded for these tests via the module-level serialisation cargo test enforces." cargo test enforces no such thing β€” it runs on a thread pool.

cargo test --lib keychain                        FAILED, 1-5 of 15 (varying)
cargo test --lib keychain -- --test-threads=1    ok, 15/15

Two reasons it stayed invisible, both worth keeping:

  • The full suite was green (568/568). 568 tests spread across the pool rarely collide, so the race hides at full scale and appears the moment a developer filters to what they are working on.
  • cargo build --lib compiles none of it β€” EnvGuard is behind cfg(test). Only --all-targets or a test build sees it.

Fixed with a process-wide lock held for the guard's whole lifetime, reentrant via a thread-local depth count (a test commonly holds three guards; a plain Mutex deadlocks on the second). std::sync::ReentrantLock says this directly but is still unstable on rustc 1.97.1.

Also merged

worker#224 (NOETL_KEYCHAIN_STRICT, default off), worker#225 (state-builder build-latency histogram), server#304 (ref_not_found vs decode_error), server#303 (multi-arch server images).

server#303 was verified against the registry, not the workflow text: ghcr.io/noetl/worker:5.96.0 publishes amd64 + arm64, server:3.62.1 publishes amd64 only β€” #236 in one line, and proof the ubuntu-24.04-arm runner already works for this org.

server#304 ships observability with zero tests. It compiles and the flag genuinely reaches record_orchestrate_drive("ref_not_found") β€” checked, after a day of finding inert things β€” but it does not meet the "test that fails pre-fix" bar and is recorded that way rather than passed silently.

Released

worker v5.96.0, v5.97.0; server v3.63.0 β€” tags read back, not assumed. v3.63.0 predates #303, so it is still amd64-only; multi-arch begins with the next server release.

Verified after merge: worker main 575/575 (was 568) and the previously-flaky keychain filter 20/20; server orchestrate-core 143/143, --all-targets clean.

Measurement note

Verifying the server case with cargo test -- --list across a git stash cycle reported the same total for both states and appeared to show one test swapped for another β€” a stale binary racing cargo's fingerprinting. Static attribute-parsing settled it: main collects 22 in that file, the fix 23, gaining exactly the scalar test and losing none. drift-audit.sh check 9 now finds this class without a build; its first cut had two flaws (flagged production handlers, and missed the server case because a comment naming the test counted as a caller).

Pointers: cli, e2e, ops, worker, server, cli-wiki (ai-meta@c6876fc3).


2026-08-05 (part 4) β€” the manifest that was meant to fix the monitoring gap was itself broken

Prod untouched. Nothing was applied to the cluster in this part; all reads.

The correction that matters

Part 3 recorded podmonitoring-noetl.yaml as "correct and simply unapplied, verified 2026-08-05". Only the server half had been verified. Checking the worker half against live pods rather than reading the file:

half selector verdict
server app: noetl-server-rust, port http matches; port exists; /metrics serves 75 noetl_* series
workers app In (…4 names…), port metrics stale β€” omits noetl-worker-system-pool-shard1

noetl-worker-system-pool-shard1 is the #166 Phase 5 second system shard, live in prod since that rollout and never added to the list. Two of the four names the list did carry (noetl-worker-rust-subscription-pool, noetl-worker) do not exist at all.

Applying it as written would have produced a green PodMonitoring scraping half the system pool, with nothing distinguishing that from full coverage. The failure mode is specific: a materializer-lag alert stays quiet exactly when the lagging shard is the unscraped one. Partial monitoring coverage emits no error.

Fixed in ops#252 by selecting on worker-pool: Exists β€” verified that label is on all four worker pods and on no other pod in the namespace (excludes server, cmdbus writer, both CronJobs), so a pool added later needs no edit. Also confirmed: ns noetl has exactly one applied PodMonitoring (noetl-cmdbus-writer), so the server and all four worker pods are unscraped today.

Made mechanical

playbooks/drift-audit.sh gained check 8 (scrape): running pods that no applied PodMonitoring selects β€” the inverse of check 7 ("is what we declared running?"). It reports the 5 unscraped pods and correctly does not flag noetl-cmdbus-writer-0, which is its built-in negative control. The rule agents/rules/representation-drift.md now names the generalisation: an enumerated selector is itself a representation, a copy of the workload set that nothing forces to agree.

Ops PR sweep β€” 9 open β†’ 7

PR disposition evidence
#230 closed 1Gi/2Gi already in main and live on both system pools; the #163 rationale comments are in main too
#231 closed #166 Phase 1 env already in main and live; its only residual was NATS_URL = inline plaintext credential for a deleted namespace
#229 merged the inverse of stale β€” see below
#225 kept open 3 metrics + 2 label literals all verified still emitted post-T5; no NATS residual
#251 merged MCP raw-body leak, 7 call sites across 3 agents

#229 was the interesting one. Not stale β€” the missing declaration for things already running: the noetl-state-builder-watchdog CronJob (9 days, */3 * * * *, matching image and service account) and httpGet /livez liveness on both system pools. main declared neither, so a cluster rebuilt from the manifests would have lost the watchdog and reverted to the tcpSocket probe that stayed green through the ~22h login outage of #163. It was blocked by the same pre-T5 NATS_URL residual as #231; merging origin/main in resolved it cleanly, with zero config-level NATS references left (the 37 remaining nats matches are all prose). All four alert exprs were checked against metrics the worker still declares, down to the label literals drain_dead / connect_error at state_builder.rs:1647,2000,2112 β€” a renamed label is the usual way an alert goes quietly inert.

Negative results worth recording

  • No orphaned metrics in server or worker. 70 worker metrics, 66 server; all four server candidates flagged by the name heuristic are wired (event_write.rs:592,596,607) β€” the lazily-registered recorders just have names that differ from the metric names. The #206 inert-metric class does not exist there.
  • The atomic-item-context contract (NOETL_ATOMIC_ITEM_CONTEXT) is reachable, not inert β€” env-settable, default off β€” and its module doc already serves as its spec, so no parallel document was written.

Trap

zsh applies history modifiers to $var outside braces, so git show "origin/$br:ci/manifests/…" silently loses :c and fails with a mangled path. Third zsh-specific trap this session, after set -- $var not word-splitting and k() {…} colliding with an alias.

Pointer: ops@927f5ba (ai-meta@949667f6). Issues touched: #238, #175.


2026-08-05 (part 3) β€” no alerting exists on prod, and the events bus is unscraped

Prod untouched since the morning roll β€” server v3.62.1 / worker v5.95.3, all lags 0, cursor_errors 0, 6/6 Running, every session flag unset.

The headline is #238: prod has ZERO alerting. Not misconfigured β€” absent:

GMP rules / clusterrules / globalrules / prometheusrules   0   (cluster-wide)
Cloud Monitoring alertPolicies                             0
Cloud Monitoring notificationChannels                      0

…while metrics ARE ingested (11 ehdb_* series). Every incident in this workstream had to be found by a human looking at the right thing.

The zeros needed work to be trustworthy. The first check used gcloud alpha monitoring policies list, which is not installed and returned a plausible "count: 0". Re-run against the Monitoring REST API with a positive control β€” the same API returns 5 ehdb_* metric descriptors β€” so the API works, the project is right, and nothing evaluates the metrics.

And collection is partial in the worst place. The writer hosts both buses and exposes the events-bus metrics on a NAMED port, events-lag (9106). Nothing scrapes it, so ehdb_events_group_lag and ehdb_events_cursor_errors do not exist in Cloud Monitoring β€” the exact signals the 3h24m consumer wedge needed. Post-T5 the events feed is the sole writer of the durable log, so the most load-bearing surface is the least observable. podmonitoring-noetl.yaml (server

  • pools) turned out correct and simply never applied β€” noetl_server_* ingested descriptors: 0.

ops#252 proposes seven rules, each tied to the incident it would have caught, plus the missing scrape and a bootstrap runbook. Nothing applied; with zero channels a firing rule notifies nobody.

#234 widened twice more. It is not manifest drift but a live prod dependency: 36 of 307 REGISTERED catalog playbooks name the pre-migration project, including the v67 planner and every MCP provider. Health check (read-only): the old project is ACTIVE, the secret lives, the WIF pool is ACTIVE, last keyed deploy 2026-07-06 SUCCESS β€” healthy but unexercised for a month, and the runtime identity does not line up.

Ops PR sweep: only #230/#231 superseded; #229 is the inverse β€” its CronJob has run in prod since 2026-07-26 while its manifests are on no branch.

Five measurement traps caught by disbelief, and the fifth was mine to own: gh issue list --limit N is a CAP, so "40 open / down to 34" in earlier reports were the limit echoed back. The real total is 55, stable across limits, and was 55 throughout.

2026-08-05 (part 2) β€” the roadmap loop, and a rule that kept finding new instances of itself

Continuation of the deploy+drain run. Prod untouched since the morning roll β€” server v3.62.1 / worker v5.95.3, all lags 0, cursor_errors 0, 6/6 Running, every session flag unset.

Closed: #170, #185, #188, #191, #193, #233. #185 closed on binary-level proof rather than inference: crane blob the largest layer of each published image and strings | grep -ci duckdb β€” worker:5.95.3 returns 0, worker:5.96.0 returns 51,793. Paired, so the zero is meaningful.

#236 filed and fixed (server#303). ghcr.io/noetl/server has always been amd64-only while the kind cluster is arm64 β€” so every kind validation of a server change needed a local build, the etcd-risk operation, and kind ran hand-built images no release produced. Writing the fix surfaced a second defect: adding the matrix naively would let github-release fire before publish-manifest created the tag it names. Found by diffing the job graph against the worker's rather than assuming the matrix was the whole change.

Shipped: worker#224 (NOETL_KEYCHAIN_STRICT, default off β€” an unresolved keychain alias currently renders empty and sends Authorization: Bearer ), ops#251 (raw provider bodies leaking into error_card.description), playbooks/drift-audit.sh.

The drift rule kept earning its keep. Written this morning from five instances, it reached seven by evening β€” #204 records the Cloud Build AR path as LIVE while publish-ar has failed on every release today, and #201's five stage boxes are unticked with 5.0.0 live on PyPI. Then the audit script found the eighth: #234 is 33 files, not 8 β€” including automation/iap/gcp/deploy_gke_stack.yaml and gke_autopilot.yaml, so the drift is in the playbooks that would rebuild a cluster from scratch, not only in manifests describing today.

Two premise corrections. #207 claims four files import noetl.outbox; none do β€” all eight hits are the SQL table noetl.outbox, and 24 of the 30 real edges are one auth/credential cluster. #211 asks for two IAM grants; the bucket one is already applied and only roles/serviceusage.serviceUsageConsumer is missing β€” GCP reports that denial in terms of the bucket, which is why a storage grant was the natural wrong fix.

"Audit the siblings, not the named file" paid twice β€” ehdb#311's accept-loop sweep, and #175 where the leak was fixed in hotelbeds.yaml but not its two siblings or google-places.yaml. The discriminating move was counting raw-detail call sites, not the parameter: firestore and ollama show user_message=0 yet never pass a raw body, so they do not leak.

Three measurement traps, all caught by disbelieving a clean result: \b in git's ERE (0 occurrences everywhere), zsh not word-splitting set -- $pr (βœ… for ten merges that never happened), and git grep -c <ref> emitting ref:file:count so $2 sums paths (noetl run = 0 where it was 796).

2026-08-05 β€” 13 PRs merged and deployed, 3,183 stalled executions drained, and DuckDB found missing from the shipped worker

Prod is on server v3.62.1 / worker v5.95.3 (now v5.96.0 building). Rolled by digest after crane GHCR→AR, in dependency order, verified between each. The writer restart was clean: recovered_active_records=0 on both engines, command cursor resumed at exactly 64383, all three event groups clamped=false replay_records=0. A live execution then COMPLETED and moved the cursors — a positive signal, not just zeros, since lag=0 reads identically for drained and never published.

Mass-drain: 3,286 β†’ 78 stuck, 3,183 terminated via real playbook.failed, negative control (a live execution fired mid-sweep) COMPLETED. Everything touched was ~159 days stale, from automation/agents/mcp/firestore (89%), fixtures and tests. Sweep returned to default-off afterwards.

I aborted the canary once on a false alarm. noetl.execution.status sat at RUNNING|3225 while 3,183 executions were being terminated, and I read that as the drain being ineffective. It is a frozen Python-era column β€” nothing in the Rust path reads or writes it, its newest row is 2026-06-11, and today's executions are not in it at all. The API was right the whole time. Filed as #235.

DuckDB had already silently vanished from the shipped worker. The manifest says noetl-tools = "3.19.1"; the lockfile resolved to 3.26.1, past the version that made duckdb optional, and the worker never enabled the passthrough β€” libduckdb-sys entries in the worker lockfile: 0. The PR guarding against exactly this had been sitting as a draft while it happened (worker#183, now merged). Nobody edited the pin; a caret range did it.

New rule β€” representation-drift.md. Five instances in one run: manifests pinning a dead GCP project (#234), two sets of unticked checkboxes for shipped work (#194, ehdb#241), the frozen status column (#235), and the caret range (#185). Each is a claim about the world outside the code, so no test has an assertion site. The check is two questions: what forces this to agree with reality, and if it disagreed how would anyone find out.

#228 sized against real data. The proposed (execution_id, step_name, attempt) key has 5,655 colliding groups β€” 5,140 of them __orchestrate__, legitimately re-issued. Excluding it, 515 remain, spanning 2026-05-07 to yesterday, with 13,195 orphaned PENDING commands but only 10 groups actually double-executed. The derived guard is weak, not absent. Schema change not attempted β€” the 515 need dispositioning first.

Also: ehdb#314 turned ehdb main red on cargo fmt (fixed, ehdb#315); publish-ar failed on both releases (the known #211); #193 swept 81 run-verb occurrences across ops/e2e/docs.

Two measurement errors caught by controls: a \b in ERE that git silently does not support returned "0 occurrences" for every repo, and a merge loop using set -- $pr (which zsh does not word-split) reported βœ… for ten merges that never happened.

2026-08-04 (part 2) β€” thirteen PRs, three systemic drifts, and a class of bug that passes its own tests

Headline. Continuation of the same day's run. Nothing merged, nothing deployed, prod never mutated β€” verified at close: server v3.61.0 / worker v5.93.0 / gateway v3.7.1, both command lags 0, out_of_order_appends 0, 6/6 pods Running, and every session flag (NONCONVERGENCE_*, PERMANENT_LOG_LEAN, *_SINK_GATE, AUTOSINK*, all five EHDB tier flags) unset on every workload. kind restored to default-off.

The recurring finding: a fix reachable only from tests is not a fix. noetl/ai-meta#191 had three stacked layers of it β€” an extractor written against an assumed envelope, a fold_eventlog_with_coverage whose every caller was a test, and then a tracing::warn! on a code path whose binary installs no subscriber. Each layer passed its own tests. Fixed in tools#93 + cli#77.

cli#76 root-caused after months of "flaky". Two defects, one per half of its title. The randomness was HashMap iteration order over a naive substring replace: with status and status_code in scope, status_code == "200" becomes 200 == "200" or ok_code == "200" depending on which key came first β€” measured 858 true / 1142 false over 2000 runs. The "never fires" half was a case-sensitive falsy check against a Python-style "False". cli#78 fixes both; ops#249 records it while keeping the IaC's no-when: rule, because neither defect explains the 4.19.0 symptom.

Three systemic drifts, none guessed at. An orphan noetl-cmdbus-writer-1 Deployment on an 8-day-old image that drain_legacy could never have removed (ops#247); the plaintext NATS credential still in 14 public-repo files (ops#248, closes #188); and 8 manifests still pinning the pre-migration project noetl-demo-19700101 (#234 β€” filed, deliberately not fixed, because the correct pin is a promotion decision).

#186 all three bugs, plus #231, plus ehdb#311. ehdb#311 retires the standing "never bare-connect :9104/:9107/:9108" rule β€” that rule was a workaround for a bug where one malformed connection silently killed a face for the life of the process, and a bare connect-and-close (any tcpSocket probe) reproduced it.

Two self-caught errors, both reported rather than buried. gh api .../comments paginates at 30, so .[-1].id overwrote a July-16 comment β€” recovered byte-exact via GraphQL userContentEdits. And a temp-index gitlink bump left the real index stale, so the next ordinary commit silently reverted the wiki pointer.

#261 re-run: contention, not regression. durable_segment 255 β†’ 181 ev/s, but the box ran a live etcd on the same SSD and the tier is fsync-bound. Two internal controls agreed (within-run rotation ratio unchanged; non-fsync-bound local_reference did not drop). The finding is that the baseline records no machine-state protocol.

Nine decisions parked, listed in one place on ai-meta#233 and in the end-of-run summary.

2026-08-04 β€” T5 was done and nobody had ticked it; two prod drifts and a plaintext credential removed

Headline. The #194 umbrella still showed T0–T5 all unchecked for work that has been live in prod for days. Verified directly against shastaratech-noetl-prod rather than trusting a prior write-up: no nats namespace, nothing named nats cluster-wide, no NATS PVC, and no NATS_* variable on any workload in ns noetl or ns gateway. NATS is not degraded or idle β€” it does not exist. Ticked T0–T5 and posted the measurement.

Prod state, measured. server v3.61.0 / worker v5.93.0 / gateway v3.7.1. Both command subject lags 0, ehdb_feed_shard_committed{shard=0} 64,359, ehdb_l0_out_of_order_appends 0. All three events materializer groups (noetl_materializer, noetl_result_materializer, noetl_state_materializer) at lag 0, ehdb_events_cursor_errors 0. The #227 sweep is present-but-inert.

One writer hosts both buses over three PVCs β€” 9100-02 cmdbus, 9103-08 events incl. SSE :9105 and KV :9107. There is no separate events-bus workload, which is easy to misread as "the events bus was never deployed".

A gateway log that reads as a live incident is not one. EHDB lifecycle feed error; reconnecting … Connection refused at cursor=5729 looks alarming until dated: last line 19:09:31, inside the writer-restart window, zero lines since. Under a retry loop whose backoff caps at ~5 s, ten minutes of silence is positive evidence of a reconnect β€” a still-broken gateway writes ~120 lines in that time.

Two prod drifts.

  • A noetl-cmdbus-writer-1 Deployment at 0 replicas on an image 8 days older than the running writer β€” predating the #203 ordering fix and the #208 restart fixes β€” plus its endpoint-less ClusterIP. drain_legacy walks ordinals 0..shard_count-1, so an ordinal above the count is outside its loop by construction; no converge would ever have removed it, while the IaC kept reporting a converged cluster. ops#247 adds a bounded prune_orphan_shards: deletes only what it can prove inert, hard-errors on a Deployment above the count with replicas > 0 or a Service above it with endpoints, and never touches PVCs β€” an orphan shard's durable log is the one artefact the playbook cannot re-render.
  • The plaintext NATS credential, still in 14 files in a public repo. ops#248 removes it; closes #188. Runtime no-op β€” server has no reference, gateway v3.7.1 reports "nats":"removed", worker reads it via unwrap_or_default() into dead materializer arms, so an empty string is what prod already has. It also settles a contradiction: automation/ehdb/ehdb_runtime.yaml:137 lists those vars in remove_env, so converging the IaC strips what ci/manifests/ declares. Rotation does not apply β€” there is no broker to authenticate to.

#195 + #196 β€” both halves PASS in kind, still default-off. slimmed 0β†’2 / 22,633 bytes; D1 result 773 bytes carrying a reference; D2 1 of 1 command.issued rows tiered, context down to 150 bytes; sub-floor control untouched in both halves; payload still readable through the API. The safety property #196 rests on was observed, not trusted β€” peak 2 live noetl.command rows still carrying a full context while the command was live.

Two rig defects, both the confident-null pattern. The D2 fixture had a large output and an empty workload, so its command context sat under the floor and D2 correctly stripped nothing β€” reported as a D2 failure for a playbook that was never eligible. And a port-forward re-established 1 s after a rollout produced six FAILs and empty execution IDs. pf() now waits for Available plus a live /health and exits 3 rather than emitting a verdict: a measurement failure must not be able to present itself as a result about the thing measured.

Parked, surfaced, not acted on. The mass-drain flag (3258 eligible), and the #194/#195 boundary β€” the strip is forward-only and reclaims none of the 1689 MB (59% of the permanent log) already there.

Artefacts. ai-meta 0364954d, 1c1245d1; ops#247, ops#248; comments on #194, #195, #196, #188, #222; playbooks/195-lean-log/RESULT.md; playbooks/210-t5-readiness/pre-t5-checklist.md marked superseded.

2026-08-04 (cont.) β€” muno provenance answered from the log; #227 part B built, and its negative control caught a grace that ate healthy work

2026-08-04 β€” Nothing deployed to prod. server#298, worker#214, ehdb#313 open. #224 closed.

The muno executions: 20 of 29 never ran on this cluster

Asked where they came from. The API cannot answer β€” GET /api/executions?status=RUNNING is a paginated-recent semantic capped at ~1000 candidates, so the previous session's "70 stuck executions" was a window artifact. The real count is 3359.

ingest_time defaults to now(), which in Postgres is transaction-start time, so rows sharing it to the microsecond are one transaction. 570,623 events share 2026-05-18 04:31:47.130085 β€” the noetl-demo-19700101 β†’ shastaratech-noetl-prod migration. 1699 of the 3359 carry that stamp, including 20 of the 29 muno runs. They were already non-terminal in the source cluster, and importing an event log cannot resurrect an in-flight execution.

The other 9 are all user_uid=guest with chat-<nanoid> SPA thread ids and hand-typed QA text (trip to Lndon, what hitel I need to stay in paris); the 2026-06-25 one is thread_id=_smoke-rust-fix-20260625. muno is test-only, from the data β€” the earlier reading of it as "a real user-facing business execution" was an over-read of the path name. It ran 18 s, every step succeeded, all five branches evaluated false, and no terminal was ever emitted.

⚠️ Unrelated finding, higher priority than #227: 6247 events across 2665 executions (2026-02-26 β†’ 2026-05-29) carry plaintext OpenAI + Anthropic API keys in workload.keychain. The leak stopped (muno moved to GSM secret paths) but noetl.event is append-only and never purged, so the keys are permanent β€” in two projects' backups after the migration. Response-boundary scrub hides them from the API; a Postgres read shows them in full. The only remediation is rotating both keys β€” a human action, flagged, not performed.

#227 part B β€” the predicate is progress, not shape

#171 sees one shape (a command claimed by a dead worker) and is blind to the other three: never claimed, no successor issued, imported history. So eligibility keys on the one thing they share β€” the watermark stopped and nothing can move it β€” as six conjunctive conditions. Terminal via emit_event β†’ playbook.failed through the #103 chokepoint. Default off.

The negative control failed on run 1, and that is the useful part. At grace=120s under load: 6/6 positive controls terminated, 5/5 pre-existing terminals untouched, and 30 of 344 healthy executions terminated β€” chains complete through command.completed end success, failed 126 s later. They were inside the orchestrator's finalization tail: measured with the sweep off and no restarts, last command.completed β†’ playbook.completed is p50 206 s, p90 210 s, max 393 s, 40/40 finalize. The predicate was right; the grace was wrong. MIN_NONCONVERGENCE_GRACE_SECS = 3600 now raises any lower value at startup, loudly β€” verified live (configured=120 effective=3600). 9Γ— the observed max; the 86400 s default is 220Γ—.

Predicate dry-run against the real prod table, read-only: 3258 eligible at 24 h grace, youngest 3.57 days stale (a 3.6Γ— margin), 408 ms warm, no seq scan. Also fixed: execution.cancelled was not recognised as a cancellation terminal, so 234 deliberately-cancelled prod executions report RUNNING for ever.

#209 β€” the crash didn't fail to recover the tail, it deleted it

PartWriter::open_active opened the active part with .truncate(true), so a hard kill destroyed the unsealed tail with no manual recovery β€” up to seal_max_records (1024) per shard as a ceiling, though the writer was later measured sealing at 20-140 records, making typical exposure tens rather than ~1000 (see the 2026-08-04 attribution below). Now replayed using the frame codec's existing torn-tail contract, truncated to the last intact frame, reopened in append mode β€” and the engine lifts global_sequence above the recovered tip, or the next writer-assigned key would land behind every follower cursor (the silent drop of #203). Dataset-generic, so it covers the KV face too; KvCoordinator::flush_and_wait closes the graceful half.

#229 was mine and it was wrong β€” the publish path works

I filed #229 ("prod's events feed carries no traffic") on two executions of system/scheduled_cleanup. should_publish excludes system-pool playbooks by design β€” they drain the stream, so publishing their own events would be circular. I tested the one class of execution designed to take the INSERT path, twice, and read the flat cursor as a defect.

The decisive test with a non-system playbook: published 0 β†’ 19, group_committed 5729 β†’ 5748, all three lags back to 0. published == cursor delta == 19. Closed as not-planned with the refutation recorded. The daily-volume argument was wrong too: of 3912 rows/day, 3691 are non-system β€” they did publish; I never sampled the cursor while they flowed.

What survived is real, and is now shipped. lag == 0 reads identically for drained and never-published, and prod is system-only often enough that the second is routine. #230 β†’ worker#217: ehdb_events_feed_tip = committed + lag, consumption-independent, so a gate can assert tip advanced by N AND lag returned to 0 β€” which an idle window cannot satisfy. soak.sh now ends with an explicit vacuity check: tip_delta <= 0 is a FAIL, an unreadable tip is INCONCLUSIVE, and only a positive delta licenses the lag-0 readings above it.

The inert-gate pattern β€” six in one session

Six mechanisms turned out present, plumbed, tested and incapable of doing anything. None was caught by a suite; all six were green. playbooks/inert-gate-audit-20260804.md records the three-grep check, the audit's results, and the organising distinction: NOETL_AUTOSINK_TARGET is read, stored, logged and never acted on β€” and that is fine, because its module doc says so. Inert-and-documented is a shadow slice; inert-while-described-as- working is the defect.

I then produced one myself: I said merging server#286 made the Feather-GC sink gate "real". It moved the inertness from a hardcoded empty set to an empty table β€” noetl.sink_pending had no producer. worker#218 is that producer (#199 Slice A), writing both stores, because with only one written the two GCs disagree about which executions hold un-sunk context. Also corrected: the durable-segment GC's gate was never outstanding β€” it is real and reachable.

#199 Slice A root-caused: 403, not "not wired" β€” and the fix has two halves

I reported Slice A as FAIL(reachability), "the producer exists; nothing calls it". Wrong. The worker's own log, from the pod that ran the step:

sink-state post rejected … action="mark"    status=403 Forbidden
sink-state post rejected … action="confirm" status=403 Forbidden

The predicate fires, mark and confirm are both posted, and the server rejects them β€” /api/internal/* is token-gated and my post_sink_state (worker#218) sent no bearer. Silent by construction: post_sink_state swallows errors on purpose, because bookkeeping for a default-off gate must never fail a connector step that has already written to the customer's store. The only evidence was a WARN and a counter β€” both of which I added in the same PR, and both of which are what found it.

worker#221 sends the bearer, and that alone is not sufficient: the pool that runs sink steps has no token to send. noetl-worker-rust lacks NOETL_INTERNAL_API_TOKEN in kind and prod β€” before Slice A it had no reason to call /api/internal/*, while the system pool has had one all along, which is why it was easy to miss. ops#246 adds it as a PR, not a kubectl set env β€” patching prod imperatively is the move that produced the drift catalogued in #222. Kind got the patch; prod did not. playbooks/199-sink-gate/PROD-ENABLE-CHECKLIST.md holds all three prerequisites, ordered so step 3 verifies the feed is alive before step 4 flips anything.

The boundary is 59% of the permanent log, not 83% of one column

I measured D1 (step results) at 461 MB and reported it as the prize. Then measured D2, the command input context (#196), which rides the same flag and call site:

noetl.event, all partitions 2858 MB
D1 β€” over-floor step results 461 MB
D2 β€” over-floor command contexts 1229 MB (90,297 of 92,556 rows)
combined 1689 MB = 59% of the permanent log

D2 is 2.7Γ— D1 β€” I under-reported by nearly two-thirds. One flag addresses all of it going forward; the existing 1689 MB has no reclamation mechanism and by the append-only invariant may deliberately never have one. Both parked decisions.

#209 β€” settled and CLOSED (supersedes the section below)

The instrumentation answered it. After a hard kill with a verified 7296-byte unsealed active part, all three engines log recovered_active_records=0 at open β€” printed right after L0Engine::open returns, so the path ran and found nothing. The writer's real seal cadence is 20, 69, 1024, 21, 24, 51, 140, 1024: most parts seal at 20-140 records, so the unsealed window is small and every kill landed outside it. The records survived because they were already sealed β€” retiring my "something else replays the tail" guess. ehdb#313's recovery is correct (18/18 on real captured bytes); the issue's "up to 1024 records" was the worst case read as typical, overstated ~50Γ—, now corrected everywhere.

⚠ (superseded) #209 was open pending attribution

The SIGKILL test ran on v5.94.0 with preconditions verified rather than assumed (PVC-backed storage, an active part grown 2260 β†’ 6960 bytes, 18 intact frames parsed directly from the file, container startedAt changed). Result across three kills: recovered_active_records = 0 β€” while the records survived, each restart producing a new sealed part covering exactly the missing range.

The engine code is correct on production data: I captured the real 6804-byte file, laid it down in the real layout, and opened an engine over it β€” 18/18 recovered. Cargo.toml and Cargo.lock both pin ehdb f84c82b.

So the gap is between a correct engine and a deployed writer that does not show it β€” and I could not close it by inference, because I removed the only log line when dropping ehdb-l0's tracing dep, leaving the metric as the sole signal. A counter reading 0 cannot distinguish did not run from ran and found nothing. worker#219/#220 log the count at engine open on all three L0 engines; the next hard kill settles it.

⚠ worker#219 was committed as diag(209): and produced NO RELEASE β€” semantic-release ran, chose no version, no image. Merge green, CI green, tag never moved, the change correct and undeployable on main with nothing saying why. Named in release-versioning.md; worker#220 carries it forward as fix:.

All five merged and released β€” sweep ships OFF, drain parked

repo PR released
ehdb #313 merged f84c82b (pin-consumed)
worker #214 + #215 v5.93.0
server #298 v3.61.0
tools #92 v3.26.1

Versioning per the new release-versioning.md rule: merge commits with conventional subjects, semantic-release chose every number, no Cargo.toml hand-edited, verify-version green on both binaries. The re-pin blocker is resolved — ehdb#313 merged first, worker#215 was re-pinned from the branch SHA to ehdb main f84c82b and only then merged, so worker main points at a merged commit rather than a branch. publish-ar failed on both binaries as expected (#211); GHCR→AR by crane.

Prod baseline recorded before touching anything: all three group cursors at 5729 with lag 0, cursor_errors 0, out_of_order_appends 0, both command subjects at lag 0. Rollback digests recorded for server (v3.60.2) and all four worker workloads (v5.92.2).

NOETL_NONCONVERGENCE_SWEEP_ENABLED is not being set. The mass drain is parked for an explicit go: the eligible set is 3258, not the ~22 originally scoped, and terminating thousands is a different decision from clearing twenty.

⚠ The #209 SIGKILL test's first version proved nothing

kubectl exec … kill -9 1 is a no-op against this writer and looks like a pass. The process runs as PID 1 in its container (ps: 1 0 noetl-worker), and the kernel discards signals to PID 1 from inside its own namespace unless a handler is installed β€” the standard init protection. The exec returns 0, the script continues, and nothing dies: the first run reported a clean pass with restartCount=0 and the container still up from five hours earlier.

Now killed from outside the namespace (--force --grace-period=0, kubelet SIGKILL, no SIGTERM, no hook), plus a kill_landed gate comparing the container's startedAt before and after β€” a test that cannot tell whether it did the thing is worse than no test. Pre-fix baseline recorded on worker 5.92.2: kill_landed=yes, and ehdb_l0_recovered_active_records is absent β€” the metric does not exist there, so the unsealed tail is gone with no signal at all.

Re-validated at the production grace β€” the negative control is clean under load

The floor works live: setting NOETL_NONCONVERGENCE_GRACE_SECS=120 produced configured=120 effective=3600, and the ENABLED line reports the effective value, so an operator cannot read one number and get another.

healthy executions fired during the sweep window 435
COMPLETED / RUNNING (recent) 407 / 28
wrongly FAILED 0

What it did terminate is the right set: six executions, stalled_secs 3603–3659, all last_event_type=command.completed, all from the earlier grace=120 run's load and stranded in-flight by my own server rollouts an hour before β€” none finalized in the 3600s since, against a measured finalization maximum of 393s. #227's own stranding shape, reproduced accidentally and then cleaned up correctly. Ordering is longest-stalled-first as designed.

Paired evidence: all three events groups at an identical committed cursor (15240) with lag 0, cursor_errors 0, out_of_order_appends 0, publish errors 0. ⚠ One number needed chasing β€” ehdb_feed_shard_lag read 5680 and rising with the shard cursor frozen at 3265 while executions completed normally. Per-subject explains it: commands.shared.shard.0 (the real pool) is at 0, and the 19 pinned records are my own positive controls on dead subjects. A group's committed cursor is the acked prefix, so records nobody will ever claim pin the whole-shard aggregate forever. That is exactly the isolation ehdb#303 added for #194, confirmed by accident β€” the autoscaler triggers per-subject, not on the aggregate. It also means the void-pool control technique must never be used on prod.

#209 β€” and the fix's own test caught the fix

Adding engine-level tests immediately found two holes in my own patch that the PartWriter unit tests structurally could not see, both about when recovery runs. Recovery rode the lazy ensure_writer: append_writer_assigned mints its key from global_sequence before the append that would open the writer, so the first post-crash append landed at or below the recovered tip (assigned 1 against a tip of 4 β€” the #203 silent drop); and a read never opens a writer at all, so recovered records were invisible (0 of 5). As originally pushed, the PR passed its unit tests and still did not fix the read path. Now recovered eagerly in open_replicated_with_metrics.

#209's KV half closed too β€” and a cross-repo pin trap

graceful.rs listed the KV face as an open follow-up. Now sealed, via three pieces, because the obstacle was structural rather than a missing call: KvCoordinator::flush_and_wait on the ehdb side (the engine was unreachable from outside the coordinator), WriterShutdown::push_sealable (the KV store is built after the events host builds its shutdown, so the sealable set cannot be complete at construction), and KvSeal using Handle::block_on β€” correct because seal_all runs sealables inside spawn_blocking, which is a blocking thread and not an async context. worker#215.

⚠ The pin trap, worth recording: noetl/worker main pinned ehdb 704a9bd, which is on ehdb main and carries #226's FeedWriter::seal_and_close β€” but the #209 branch was cut from ddb7ac9, before that landed, so the branch lacked a method worker main already calls. The KV wiring needs #226 and #209 in one rev. Resolved by merging ehdb main into the #209 branch (59 test binaries green); worker#215 must be re-pinned to a merged ehdb main SHA before it merges.

#191 fixed, not just described

3.25.0 added a working extractor beside the broken one rather than repairing it, so extract_facts_from_eventlog still returned zero facts silently, and the silence β€” the issue's actual core β€” survived in both. One extractor now, plus fold_eventlog_with_coverage returning considered/matched with is_suspicious() and a WARN, so "records present, none understood" stops looking like a clean slate. tools#92.

#224 closed

The version line had self-repaired (main and newest tag both 5.92.2). What remained was the runbook step that caused it β€” P1a still said "hand-bump Cargo.toml, then tag". Corrected, plus --squash β†’ --merge, and codified in agents/rules/release-versioning.md so it survives the next session.

2026-08-04 (overnight, cont.) β€” #227 part 1 built and proven; parts 2+4 turn out to be server-side and cross a safety guard

2026-08-04 β€” Nothing deployed. worker#213 open; ops#245 merged (bb291c5).

The root cause was narrower than first written

rehydrate_execution_from_wal opened a NATS JetStream pull on noetl_events. NATS was deleted 2026-08-01, so every cold-rebuild died in connect β€” prod logs: DNS error: failed to lookup address information: Name does not resolve. That is the whole of rehydrate{outcome="empty"} being 100% (491/486, served never once). With event_scans_total 0 by design, chain_walk is the only reader and rehydrate its only recovery, so a chain gap from a writer restart was unrepairable. Same class as #221 β€” a NATS-era path surviving the cutover β€” but hard-coded, so #221's fail-loud work could not catch it.

A proposal of mine was withdrawn: "don't TTL-evict actively re-driven executions" is already implemented (build_offserver_input calls idx.touch). The evictions{ttl} counts were genuinely idle chains. Single root cause, single fix.

Part 1 β€” shipped to a PR, and the tests earned their keep

The EHDB arm replays the same retained feed the live drain consumes, through the same apply, filtered to one execution. The new unreachable-writer test caught a defect in the fix itself: the first draft bounded only the replay loop, and a TCP connect to an unreachable writer blocked 75 s against a 500 ms deadline β€” on the drive's miss path, the #130/#156 latency class. deadline_ms now bounds connect and each read; the test returns in 0.50 s.

Four negative-controlled tests; gate 588 passed / 0 failed, clippy clean. Validation is load-holding unit/integration + green gate β€” no kind e2e (a multi-hour Rust build beside a live kind control-plane is the documented way to corrupt etcd on this host).

Parts 2 and 4 β€” stopped deliberately

playbook.failed is emitted by the server's orphaned-command sweep (#171), not the worker. It is already enabled in prod and worked exactly right:

NOETL_ORPHAN_SWEEP_ENABLED=true
noetl_orphan_sweep_total{outcome="candidate"}  2
noetl_orphan_sweep_total{outcome="terminated"} 2

Two candidates, two terminated β€” the two that self-terminated at 08:22:11. The other 22 are not orphaned: their commands are held by live, heartbeating workers, and the sweep's guard says "Never fails live work." Draining them needs a new server-side eligibility condition keyed on provable non-convergence, which deliberately crosses that guard. Land the predicate slightly wrong and it kills healthy in-flight work on a platform with no bus fallback β€” a human trade-off, not an unattended 4am change. Design written up on #227; part 4 is server-side too (the duplicate command.issued comes from the server's re-drive).

Unambiguous: deploying worker#213 fixes the cause but will not drain the 22. Whether any are still inside feed retention cannot be determined from outside β€” the retention floor is not on :9106 and :9104/:9108 must not be probed (ehdb#311) β€” so recover-vs-fail is not quantifiable.

Also

⚠️ The P4a rollback capture put the #188 plaintext NATS_URL and the gateway Auth0 client id into public git history at f87e468; removed forward in 6dbc9aa/3f3c74d, not rewritten (safety.md). NATS is gone so it authenticates nothing, but treat it as disclosed and fold rotation into #188 β€” flagged there.


2026-08-04 (later) β€” P4 converged, post-P4 re-validation clean, and #227 root-caused

2026-08-04 β€” Autonomous overnight run, continued. Prod is on the IaC; the StatefulSet behaves identically to the Deployment it replaced; #227 is diagnosed and the obvious remedy is ruled out.

P4 — the Deployment→StatefulSet handover

Converged from ops#245@5e28543. The plan was read before applying and the three gates held: storage=claim with zero volumeClaimTemplates (the three live PVCs adopted by exact name, same PV UIDs), no PVC deletion, and the 8th-defect selector reported explicitly β€” "apply alone would keep the extra key and the Service would select nothing". Post-converge the Service selects an endpoint, not <none>.

The log came through intact: all three event groups resumed stored=4083 tip=4083 from=4083 clamped=false replay_records=0; the command bus clamped=false. A deliberate writer-pod drop cost nothing β€” the reason P4 was gated on #226.

Post-P4 re-validation β€” no regression

Mid-burst restart on the StatefulSet pod: both hosts sealed in 308 ms (316 ms on the Deployment), groups resumed at 4675 clamped=false, same PVCs mounted, consumers reattached 6==6, backlog drained in 40 s. 22 COMPLETED / 0 FAILED, 0 dup, out_of_order 0, cursor_errors 0 across 899 s, exposure peak 223 commands / 293 events. One synthetic execution COMPLETED with +31 cursor against 31 durable events.

Worth noting: the writer Service's statefulset.kubernetes.io/pod-name selector survives a pod restart β€” the replacement pod takes the same name and the Service re-resolves. The 8th-defect fix is durable, not one-time.

#227 root cause β€” a rehydrate that never succeeds

The worker says it outright: "off-server drive (stateless): WAL chain incomplete; returning no-op, server reconcile will re-drive". The decisive evidence is the rehydrate metric's own HELP text, which defines four outcomes β€” served / incomplete / empty / throttled β€” of which only empty is ever observed (491/486, and 72 on a freshly-restarted pod within minutes; served never once). With event_scans_total 0 by design (RFC #115 tenet 3), chain_walk is the only reader and rehydrate its only recovery β€” so once an execution's chain is missing from the pool-side WAL index it is unrecoverable, and fallback_incomplete exactly equals stateless_retry (462/462, 455/455): the loop never converges.

All three stall shapes trace to it β€” a step executed twice then stalled (the guardrail re-issued while the bus also redelivered: a secondary non-idempotency defect), one limping a single step per re-drive 16 minutes apart, and one that finished its last step but never emitted playbook.completed because that needs one more state build.

Not a regression: ~25–30% stall rate across all three bursts (v5.92.0/Deployment 19/2/9, v5.92.1/Deployment 23/0/7, v5.92.1/StatefulSet 22/0/8), flat through the #225/#226 fix and the handover.

Negative finding: restarting both system pools β€” the remedy that cleared #225 β€” recovered zero stalled executions; fresh pods re-accumulate incomplete/empty within minutes. A cold rebuild does not help.

But a clean path exists: two stalled executions emitted a real playbook.failed on their own, so the orchestrator already has a legitimate terminal transition. The proposed fix is a bounded retry that ends there β€” explicitly not hand-writing terminal events into the append-only log. Fix proposed, not implemented (gated).

P4-result.md Β· P5-postconverge-and-227.md Β· ai-meta f87e468 + 5029500.

⚠️ Two process notes: the P4a rollback capture briefly committed whole-namespace manifests containing a plaintext NATS_URL credential (#188) and the gateway's Auth0 client id β€” removed in 6dbc9aa/3f3c74d, though the blobs remain in history since main is never rewritten. And prod now matches an unmerged ops#245 branch, which should be merged promptly.


2026-08-04 β€” the seal now reaches every writer host under load: worker v5.92.1 deployed and prod-proven (#226, #225 both CLOSED)

2026-08-04 β€” Autonomous overnight run: merge β†’ release β†’ roll β†’ prove. Both defects found during P2 are fixed and validated on the real cluster under load.

What shipped

worker v5.92.1 (worker#212 abf9ca5) on ehdb 704a9bd (ehdb#312), deployed by digest noetl-worker-rust@sha256:aee71e87e061ee81…. Server and gateway untouched.

Root cause of #226: the seal's leaked MutexGuard (std::mem::forget) parked async appenders on blocking std mutexes, starving the two tokio worker threads β€” so host 2's seal never started and even the 15 s timer never fired. Fixes: flag-before-lock seal (no leaked guard; a post-seal publisher gets a retryable error), phased graceful::seal_all across hosts, partial-seal ERROR + noetl_worker_shutdown_hosts_sealed/_total, plus keepalive and heartbeat on the :9104/:9108 events faces for #225 β€” the semantics #208 had given the command claim path only, which is precisely why commands recovered from a writer restart and events silently did not.

The prod proof β€” mid-burst SIGTERM with backlog in flight

sealing EHDB writer hosts before exit             hosts=2
... both ingest faces stop, both cursors persist, both logs seal ...
EHDB writer hosts all sealed β€” shutdown complete  hosts=2
Worker stopped

316 ms for both hosts, phases interleaved across them. v5.92.0 under the same load stopped dead after host 1.

The resume is the number that decides it:

v5.92.0 under load v5.92.1 under load
hosts sealed 1 of 2 2 of 2
events log on reopen 390 below cursor 1 above cursor
clamped true false

replay_records 1 and 76 are at-least-once redelivery, not loss. #225: consumers reattached 6 == 6 on both pools with no half-open sockets and drained the backlog themselves to lag 0 β€” no rollout restart.

Paired evidence: 23 COMPLETED / 0 FAILED, 0 duplicate event ids, out_of_order_appends 0, cursor_errors 0 across 435 s, every group cursor to lag 0. First real prod exposure bound: min(1024, lag) peak 245 commands / 326 events, far under the cap. Hard-kill not run.

Two benign release-path snags

ehdb#312 was red on cargo fmt --all --check (one re-export out of sort order β€” fixed surgically, not with a blanket cargo fmt --all, which reflows unrelated files across these crates), then red on a latency benchmark whose own output blamed 122 ms runner fsync while p50/p90 were healthy; a re-run passed. semantic-release cut v5.92.1 correctly from the v5.92.0 tag rather than the stale Cargo.toml, so #224 did not bite and verify-version passed.

Residual β€” #227

7 executions stalled in RUNNING after the restart, with the guardrail re-issuing for them indefinitely (~2 rec/s on an idle cluster). Not a seal or reattach failure β€” nothing lost, nothing clamped, nothing parked β€” and it predates the fix (the same burst on v5.92.0 gave 19 COMPLETED / 2 FAILED / 9 stalled). 16 stalled executions on prod need cleanup.

P4

Clear on the #225/#226 criteria β€” the writer-pod drop P4b performs is now proven survivable under load. #227 argues for a quiet window before triggering it.

playbooks/223-ehdb-prod-runbook/P2-under-load-proof.md Β· ai-meta 95e07a2 + d8a69af.


2026-08-04 β€” both P2 defects root-caused and fixed: the seal was starving the runtime, and the events faces had no liveness (#225, #226)

2026-08-04 β€” Two prod defects, one root cause each, both on paths the idle tests exercise and pass. worker#212 + ehdb#312 open. Not deployed, not rolled β€” held for the prod-deploy gate.

#226 β€” the seal ate the clock that was supposed to catch it

The graceful SIGTERM sealed the command bus and never reached the events feed. The absence in the log turned out to be the evidence: main wraps the whole sequence in tokio::time::timeout(15s, worker.shutdown()) and its expiry line never appeared either. A slow seal cannot do that. A starved runtime can.

EngineSeal::seal_and_hold enforced "nothing is acked after the seal" with std::mem::forget on the engine's MutexGuard. That does stop post-seal appends β€” by parking every appender on a mutex that is never released. And every append path in ehdb-feed takes that std::sync::Mutex blocking, from inside an async task (serve_ingest's committer, the claim and WAL readers), so each parked appender burns a whole tokio worker thread. The prod worker's CPU limit is 2, so two are enough. With a backlog in flight the runtime had no threads left, Worker::shutdown's per-host loop was never polled again, and the events log went out unsealed β€” 390 records below its persisted cursor, three groups resuming clamped=true. Worker threads also drive tokio's timer, which is why the budget could not fire.

Idle, nothing appends during the seal, nothing parks, both hosts complete in 568 ms. That is precisely why every existing test passed.

Two changes. FeedWriter::seal_and_close keeps the guarantee with a flag set before the lock is taken and re-checked under it, then releases the lock β€” so a post-seal publisher gets an error it retries against the replacement writer instead of hanging. And graceful::seal_all phases across hosts β€” stop all ingest β†’ quiesce once β†’ persist all cursors β†’ seal all engines on one blocking thread β€” because host-at-a-time was wrong independently of the starvation: host 2 kept accepting and appending for the whole of host 1's sequence, then spent its own deadline from a standing start against the same shared budget. A partial seal is no longer invisible until the next boot's clamped=true: ERROR log plus noetl_worker_shutdown_hosts_{sealed,total}.

#225 β€” the redial arms were unreachable, not missing

Reading the three faces settled it. The command claim face (:9101) got keepalive + a negotiated heartbeat in #208; the events group-claim face (:9104) had set_nodelay and nothing else β€” no liveness at all, and the WAL fan-out (:9108) had keepalive but no application-level heartbeat, so it could not tell "writer alive but stuck" from "feed idle" β€” the one case keepalive structurally cannot catch, because keepalive is answered by the peer's kernel.

The sharp part: claim_loop and run_drain_loop_ehdb already had correct redial logic. Those arms were simply unreachable, because a half-open read never produces the Err that triggers them. No retry logic downstream could have helped. Both faces now carry keepalive on accept and connect plus the same negotiated heartbeat, with the primitives lifted to the ehdb-feed crate root so all three share one definition. Backward compatible both ways (#[serde(default)] on both request frames; a client whose peer never heartbeats disarms and falls back to keepalive). noetl_worker_events_consumer_redials_total{face} makes the reattach visible β€” a counter that could not have moved while the consumers were wedged.

The regressions, each with a negative control

Idle tests already passed and missed both, so every new test holds a backlog in flight or kills a peer, and each is measured against the defect still reproducing with the fix disabled.

both_hosts_seal_with_a_backlog_in_flight 8 concurrent appenders loading both logs at SIGTERM; 2 of 2 sealed; both logs reopen at exactly what they acked β€” clamped=false, 0 records below cursor
negative_control_the_leaked_guard_starves_the_runtime (ehdb) the old shape still starves a two-worker runtime; observed over a std channel, because a starved runtime cannot time its own starvation
negative_control_host_at_a_time_... the v5.92.0 order observably lets the second log keep growing through the first host's whole sequence
negative_control_{group_claim,wal_subscription}_wedges_without_heartbeats with heartbeats off, a half-open consumer still parks silently
group_consumer_reattaches_after_a_writer_restart_and_drains detects, redials, drains the whole backlog β€” including the record stranded on the orphaned coordinator-side claim

Measured on the real wiring: with the old seal restored, the under-load test hangs and its own tokio::time::timeout never fires β€” the prod failure, reproduced. So completion is watched from outside the runtime, turning a regression into a 30 s failure with a diagnosis rather than a wedged CI job.

Gate. worker 552 lib tests + all integration binaries, 0 failures; the 6 existing idle seal tests and 7 graceful unit tests unchanged and green; shutdown suites 5/5 under repeat contention; clippy clean. ehdb 58 test binaries, 0 failures, clippy clean. No manual version bump β€” semantic-release owns it (#224).

Not done, and why

No kind end-to-end. Building a worker image needs a full release Rust build on the same 6-CPU podman VM hosting a live 12-day-old kind control plane, and that combination has corrupted etcd here before; no pre-built image carries these fixes. The bar met is the load-holding repros against the real host wiring plus the green gate.

The 9 stuck prod executions left in place. Re-checked read-only: all vars_test/test_vars_block from the 04:28:10 burst, chains truncated to 6–24 events against 37 for a healthy run β€” that truncation is the #226 tail loss. There is no server API to cancel or terminate an execution; the only write path is POST /api/events, so "clearing" them means synthesizing terminal events into an append-only source of truth whose chains already have holes, trading a truthful record for a fabricated completion on the very rows that evidence the incident. Test playbook, no user impact. Prod left as found.

⚠️ P4 still needs one under-load graceful restart on a real cluster before the #222 converge β€” the seal claim now rests on in-process tests.

2026-08-04 β€” P2 aborted: prod's events consumers had been wedged for 3h24m, green the whole time (#223, #225)

2026-08-04 β€” The soak did not fail a latency or durability gate. It never got far enough to measure one. P4 is NOT clear.

What the first regime found

Twelve executions at concurrency 4 (under the 8-slot pool β†’ unsaturated, the regime that measures the bus). Four were submitted before the anomaly was unmistakable: noetl.event β€” the append-only source of truth β€” had received no writes since 23:03 UTC, a 3h24m gap.

Signal
ehdb_events_group_committed, all three groups frozen at 3137 across a 642 s 5 Hz window
ehdb_events_group_lag rose to 24, stayed there (>0 for 88.8% of samples)
materializer_drained_total 36 β€” exactly P1's execution-#2 count, then nothing
last log line, every worker pool 23:03, against a 02:27 wall clock
/readyz, cursor_errors, out_of_order_appends, command shard_lag ready, 0, 0, 0
writer faces all ten listening, 6 ESTABLISHED on :9104

Connected, healthy-looking, consuming nothing. No loss, no duplication, no out-of-order append, no cursor error, no dead face β€” none of the classes #203 / #208 / #221 / ehdb#311 describe. A new silent-failure class.

The user-visible shape: each execution gets its first command claimed and then stops β€” cmd_committed advanced +4 for 4 executions where a healthy run of the same playbook produces ~19 records each. Under NOETL_STATE_BUILDER=offserver the drive rebuilds state from the WAL face, and with that consumer parked it can never issue a second command. It is invisible through the API too: /api/executions/{id} returns 404 because the projection tier's sole writer is the wedged materializer β€” a running execution reads as one that never existed.

The load did not cause it

The platform was verifiably healthy after P1's writer restart β€” all three groups drained 3103 β†’ 3137 at lag 0 at 23:03 β€” and the wedge developed during the 3h18m idle window that followed. Without the soak this would have surfaced as a user-facing outage the next time real traffic arrived, behind a green dashboard. P1's clean bill of health was accurate when taken and is not contradicted.

None of P2's five objectives have usable results

Throughput/latency not measured; cursor_errors 0 but at a trivial event rate; KV+SSE unexercised (no organic traffic at 02:20 UTC β€” the gateway holds an ESTABLISHED SSE connection on :9105 with zero feed errors, but :9107 KV dials lazily and is unverified); the graceful-restart-under-load re-proof not attempted. Critically, the sampler's min(1024, lag) = 24 is a frozen backlog behind a stopped consumer, not a lag distribution β€” reporting it as the prod exposure bound would be exactly the false pass this rig exists to prevent. The destructive SIGKILL variant was not run and was not authorized.

Left as found

The documented remedy (rollout restart of the consuming pools, per #161) was attempted, blocked by session policy, and not worked around β€” it needs explicit approval. Prod is degraded: commands still dispatch, events do not materialize, executions cannot advance past their first command; four test executions stuck; all pods Running with 0 restarts.

Artefacts

playbooks/223-ehdb-prod-runbook/P2-result.md Β· ai-meta b3c4b7a Β· new defect #225.


2026-08-03 (evening) β€” P1: worker v5.92.0 in prod, and the writer's seal finally covers the events log (#223, #209, #221)

2026-08-03 β€” P1 of the EHDB prod runbook: released, rolled and verified on shastaratech-noetl-prod. P2 (soak) and P4 (IaC converge) are held for an explicit go.

What landed

  • worker v5.92.0 (worker#211, merged as ddb4de8) β€” deployed by digest noetl-worker-rust@sha256:ad1e96063bf5…81725. ai-meta pointer 4f35c5d. Server (v3.60.2) and gateway (v3.7.1) not rolled β€” no new rev.
  • #221 CLOSED β€” an unset/blank/typo'd *_SOURCE on an enabled consumer is now a startup error instead of a silent fall-through to the deleted NATS transport.
  • #209 graceful half CLOSED, crash half still open.
  • #224 opened β€” semantic-release vs the manual release bump.

The bug the release gate caught, and why the gate is worth running

The runbook's P1a gate went red on shutdown_closes_the_ingest_listener_before_sealing, and it was investigated as a suspected flaky harness. It was a real race, and it meant defect #2 of #209 was not actually fixed. WriterShutdown::run signalled with Notify::notify_waiters(), which wakes only already-registered waiters and stores no permit, while the acceptor runs under tokio::spawn(until_stopped(..)) and registers on its first poll β€” a shutdown firing before that first poll lost the signal permanently, so serve_ingest accepted straight through the seal.

What decided it was asking who holds the port: lsof at the moment of failure showed the writer host's own acceptor still in LISTEN, in the test's own process, after shutdown.run() had returned. The old assertion (connect must be refused) could not have told β€” a connect only reports that someone is listening, and free_addr hands out an ephemeral port sibling test binaries can take. Replaced with "we can re-bind the address", which only succeeds if this writer dropped its listener. Rate was load-dependent β€” 24/30 full-suite runs failed under contention, 0/15 idle β€” which is exactly why it read as flake. c44ebf2 replaces the signal with a watch plus an explicit ack after the listener's fd is dropped, so "stop accepting" is confirmed, not assumed.

The drain, and why it mattered

The writer being replaced still ran the old image, so on the way out it carried both the unsealed-tail bug and the lost-wakeup bug. Six identical samples (cmd_committed=39148, every lag 0, cursor_errors 0) put the exposure min(1024, lag) at 0 before the pod was replaced. Order: writer (kubectl set image, strategic β€” never --type merge), then the pools, container targeted by name. Nothing connected to :9104/:9107/:9108 (ehdb#311); face liveness read from the pod's own netstat.

Evidence

Paired, not a green count: a synthetic execution moved the command bus +19 and every one of the three group cursors +31, ending lag 0, with exactly 31 durable events recorded for that execution. cursor_errors 0, out_of_order_appends 0, all ten faces listening, 5/5 pods on the new digest with 0 restarts.

The payoff β€” a controlled SIGTERM on the new writer sealed both hosts in 568 ms:

sealing EHDB writer hosts before exit          hosts=2
command-bus ingest face stopped β€” listener closed
command-bus cursor persisted / log sealed on shutdown
events ingest face stopped β€” listener closed
events-feed cursor persisted / log sealed on shutdown

hosts=2 is the new part. The events writer previously never sealed under any circumstance, and under NOETL_EVENT_INGEST_PUBLISH_ONLY=true it is the sole writer of the durable noetl.event log. Every cursor resumed at its pre-SIGTERM value with clamped=false and replay_records=0 β€” no clamping needed, because the log was actually sealed.

⚠ semantic-release fought the manual bump

24 s after the merge, semantic-release pushed b11e6d5 chore(release): version 5.91.3, rewriting Cargo.toml from 5.92.0 back down to 5.91.3 and tagging v5.91.3. worker main now claims a lower version than the newest published tag, and two images exist with identical code. Prod is unaffected β€” built from the v5.92.0 tag at ddb4de8, deployed by verified digest β€” and the ai-meta pointer is ddb4de8 for that reason. The runbook's P1a manual bump is in direct conflict with this repo's semantic-release ownership of versioning; #224 tracks the decision.

Artefacts

playbooks/223-ehdb-prod-runbook/P1-result.md Β· ai-meta 4f35c5d (pointer) + 25a77ff (result doc).


2026-08-03 β€” P0 prod discovery, and the EHDB-only IaC hardened against what it found (#223, #222)

2026-08-03 β€” P0 was read-only. Prod was not converged, nothing was applied to it, and the IaC work was validated against local kind. P1–P5 remain unrun.

What landed

  • ops#245 @ 5e28543 (branch feat/ehdb-only-iac, not merged) β€” the EHDB-only IaC's defaults now describe prod rather than the kind-soak reconstruction.
  • ai-meta@f76c408b β€” runbook P3/P4 rewritten to be copy-run-safe; P0 discovery record extended with the kind validation and the seventh defect.

The six defaults that would have broken or misconfigured prod

Read-only discovery against shastaratech-noetl-prod compared ops#245's defaults against the live cluster. As written, action=converge --set profile=prod would have:

Default Was Consequence on prod
state_builder server Prod runs offserver on the server and both system pools, with NOETL_STATE_BUILDER_SOURCE=ehdb and a live :9108 WAL client. A converge would have turned a running subsystem off. The variable was also never written to the system pools at all β€” their builder mode sat entirely outside the IaC.
writer_claim_* un-indexed names Prod's PVCs are shard-indexed (noetl-cmdbus-writer-0-data, …). The un-indexed names came from the kind soak and match no PVC in prod β€” the converge would have stranded the live command and event logs.
user_pool_container worker Prod names every worker container noetl-worker. A wrong name is rejected, not ignored: a strategic merge on containers keys by name, so it appends a second, imageless container and the API server refuses the patch (containers[0].image: Required value) β€” aborting the converge with the earlier workloads already patched.
gateway_namespace / gateway_deployment noetl / noetl-gateway Prod's gateway is Deployment gateway in namespace gateway. gateway_reconcile=true failed outright; auto silently skipped the workload carrying the SPA's only live-update path.
writer_memory_limit 2Gi Prod runs 4Gi. A converge would have halved a live workload's memory limit and cut its requests, on the process holding both buses' L0 index.
system_pool_deployment one pool Prod runs two on commands.system.> (-shard1 is a second replica, not a shard-1 pool), so half the materializer tier stayed undeclared.

All six are now defaults carrying prod's measured value rather than another --set on a P4 command line that gets copied by hand under time pressure. The trade is that they become invisible at the call site, so the P3 plan diff is now the only place they are checked β€” the runbook carries a table with a kubectl command per row to re-confirm each against prod at apply time.

Two premises that were inverted

Prod runs all three materializer groups β€” :9106 shows noetl_state_materializer live at cursor 4022, lag 0, alongside the other two, and both system pools carry NOETL_STATE_SHARD_WRITE=true. So state_shard_write=true and the three-group verify_groups default are both correct, and the earlier instruction to narrow them would have made the rig's own verify read a live group as absent β€” which by its own "unreadable is a failure, never a zero" rule is a hard fail.

P4c also drops from a live change to an idempotence check: the ScaledObject is not paused, has a 3d-old HPA and a single EHDB metrics-api trigger with no NATS trigger. The #210 signature (absent HPA) is not present.

The seventh defect β€” invisible from reading

The kind rig was first rebuilt into prod's shape (writer as a legacy Deployment over shard-indexed PVCs, ns gateway / Deployment gateway, both system pools, 4Gi writer) because kind's own topology could not have caught any of the above. Running the adoption path then produced something no amount of reading would have:

$ kubectl -n noetl get endpoints noetl-cmdbus-writer-0
NAME                    ENDPOINTS   AGE
noetl-cmdbus-writer-0   <none>      12h

kubectl apply is a three-way merge: a key on the live object present in neither the applied config nor its last-applied-configuration annotation is preserved by design. Prod's per-shard writer Services were created imperatively during the cutover and carry no such annotation, so their existing app: noetl-cmdbus-writer-0 selector survives the converge and is ANDed with the pod-name selector the StatefulSet renders. The StatefulSet's pod is labelled app: noetl-cmdbus-writer (no ordinal) and satisfies neither conjunct.

The writer pod stays 1/1 Running with all nine faces listening, and nothing can reach it β€” the server's ingest, both pools' claim, the gateway's KV and SSE and the KEDA scaler all address it through that Service. A total silent outage behind a green rollout. ehdb_writer.yaml now forces /spec/selector with a JSON patch (the only shape that can drop a map key), prints the before/after, reports the impending replacement in plan, and fails the writer's status step when a per-shard Service has no endpoints.

Kind validation

plan β†’ converge β†’ converge, with profile=kind, writer_storage_mode=claim, gateway_reconcile=true, autoscaler_enabled=false:

  • plan β€” the three claim names, offserver on the server and both system pools, the gateway patch in ns gateway, 4Gi, probes on :9101 only, and the selector-replacement warning.
  • converge β€” the StatefulSet adopts the three pre-existing PVCs verbatim (volumes:, no volumeClaimTemplates, same PVs, no new volume); selector replaced; Service β†’ 10.244.0.57; all nine faces up; VERDICT: PASS with published == projected == 380, all three group cursors +380 at lag 0, cursor_errors 0, out_of_order 0, 3 SSE subscribers at 380 frames each.
  • second converge β€” clean no-op: no .metadata.generation moved on any of the five workloads or the gateway, writer pod UID unchanged, 0 restarts.
  • negative check β€” the old gateway targeting with gateway_reconcile=true still fails loudly, which is what it should have done on prod.

Also fixed: a backtick pair inside the render's unquoted heredoc was being command-substituted, printing ehdb_feed::serve: command not found on every render.

Pointers


2026-08-02 (late) β€” Three gcloud-blocked EHDB workstreams, sequenced into one prod runbook (#223)

2026-08-02 β€” Planning only. Prod was not touched and gcloud was not invoked. Three finished, kind-validated pieces of work were all waiting on the same re-auth, with dependencies between them that were not visible from any one of them.

What landed

Artefact Result
playbooks/223-ehdb-prod-runbook/README.md The sequenced runbook: six phases, each with preconditions, exact commands, a read-only pass/fail gate and a rollback. One-screen executive checklist at the top.
#223 Tracking issue, on roadmap board 3 (Todo).
ops#245 Commented β€” two un-threaded parameters found.
worker#211 Commented β€” release blocker + the env precondition matrix.
ehdb#261 Commented β€” the prod-soak half is now a gated procedure; the head-to-head should be retired.

The ordering, and the dependency that decided it

P0 re-auth + read-only discovery β†’ P1 release/roll worker#211 β†’ P2 prod soak β†’ P3 IaC plan β†’ P4 IaC converge β†’ P5 watch

P1 must precede P4. The IaC's Deployment→StatefulSet handover deliberately drops the writer pod once, and what makes that survivable is exactly worker#211's sequenced seal — before it, the seal raced process exit and the events writer never sealed at all, and that feed is the sole writer of the durable noetl.event log under NOETL_EVENT_INGEST_PUBLISH_ONLY=true. P2 must precede P4 because P4c unpauses the autoscaler, which changes the consumption-lag regime the soak's headline number depends on — and because a soak on the old topology isolates "the new image" from "the new topology" on a platform with no bus fallback (NATS deleted 2026-08-01).

The one thing that could have forced a different order β€” whether worker#211's fail-loud env has to arrive via the IaC β€” does not: the strict resolver only demands a _SOURCE var when its consumer is already enabled, so it is a read-only precondition check, not a re-order.

Findings that change the plan

  • ops#245 does not thread state_shard_write or the three claim_* PVC names from ehdb_platform.yaml to its children, so --set on the entry point silently has no effect on either. The PVC one risks mounting the wrong claims under storage_mode=claim; state_shard_write defaults to "true" and would start a third materializer group prod deliberately does not run (#217) β€” a silent topology change smuggled in by the IaC, which is the exact failure class this workstream exists to remove. Knock-on: verify_groups defaults to all three groups, so the IaC's own verify would fail on prod by its own "unreadable is a failure" rule.
  • worker#211's Cargo.toml still reads 5.91.2, an already-published tag; verify-version hard-fails on a tag/Cargo mismatch. Bump to 5.92.0 first.
  • The hard-kill measurement is more dangerous on prod than the kind write-up implies. A lost events tail is a permanent hole in the append-only noetl.event log. The runbook makes the non-destructive exposure bound (min(1024, lag) sampled through load) the default and gates the SIGKILL behind explicit human go-ahead, recommending against it β€” the deterministic bound already exists as a unit test (300/300 with the seal, 0/300 without).
  • The kind rig's port-forward problem is solved by moving the sampler in-cluster β€” a Job scraping the writer's ClusterIP at 5 Hz writing to stdout, so the record survives the writer dying. Never touches :9104/:9107/:9108 (ehdb#311).
  • playbooks/194-l1-t4-prod-iac/rollback.sh is dead β€” it rolls the bus back to NATS, which exists neither in the code nor in the cluster. Every rollback in the new runbook is image or topology only.

Irreversible steps, flagged

P1d / P4b writer-pod drops (no bus fallback) Β· P4b drain_legacy deletes the writer Deployment (rollback depends entirely on a saved YAML) Β· P4c unpauses live autoscaling Β· P2d hard SIGKILL destroys the unsealed tail by design and can permanently lose noetl.event records. Plus the three standing traps: storage_mode=template would strand the live logs, any tcpSocket probe against an events face kills it forever, and kubectl patch --type merge on the writer wipes its container spec.


2026-08-02 (evening) β€” The EHDB-only topology is IaC now: declared as noetl playbooks, kind-validated clean-state + idempotent (#222, ops#245)

2026-08-02 β€” The soak earlier today recorded "there is no EHDB-only IaC at all" as its third defect. This session closed that gap the way this project does infrastructure: as noetl playbooks run through the noetl CLI in local mode, not as loose manifests.

What landed

Repo PR / commit Result
noetl/ops #245 automation/ehdb/ β€” five playbooks + README. Open.
noetl/ops #241 Closed as superseded, with the reasoning recorded on the PR.
noetl/ops.wiki 9909111 EHDB-only topology + Home + sidebar
noetl/ehdb #311 Commented: the defect is broader than recorded.
noetl/cli #76 New: when: on a next.arcs entry does not reliably take its arc.

The playbook set

ehdb_platform.yaml (entry + cluster guard) drives ehdb_writer.yaml (writer StatefulSet, per-shard PVCs, per-shard ClusterIP + headless Service, all nine faces), ehdb_runtime.yaml (server / user pool / system pool / gateway env, NATS-era vars stripped), ehdb_autoscaler.yaml (KEDA on EHDB per-subject lag + the writer scrape) and ehdb_verify.yaml (the functional proof). action is plan / converge / status / verify / teardown; profile is kind or prod and refuses a mismatched context in either direction.

The writer became a StatefulSet. It is a singleton per command shard over an RWO volume; prod expressed that as one hand-copied Deployment per shard, which is how a two-shard cluster ended up with two files differing only in an index. A StatefulSet says it once, and the pod names come out noetl-cmdbus-writer-0, -1 β€” the names prod already resolves β€” so it is a drop-in rather than a re-address. storage_mode=claim adopts pre-existing named PVCs, converting a running Deployment without moving a byte of the durable log.

Kind evidence

Clean state (no StatefulSet, no Services, no ScaledObject, PVCs deleted) β†’ a working platform in 1m42s:

all nine faces bound (9100-9108)
accepted=20 errors=0 duplicate_ids=0
published +386   projected +386        -> published == projected == 386
noetl_materializer        cursor +386, lag 0
noetl_result_materializer cursor +386, lag 0
noetl_state_materializer  cursor +386, lag 0
cursor_errors 0 Β· out_of_order 0 Β· publish_errors 0
3 SSE subscribers, every one receiving frames
VERDICT: PASS

Idempotent: a second converge reports unchanged / patched (no change) / clean throughout and bumps no .metadata.generation on any workload; no pod restarts. Also validated: storage_mode=claim, and a prod-shaped Deployment→StatefulSet migration where the legacy per-shard noetl-cmdbus-writer-0 Deployment holding the RWO volumes is drained first and the logs survive.

Two findings

ehdb#311 is worse than recorded. It is not only a malformed frame that kills an events face β€” a connect that sends nothing and closes is enough; read_frame returns early eof and escapes the accept loop. The verify playbook's preflight was nc -z and it killed :9108 on its first run against a freshly converged writer, with one ERROR line as the only trace. A kubernetes tcpSocket probe on any events face would do the same every period, forever, on a pod that still looks healthy. Liveness is now read from the pod's listening sockets and nothing is ever connected to; the writer's probes point at :9101 deliberately.

cli#76 β€” conditional arcs do not work in the local runtime. A true when: takes its arc roughly half the time on 2.17.0 (measured over repeated identical runs, nothing varying) and never on 4.19.0. For IaC that means reporting success while reconciling nothing β€” the same silent-no-op class this topology keeps producing. So there is no when: in automation/ehdb/: linear chain, per-step case guards in shell. (4.19.0 also does not print shell stdout, so the plan renderings and the verdict are invisible there.)

Still open

Prod has not been converged from these playbooks, and was not touched in any way — no gcloud, left exactly as found. The remaining scope, the settings that are not optional on prod (writer_storage_mode=claim above all), and the cost of the Deployment→StatefulSet handover are all written down on #222.

2026-08-02 β€” First sustained-concurrency soak of the EHDB-only platform (kind): PASS, three defects, and a hard-kill number that could NOT be measured here (#209, #221, ehdb#311, ehdb#261)

2026-08-02 β€” The EHDB-only platform had only ever been validated with ~50 executions on an idle cluster, and there is no NATS fallback any more. This is the first time the events path has seen real concurrency. It passed β€” and produced three defects plus an honest "cannot measure this in kind".

What landed

Repo PR Result
noetl/worker #211 H5 fail-loud internal bus source + #209 graceful seal + face supervision. Open, not merged; no release cut.
noetl/ai-meta 257380ba playbooks/220-ehdb-only-kind-soak/ β€” the rig + results

Three fixes, all closing silent failure modes

  1. H5 (#221, newly filed). EventSourceMode still fell back to nats on unset / default() / a typo β€” pointing a materializer at a transport deleted on 2026-08-01. Now a hard error, resolved on the startup path for all four internal consumers; two of them had been resolving it inside their spawned task, where an error was a tracing::error! and a dead materializer. Proven in-cluster: NOETL_MATERIALIZER_SOURCE=ehbd β†’ crashloop in ~30 s naming the var and the fix.
  2. #209 graceful half. The survey found it worse than the issue said: the seal also raced process exit, and the events writer never sealed at all (its handler only checkpointed cursors) β€” that feed is the sole writer of the durable noetl.event log. Now a sequenced, awaited shutdown: stop ingest β†’ quiesce β†’ cursor β†’ seal-and-hold.
  3. Face supervision. Every face was tokio::spawn(serve_x(..)) with the io::Result discarded, so a dead face said nothing. Now logs at ERROR when an accept loop ends.

Soak: PASS on every paired-evidence gate

360 executions, 6 rounds Γ— 60 @ concurrency 30.

published_delta=6840   projected_delta=6840
all three group cursors committed_delta=6840, final_lag=0
cursor_errors=0  out_of_order_appends=0  dup_ids=0  publish_errors=0
sse: 7864 / 7864 / 7864 frames (3 subscribers, identical β†’ broadcast, not a queue)
kv:  880 ops, 0 mismatch, 0 err, 2627 ops/s, p50 13.40 ms / p99 40.60 ms

Deliberately broader than prod, which runs only two materializer groups (NOETL_STATE_BUILDER=server disabled the off-server subsystem, #217). This rig ran all three plus the state-builder WAL drain in shadow, so :9108 was under real load.

Writer restart

Graceful is a clean proof of #209: tip at SIGTERM 7781 with lag 570 β†’ reopened tip 7781, clamped=false. All 570 records sitting in the unsealed region survived.

The hard-kill number could NOT be measured in kind, and is reported as such rather than as the 0 it produced. The sample goes stale before the kill lands (reopened_tip came back higher than tip_at_kill), and the exposure is masked because consumption lag (~1100) exceeds seal_max_records (1024). The corollary matters: a healthy low-backlog system is MORE exposed than this rig, not less β€” the loss becomes observable exactly when consumption lag is smaller than the unsealed window, i.e. prod's normal state. The precise bound stays the deterministic unit test: 0/300 survive with no seal.

Kind's ceiling, stated plainly

The bus was never the bottleneck β€” shard lag sat at 0 through every round while the 8-slot worker pool saturated. A saturating burst in kind measures the pool, not EHDB; the same reason #205 could not be reproduced here. Correctness-under-concurrency and shutdown behaviour: proven. Throughput, dispatch latency, and the hard-kill loss: prod soak only.

Defects found

  • ehdb#311 β€” ehdb_feed::serve handshakes inside its accept loop, so one malformed connection permanently kills the WAL fan-out face, silently. Deterministic repro; found by a throwaway HTTP probe in the harness itself.
  • NOETL_STATE_BUILDER=shadow silently means Off β€” shadow is a separate flag, so a sensible-looking value disables the drain and :9108 never gets a client. Same class as H5.
  • There is no EHDB-only IaC at all β€” repos/ops main carries zero occurrences of NOETL_COMMAND_BUS. The prod topology was applied imperatively during the 07-31/08-01 cutover and never written back, so the kind rig had to reconstruct it from source. Bigger than ops#241.

ops#241 β€” reviewed, NOT merged

Textually clean, semantically overtaken. The writer already exists in prod as noetl-cmdbus-writer-0; merging would add a second, differently-named writer that nothing routes to but the merged GMP PodMonitoring would still scrape, doubling the ehdb_feed_* series the autoscaler dashboards read. Its "inert as shipped" premise (NOETL_COMMAND_BUS=nats) is dead and three NATS_* vars it sets were removed. Full review + the seven salvage changes posted on the PR; recommendation is close-as-superseded.

Still needs the human

  • gcloud re-auth β†’ prod soak (throughput, latency, hard-kill loss, gateway-driven KV/SSE).
  • noetl/worker#211 review + merge; no release cut.

2026-08-01 (later still) β€” NATS dead-code removed; platform still e2e green (#212)

2026-08-01 β€” server v3.60.2 / worker v5.91.2 / gateway v3.7.1; health β†’ "nats":"removed".

Check Result
Executions 50/50 COMPLETED
Durable log materialized 950
All three feed cursors 3072 β†’ 4022, lag 0
ehdb_events_cursor_errors 0
WAL chain incomplete 0
SSE frames 4022

The liveness survey earned its keep

Done before deleting anything, because NATS_FILTER_SUBJECT had already proved a NATS name is not evidence of a NATS dependency. Four NATS-named things turned out to be on the live EHDB path and would have gone with a grep-driven pass:

  • CommandNotification / segment_from_filter / claim_outcome β†’ extracted to worker/src/dispatch.rs
  • the gateway's NatsConfig bucket names and TTLs β†’ renamed KvConfig (they configure EHDB KV; only the connection fields were dead)
  • the server's coherence KvRead API β†’ reduced to a shell, API kept, because state.rs branches on Hit/Miss/Unavailable and that distinction is real
  • build_state_message β†’ the EHDB feed reader calls it

Two defects the removal itself surfaced

record_command_publish lived only in the NATS arm β€” deleting that arm dropped the command-dispatch metric entirely, with nothing erroring. lag_poller had already been dead in prod, early-returning because nats_subscriber() is None on the EHDB bus: alive in the code and the process list, no number reported since the T4 flip.

Silent defaults β†’ loud failures

A worker whose pool doesn't resolve refuses to start; a bus mode naming a removed transport logs "not delivered"; an empty gateway feed/KV address is a startup error. The same lesson this migration kept teaching, applied on the way out.

Tracking (issues + wikis)

Cleanup enumerated and closed as #220 β€” per-repo removal inventory, final versions, and the e2e evidence. Repo wikis updated to state NATS is fully removed, NOETL_FEED_FILTER_SUBJECT is primary, and nats-dr/ is the only recovery path:

  • noetl/server.wiki β€” deployment-specification (prereqs, readiness gate, dependency table, env catalogue, NOETL_REPLICA_COHERENCE), runtime-shape, Home
  • noetl/worker.wiki β€” deployment-specification (role, runtime contract, KEDA trigger now EHDB per-subject lag, env catalogue), nats-mcp-tool-kinds, Home
  • noetl/gateway.wiki β€” configuration (NATS section replaced by EHDB KV + feed vars), architecture, deployment, sse-events, Home

Deliberately not finished β€” #219

async-nats still ships in the worker (state_builder's legacy drain, the materializers' NATS arms, spool_runtime's nats_object), and the nats tool kind stays while five catalog playbooks still declare it. Both dead, both interleaved with live code or live catalog rows.

2026-08-01 (later) β€” Fully native on EHDB. The three residuals are closed, no workarounds left (#216, #217, #218)

2026-08-01 β€” 🟒 server v3.60.1 Β· worker v5.91.1 Β· gateway v3.7.0 Β· ehdb ddb7ac9. Zero NATS-named env vars cluster-wide; NOETL_STATE_BUILDER=server gone.

Check Result
Executions 50/50 COMPLETED
Durable log materialized 950
All three feed cursors 2048 β†’ 2998, lag 0
ehdb_events_cursor_errors 0 (was 26)
WAL chain incomplete 0
State-builder rehydration indexed=121, wal_events=2048
SSE frames 3093
NATS-named env vars 0

All three cursors advancing is the load-bearing line β€” the state-materializer cursor previously sat flat while everything else looked healthy, and a completed-execution count would have signed that off either way.

#217 β€” state builder natively on EHDB

The WAL drain reads the writer's new raw fan-out face (9108), replaying from cursor 0 per boot with no acks. A subscription, not a consumer group: a persisted cursor that outruns a restarted worker's empty index is the #119 stall, so replay-from-0 makes the index self-rehydrating by construction.

Two more defects fell out. The state materializer called ensure_consumer() unconditionally, so with NATS gone its loop exited at startup and it never ran β€” silently, group cursor flat. And TERMINAL_EVENT_TYPES lacked the dotted spellings the orchestrator actually emits, so per-batch terminal eviction never fired on the common case (#166) β€” surfaced by a new test.

#218 β€” the misnamed routing variable

NOETL_FEED_FILTER_SUBJECT is primary; the manifests use it alone. An unresolvable pool is now a hard error rather than a silent default to shared β€” a crashloop an operator sees in seconds beats a cluster that looks healthy while nothing progresses.

#216 β€” root-caused

CursorStore::store writes a fixed temp path, and GroupCoordinator::ack persists outside the group lock β€” so the two system-pool replicas' acks raced, and the loser's rename hit ENOENT. Fixed with a write lock. The test is verified meaningful: removing the lock reproduces 80 failures, every one the prod signature. And the failure is no longer silent β€” the last error is surfaced to the host, which logs it when the count moves.

Gateway OIDC test

oidc_bad_signature_rejected was red on main β€” a test bug, not a security hole. An RS256 signature's final base64url char encodes only 2 bits, so an end-flip usually produces an undecodable segment: the token failed at decode (oidc_invalid) rather than at verify. It was rejected either way. Tamper moved to the middle; a companion test pins that the undecodable case is still rejected.

2026-08-01 β€” NATS IS DELETED. The platform runs on EHDB alone (#212)

2026-08-01 β€” 🟒 Strategy change: with no real traffic on shastaratech prod, NATS stopped being a fallback. Flipped EHDB-authoritative, deleted NATS, and fix-forwarded until end-to-end green.

Final state

server v3.60.1 Β· worker v5.88.0 Β· gateway v3.7.0. GET /api/health β†’ "nats":"not_configured". Namespaces nats and nats-supercluster (6 pods) deleted. #188's plaintext noetl:noetl credential retired with them.

Check (NATS absent) Result
Executions 50/50 COMPLETED
Published to the EHDB bus 950 across 8 event types
Durable log materialized 950 β€” exact match
Feed cursors 2048 β†’ 2998
SSE frames to a live subscriber 2559
Publish / gateway errors 0 / 0

What shipped

ehdb#307 networked KV face (TTL at the boundary β€” the D4 fold is clock-free; expiry filtered on read, so a lapsed session is invisible before any sweep) Β· worker#202 KV host on :9107 with its own D4 engine Β· worker#203 MaterializerFeed adapter Β· gateway#34 session + request stores on EHDB KV Β· gateway#35 NATS callback listener removed Β· server#295 publish gate fixed. #213/#214/#215 closed.

Five defects, found by testing β€” two of which failed silently

  1. set image '*' swapped the curl init container for the worker image.
  2. The result + state materializers were still NATS-sourced, draining a stream nobody writes.
  3. The off-server state builder was a 4th, uninventoried noetl_events consumer. With NATS gone every orchestrate returned WAL chain incomplete and every execution stalled at RUNNING with no error anywhere.
  4. The gateway hard-failed at boot on missing NATS (? on start_nats_listener).
  5. should_publish required a NATS connection. Removing NOETL_NATS_URL made it false for every event, so the server quietly fell back to synchronous inserts while the EHDB feed sat at a flat cursor β€” the whole CQRS path inert, nothing erroring, executions still completing.

Defects 3 and 5 share a shape worth remembering: both looked healthy. A green execution count alone would have signed off on a platform whose event bus was doing nothing β€” which is why the acceptance evidence pairs published with projected with the feed cursor, and never trusts "executions completed" on its own.

Residuals

#216 cursor-persist errors (26, and not diagnosable from logs β€” an observability gap this work introduced) Β· #217 off-server state builder still NATS-coupled, so that drive path is off Β· #218 NATS_FILTER_SUBJECT survives as a misnamed var that still drives EHDB pool routing β€” unsetting it silently collapsed the system pool onto commands.shared.>.

DR snapshot (definitions + IaC, no message data) at playbooks/211-t3-events-migration/nats-dr/.

2026-07-31 (evening) β€” T3 shadow is LIVE on prod and publish parity is PROVEN (#212)

2026-07-31 β€” 🟒 The events fan-out now dual-publishes to NATS and EHDB on shastaratech prod, with 1:1 parity measured end to end. All four consumers stayed NATS-sourced. NATS untouched and authoritative. HOLD before any cutover.

Deployed (shadow only)

Workload Version Setting
noetl-server-rust v3.60.0 NOETL_EVENT_BUS=shadow
noetl-cmdbus-writer-0 v5.86.0 NOETL_EVENT_BUS_HOST=true β€” 9103/9104/9105/9106, own 50Gi PVC
system pool Γ—2, gateway β€” sources unset β†’ nats

publish-ar failed on every release (#211, expected); publish-image succeeded, so all three images went GHCR→AR by crane copy.

The two gates

Publish parity β€” PASS. 60 executions, 0 errors. 1140 = 1140 = 1140 across the NATS stream delta and both server publish counters, identical per-event_type distribution over all 8 types, 0 publish errors.

Projection parity β€” PASS. Read the feed back through the SSE broadcast face (its own per-subscriber cursor, so the read disturbed no materializer group) and compared against the rows the NATS-fed materializer actually wrote: event_id sets identical, 5460 field comparisons with 0 semantic mismatches, ordering strictly ascending and contiguous 1..1140 with no gaps.

One apparent created_at mismatch was the comparator truncating where Postgres rounds β€” max real delta 1.0 Β΅s of timestamptz narrowing, identical on both transports. Recorded so it is not re-litigated.

Consumer half merged, default-off

worker#200 materializer EHDB source, worker#201 SSE face, gateway#33 gateway EHDB SSE client. The gateway reader is a hand-rolled SSE client rather than an ehdb-feed dependency β€” the edge should not carry the L0 engine to read a socket β€” and its cursor makes reconnect exact, which is a real upgrade over core NATS, where a gateway restart silently loses the gap.

Caught in prod

The writer Service exposed events-sse targeting a container port the deployment never declared β€” a silently dead Service port. Found by checking rather than assuming.

Honest limits

The read API surfaces 7 of noetl.event's columns, so 8 were not compared row-by-row; the window was 1140 events on an idle cluster; and the consume path is unproven in prod (groups sat at lag 1140 by design).

The complete T5 path is now written down

playbooks/211-t3-events-migration/T5-REMAINING-SCOPE.md + #213 (noetl.callbacks.>), #214 (KV requests), #215 (KV sessions). #213 carries four independent signals that the callbacks subject is already dead, so its plan leads with a counter and a one-week observation window rather than a port.

2026-07-31 (later) β€” T3: the events fan-out gets an EHDB home; the publish half is merged, default-off (#212, #194)

2026-07-31 β€” The blocker the morning's T5-readiness audit surfaced, taken on directly. noetl.events.> now has a designed and half-built EHDB path. Nothing deployed; prod unchanged.

The finding that resized the work

The audit framed the events path as "the SPA loses live updates". It is larger than that. noetl-server-rust runs NOETL_EVENT_INGEST_PUBLISH_ONLY=true, so the server writes zero noetl.event rows β€” every event publishes to noetl_events, and the worker-side noetl_materializer draining it is the sole writer of the durable event log. noetl.events.> is the write path of the platform's append-only source of truth. Every cutover gate is sized to that.

What merged (all default-off; prod untouched)

Repo PR Result
noetl/ehdb #306 named durable groups main 25def72
noetl/worker #199 events writer host main 8aa2fab
noetl/server #293 NOETL_EVENT_BUS dual-publish main 6894886
noetl/server #294 noetl_events_projected_total main f4f821a

Two things the build surfaced that the design did not predict

events/project had no metric at all. The sole writer of the durable even

⚠️ **GitHub.com Fallback** ⚠️