Sessions Log - noetl/ai-meta GitHub Wiki
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.
cli#98 and cli#99 merged; semantic-release cut CLI 5.3.1 / executor 0.13.1 on every channel.
-
render_templateno 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
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.
-
AgentSessionis turn-by-turn because the CLI runs each agent tool as a sub-playbook, and its runner enters tokio itself. -
framework: claudebills an API key, and NoETL sees every tool call. -
framework: claude-coderuns headlessclaude -pon 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
thinkingblock and the run was reported as successful with empty text. Now an error that names the block types (tools 4.2.0). - β
render_templatere-expanded substituted values inHashMaporder. 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.
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.
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.
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 |
-
The prune floor would have reclaimed ZERO.
compute_prune_floorreturnedNoFootprintwhen every archivable execution is archived β which is exactly the steady state this drain produces. Every call would have returnedOk. 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 whyHotExecution::max_sequenceexists β added for this case, then never read. -
collect_candidatescould silently truncate. Capped at 200x100 and returnedOkon 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. -
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. -
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.
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.
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.
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".
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.
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.
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.
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.
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.
Umbrella: #455 Β· #460
Landed: noetl/worker#348 (d135e97d) β a
structural guard, zero src changes.
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.
"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.
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.
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.
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.
Umbrella: #455 Β· #460
Landed: server#511 (1753084b) Β·
spec 5ff3511
Observability + audit only. Nothing armed, no semantics changed.
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.
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.
/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.
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.
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 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.
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.
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.
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.
- β 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.
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.
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.
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.
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.
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.
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.
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 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.
- 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 Postgresnoetl.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.
- ββ 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.
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.
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β¦)
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.
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.
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().
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.
- A topology parser looking for a flat
membersreportedlive members: 0against a response that actually carried 6 β the shape iskinds[]. 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=*.rsunquoted in zsh returned "no matches found" for a file that exists. - A Python
str.replacesilently 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_byand confirmed by mutation.
#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).
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.
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.
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.
β 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.
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.
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.
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.
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.
| # | 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.
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.
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.
- Prod server is v3.123.2, not the v3.122.0 my index claimed.
- I read
registered=53, failed=0and 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
cargomissing on thePATHI exported. -
mk() {β¦}and$K get podsboth 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.
| 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.
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:
-
Repointed the entire travel business catalog at a project frozen two months ago.
Both projects hold a
(default)database and achat_threadscollection, so the wrong project returns an empty collection, not a 404 β and the planner treats an emptyslot_stateread as "a brand-new threadβ¦ not an error". Every conversation would have looked new. -
Dropped half of every turn's writes.
batch_write'sset_docs_extra/append_events_extrakeys β whichpersist_all_atomicallydepends 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.
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.
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.
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.
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.
Two files modelled business entities as internal objects, and I wrote both:
-
foreign_key_identity.rsβtable_rowwithcategories/10,category_types/1,trip_category/1,trips/100: adiona's business rows. Re-fixtured onto real internal references,playbook β mcpandplaybook β credential. -
localization.rsβresource_type: "category"withcategory_namein en/de/ka: adiona'scategoriesand its translations, business data twice over. Re-fixtured ontomemory, 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.
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.
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.
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.
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.
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."
-
attempt_total{outcome="unavailable"}is not a loss counter. It isAttemptOutcome::Retryableβ a failed attempt that gets retried. The counter to read it against ismirror_total{outcome="recovered"}, which says all 7 affected batches succeeded. I built a causal story ("one lost batch per roll") on a retry counter. -
I attributed
unavailable=2to rolls that predate the pod. The pod started 19:23:33Z; a counter cannot record anything before that. - β 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.
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.
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.
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.
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.
β 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.
-
The CI-gate table β wrong twice, and the published figure was also wrong.
The first pass reported
catalogas|| truebecause the grep matched|| trueinside 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 behindorigin/main, so I read the pre-#374 state. The truth: all 8 Rust repos gate clippy with-D warnings, all pin 1.99.0, onlyclicarries 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 againstorigin/<default>, never a local checkout. β And a third defect in the same three-line probe:head -1of the clippy lines pickscargo clippy --version, socatalogwould have misread as ungated even on a fresh tree. -
gh issue create --jsonis 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.
Two untracked findings from the catalog object-type enumeration are now filed:
-
#446 β
subscriptionis a first-class catalog type in the server with nonoetl.resourcerow fornoetl.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/mcpvalid 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#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.
catalog#19β#22. 142 tests, fmt + clippy -D warnings clean, AC3 still exactly 4
Dataset impls.
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.
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.
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.
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().
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.
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.
catalog#15-#18 merged on green. 120 tests, fmt + clippy -D warnings clean, AC3 still
exactly 4 Dataset impls.
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.
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.
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.
-
AC10 needs a server consumer β nothing in
noetl/serverlinkscatalog-store, so the crate has no presence on any serving path. -
The
noetl/catalogwiki repo does not exist:has_wiki=truebutcatalog.wiki.gitreturnsRepository 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.registrymerge, DDL ownership,Observedwrite volume, own PVC vs shared) are all decisions.
Recorded in STATUS as the explicit stopping point rather than re-emitting a report.
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.
| 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.
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
-
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 onlyRegistered, 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. -
partition()derived fromr.pathwould send a reader to the wrong shard, returning nothing rather than erroring. Both datasets now derive it fromindex_key(). -
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.
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.
| 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.
- β Absent is the correct off, not
0. The off-states are different shapes: the booleans are an allow-list, whileSOURCE's_ => Noneis a fail-safe for unrecognised values, so0reads 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
.imagewith field managerkubectl-setβ it would roll the image backward into the known-wrongevent_idordering its own header warns about. Targeted JSON patch instead:testops asserting each name at its index,removein 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, sochain_populate_total = 0is also an idle new pod. The cleanest discriminator found β an armed boot logsevent-chain DDL skippedas the chain path initialises, where a disarmed pod logged zero chain lines at all. - β Honest limit:
chain_populatehas not moved since the re-arm, and the reason is measured rather than hopeful βchain_head_hydrateis 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.
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.
Last item of the window, and it came from distrusting my own verification.
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/mainit loadsplaybooks/lib/pinned_sets.pyfrom$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.
-
git rev-parse HEADin 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 the0 first-party .rs of 0the 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.
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.
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 }
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.
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.
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.
Follow-through on the three merged noetl/noetl PRs, then the downstream question for #709.
#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.
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.
Picked up #415 (three unguarded pinned label sets). The guards were the easy part; what releasing them uncovered was not.
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).
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.
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.
-
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
E0308after asedchanged 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 ctakes one positional filter, so three names is a usage error β which reads as a failing baseline and makes every mutant look caught. -
$MINEdid not word-split in zsh, so a loop meant to revert everything except my three files reverted those too; thegit stash/popthat followed popped a pre-existing stash from another session and conflicted. Recovered with that stash intact; work re-applied. -
cargo fmt --allreformatted 39 files.mainalready carries 139 fmt diff sites and CI does not gate fmt. Reverted; the PR quotes 139 before and after.
Continued autonomously through what the gates surface. Three findings, all about instruments rather than code.
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.
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.
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.
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.
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.
-
"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 distinctmetric.typereferences 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
mainrun was a stale August failure whilev3.12.3built 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.
- #400: the publish guard, RED-proved against live PyPI β noetl#707, green, awaiting review.
-
noetl.event_dead_letterported 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-19700101and isNOT_FOUNDinshastaratech-noetl-prod, so repointing breaks the deploy. A grep for a project ID cannot tell a comment from a credential lookup. -
STATUS.mdadded β there was no present-tense answer to "what is blocked on me right now".
#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.
Two mistakes produce a green release that ships nothing, and only one involves a mismatch:
-
pypi-v5.1.2cut whilepackaging/pypi/still says 5.1.1 β builds 5.1.1. Tag and artifact disagree; a string comparison catches it. -
pypi-v5.1.1cut 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.
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.
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.
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.
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.mdtreats work blocked on external action as something that belongs in an open issue. The wait qualifies; the blocker does not. - The two
quick-xmladvisories 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, becauseobject_store0.14 pullsaws-lc-rsin besideringand leaves rustls with no defaultCryptoProvider. Filing that as "waiting for upstream" would have been wrong, so #406 states the two causes separately.
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
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.
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.
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.
#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.
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.
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).
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.
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.
Completes #378's pin work. All 7
Rust repos now carry a rust-toolchain.toml.
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.
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.
#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.
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.
In an issue about inert gates:
-
#[allow(clippy::assertions_on_constants)]placed directly on anassert!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. - 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.
-
const { assert!(..) }failscargo build, notcargo 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.0on pinned cli vsrust-1.99.0on unpinned tools) is authoritative.
#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.
#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 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.
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.
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.
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.
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'.
New issue #390:
-
22 of 23
.debs are listed in no index βdpkg-scanpackageswithout--multiversion. -
arm64 is in
pool/but indexed nowhere βbinary-amd64is hardcoded. -
Releasecarries 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.
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.
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.
-
extract_auth0_tenant_from_domainwas 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_elsewas a dead branch βcontains("kind: Playbook")returned the same value as the default, so the check never changed the outcome.
-
A
-D warningsrun 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 mainnoetlcrate β holding 23 of them β was never linted. -
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. -
clippy --fixintroduced a hazard worse than the lint.manual_flattenβreader.lines().flatten()islines_filter_map_ok: loops forever on repeatedErr. An auto-fix is a suggestion, not a proof. -
A local RED proof returned
exit=127three 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. -
git checkout -- <file>to drop a probe reverted four real fixes in the same file; the assertion only checked the probe was gone.
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.
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.
Follow-on to the entry below. Everything here is the gate being made trustworthy rather than merely present.
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.
| 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 |
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.
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.
-
Re-point
repos/docsrather 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, asrelease-versioning.mdsays. Verified rather than assumed: both newest tags (v3.122.1,v6.2.1) predate the merge commits.
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.
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.
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.
| 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.
| 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.
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.
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.
The question asked was "does anything trigger on pull_request?", not "are
there workflow files". 16 repos examined.
- β β clippy is
|| trueon 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_requesttrigger at all: doctor, ops, e2e, apt, docs, ai-meta. #375 - β
noetl/noetlruns no pytest anywhere, andcheck-pr.disabledsits on its default branch β a check deliberately switched off, reason unrecorded. - β β
*.wiki.gitrepos cannot host Actions. Platform constraint, so "add CI to the wikis" is not achievable as stated; the option is a scheduled ai-meta job runningcheck-docs.shover the wiki submodules. - β
#232 is stale-corrected:
cargo test --all-targetsdoes run on PRs on all seven Rust repos. My memory index had asserted the opposite for months.
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.
- 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
COMPLETEDmarker and anEXITtrap that turns any abort into a failure. -
Three separate counts were wrong while the verdict was right. My harness
grepped
β on mainand reported "ancestry 3 of 5", missing the two wikis that trackmaster. Aworkflows=0column 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. -
commsilently produced garbage in both directions because I fed itsort -nwhen 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-
fSHA still fails.
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.
βοΈ 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.
| 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.
#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.
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.
- β
/api/events/batchreturns{"status":"ok","event_ids":[β¦]}while persisting nothing β the insert 500s onevent_catalog_id_fkey(#363). - β β Two of my own guards were decorative on first write: one matched
link_batchin the comment above the call; the other matched a bare route path, soβ¦invariant-DISABLEDstill 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.
β β β 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.
β 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:
-
populate_from_logclassed "the store holds more than my snapshot" asDiverged. 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. NewFromLog::StaleLogβ benign, partition stays servable. - 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.
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 andCargo.tomldisagree as steady state βv3.113.0βv3.116.0all carryversion = "3.112.3", and everyrelease-serverrun 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
ghfamily, all caught before they mattered.--json conclusionyields""notnullwhile a run is in flight;--limit Nspans several pushes so asuccesscan belong to an earlier commit; and--commit <sha>can return 0 rows while--limitshows runs with thatheadShaβ an empty list satisfies "0 not completed", so anuntilloop exits instantly and prints a blank that reads exactly like a pass. Every green reported was re-verified by pulling--limit, filteringheadShaclient-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.
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 useplaybook.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
Emptychanged 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
/metricson 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-feedpullsehdb-l0; bumping one put twoehdb-l0versions 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.rswith no fixture β its failures then read like a code regression. Now committed astests/fixtures/chain_fixture.sql, with 1002's deliberate hole documented. - β I nearly shipped a second copy of one error message as a new
Displayimpl beside the existingmessage()β 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_POPULATEis not strict== "true"(it accepts1|true|yes|on), unlikeNOETL_EHDB_EMBEDDEDone row above, which is. Corrected β the divergence matters exactly when reading an armed state off a manifest.
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
0before anything fired. - fresh execution β Postgres 6 events correctly chained; store 6, seqs
[1..6], watermark1:6, guarded read all six, root correctlyprev=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 readSome(1)withroot_prev=None.first=1is 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 withplaybook.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-l0alone put twoehdb-l0versions 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 green0 passedfor an integration test β the populator's tests live intests/, so the filter measured nothing and reported ok. - β
gh project item-list --limit 200returns exactly 200 as a CAP, so "not on the board" was an unreliable zero; at--limit 1000the 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.
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 |
β β 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.
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. |
β 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.
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.
| 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 |
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.
Recorded because the pattern is the point β in each case a guard was written to prevent a failure class and then exhibited it:
- the
#[non_exhaustive]guard matched its own doc comment β prose counting as code; - 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; - the
OpOriginseam test called the authorizer directly, so deleting the call from the append path stayed green β a seam nothing calls; - 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.
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.
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.
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.
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.
β β 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.
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.
-
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 touchesspec.templateat 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 correctedmainwith 5 workloads compared. It also prints the rollout prediction, so error 2 is detected too. -
ops#302 β
maincorrected to prod's actual three-volume shape, verified field-by-field with a YAML parse. Until it merged, anyone applying frommainreproduced the outage.
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.
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.
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.
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.
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.
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.
/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.
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.
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.
Read-only. The residual #325 found turns out to matter more than its size suggested.
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.
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 thereThe 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.
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β thePERMANENT_LOG_LEANexternalisation: authoritative holds{"__context_ref__":β¦,"__context_bytes__":β¦}, the tier the inlined object. The same reference-vs-inlined class asresult, in a different spelling, which is why the first collapse missed it. -
worker_idβ a location difference. The column is often NULL whilemeta->>'worker_id'carries the value and the mirror copies it top-level. Read off a prod record, top-level andmeta.worker_idare the same string. A missing value that was never missing.
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.
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.
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
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 β theNOETL_PERMANENT_LOG_LEANexternalisation, 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.
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.
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.
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.
#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.
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.
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".
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.
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.
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.
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.
"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.
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.
Follow-on to the #320 close. Three residuals worked, one of them corrected.
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.
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:
-
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_wedgedis deliberately keyed on failures, not claim recency, with the reasoning written out atmetrics.rs:2732. That trap was already found and fixed. -
Rollouts β
noetl-state-builder-watchdog(a 3-minute CronJob with cooldown + flap-stop +MAX_ATTEMPTS) doing designed remediation, plusdeployments.patchby[email protected]from outside this session. - β Preemption under capacity pressure β shard1 shows
Preempted+ 24FailedScheduling. That is capacity (#318), not configuration. β Found only after noticing--limitis 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.
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.
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.
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.
Three releases and a repair. The clean floor the session was aimed at.
-
No retry.
deliverPOSTed once and returned()β structurally unable to report failure β and all three failure arms ended in awarn!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. - β β Stale pooled connections.
relay_client()wasreqwest::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. - 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.
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.
| 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.
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).
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.
| 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 (threemuno/playbooks/hotel-cards, onesystem/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 ondivergentis dominated by ordering, not loss (2.75Γ false-signal on this population).
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.
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.
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.
Two things were asked for and both are done, read-only and inert respectively.
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.
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).
- β β An in-pod
/metricsprobe reportedbuild_info lines=0on four separate ports. All four zeros were false:curlis not in the container.kubectl exec β¦ curlfails with "executable file not found", and piping that intogrep -cyields a confident0that 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. - β
rustfmtgiven a path recurses into newly-declared modules; given stdin it cannot. A fmt baseline that pipesgit show HEAD:fileand compares againstrustfmt --check fileis not like-for-like βmod.rsjumped 28 β 1019 diff lines purely from adding onepub modline. The measurement was wrong, not the code.
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
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.
Β§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.
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.
| 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_idand passes do not overlap.NOETL_EHDB_EVENTLOG_MIRROR_DRAIN_CONCURRENCY=1is 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.
| 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.
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.
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.
- 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 rewrotemain.rsinto a shapeauth_gate_wiringcannot 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 strmatched a search for retainedstaticstate.
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
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.
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β¦.
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(defaultLATEST+ 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_failedat both silentreturnsites, 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
AssertionErroron 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.
ShardElectionhas no call sites β but that is already deliberate and documented, and made legible on the scrape asehdb_election_active 0. Fencing is wired in the worker. My first grep missed it by being scoped to the ehdb repo alone.
Deployed. Prod is on server v3.99.2. Three fixes landed and one was gated back off:
-
worker_id/contextreader asymmetry (server#380) β the tier readworker_idonly as a JSON string and filtered a jsonbnullcontextthat the Postgres side kept. Fixed symmetrically. -
created_atproducer unification (server#384) β the column is microseconds, aDateTime<Utc>is nanoseconds, and the two writers reduced differently: sqlx's binary bind TRUNCATES, the SQL text cast inproject_eventsROUNDS. 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.
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/.
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.
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.
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.
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_idserialised as a JSON number read asNone, and a jsonbnullcontextthat 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_atis 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.
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.
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.
-
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 isprimaryon prod is the event one. Both renderehdb_l0_*and share the same helpers, so a grep finds one wiring and stops. Fixed in worker#292. -
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:PartWriteralready 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).
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.
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
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
_FILEvariable set and reportedsource="env"on prod. That is the control: it shows the metric can report either value, so the latersource="file"is evidence rather than a pinned constant. -
Digest-checked before removing the fallback. Every mounted file was compared
by sha256 against the
secretKeyRefvalue 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.
| 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.
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.
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.
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.
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
natsnamespace. 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-resultsis still registered on prod;/api/catalog/deletereturns 403 (protected since the soft-delete incident), so it needs an operator to archive. Nothing calls it. - Wiki: noetl-travel-wiki@67241ba
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-plannerv88,flight-plannerv4.
β β 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.
- 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 isrepresentation-drift:check_out_datewas 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. - "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.
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.
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 vsclaim_outcome). Settled the attribution in one run: everyoutcome>250ms vs ~40ms for an ordinary event emit. ai-meta pointer #277. -
noetl#696 β index
noetl.command (event_id). The claim endpoint resolves byevent_idalone and every index leads with the partition keyexecution_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_flightis 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/binwhile the ENTRYPOINT is./noetl-workerfromWORKDIR /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.
#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.
- β β
noetl.projectionis a DEAD TABLE. The charter names "theprojection-
projection_snapshottables"; half of that is wrong. 0 rows, and no writer anywhere in the Rust control plane β its only writer is the retired Pythonprojection_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 frozennoetl.execution.status(#235).
-
- The real authoritative store is
noetl.projection_snapshot, with exactly oneINSERTsite (services/orch_snapshot.rs). A stronger chokepoint thanemit_eventsever 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. -
The tier's existing parity check compares EHDB to EHDB β
shadow_projectfolds 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 perdata-access-boundary.mdit cannot. - 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.
#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.
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.
#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 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.
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.
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).
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.
#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.
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.
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.
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.
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.
#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).
| 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).
-
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 onmain; nothing in CI checks this. Consolidated via worker#274 -
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
payloadbyte 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. -
The serve-ready config needed three env vars the plan did not name β
TIER_SERVICE_ADDR(without itTIER_QUERY_SOURCE=servicesilently 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), andCROSSSTORE_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.
#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.
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 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.
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.
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:
-
The writer restart re-executed one in-flight step. The 22:00 cleanup
completed normally at 22:00:05, then its
endstep ran again in full at 22:00:33 β a 30.3 s gap againstNOETL_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. - 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.
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.3is already in AR assha256:c50eff55β¦,linux/amd64verified withcrane config. Landmines named:b56e225carries a 22-filecargo fmtsweep; 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-1isreplicas: 0on a much older digest.
Two decisions parked with the user: availability posture, and whether to apply any of the three staged changes.
Prod untouched. No tier is primary, no flag flipped, worker v5.115.3 is released but not deployed.
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.
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.
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.
-
worker#262 β worker v5.115.3
(merge
9398cea, main tipdfcf9d9) - 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.
Prod: server 3.79.2, worker 5.108.1, gateway 3.12.1. All pods 0 restarts, gates 0,0,0.
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.
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.
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.
#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_OUTCOMESis 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.
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.
Prod: server 3.79.2, worker 5.108.1 (all pools + writer), gateway 3.12.1. Gates 0 throughout except the incident below.
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.
-
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.eventholds an FK ontonoetl.catalogand every real entry has execution history. The fixture had tested the only case that could succeed. - 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.
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.
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.
-
Soft delete is shipped but inert β the column does not exist, so every
path behaves as pre-soft-delete. One operator
ALTER(as roledemo) enables it; no redeploy, since presence is detected frominformation_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.
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.
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.
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.
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.
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.
-
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. -
getent hostson macOS does not reliably consult DNS, which made an Amadeus hostname look unresolvable everywhere.digconfirmed the conclusion but the first control was unsound. -
Building string anchors from
sed-prefixed output β the display prefix is not in the file. Four failed edits before switching to exactreprs.
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.
#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.
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.
-
#247 β EHDB phase 6-10 tier
flips. All five tiers are
Offin prod and have never been shadowed, soprimarywould serve an empty log as authoritative; and the worker pods have no PVC while KEDA scales them 2β20. Evenshadowis blocked for the same reason. The requested acceptance evidence is shadow-mode evidence and cannot exist yet. -
#248 β sink-gated eviction.
markfires onis_sink_step;confirmneedsis_success() && !pending_callback. A failed sink leaks an eviction block forever β the pending set has onlyinsert/remove, no TTL, no sweep. ops#246 (the bearer) is unmerged; prod has 0 matching env vars. -
Boundary compaction (~1.7 GB). No
UPDATE/DELETEagainstnoetl.eventexists anywhere in server or worker. Needs an RFC, not a script.
4 PodMonitorings, 4 Rules objects, 4 alert policies all ON (including gateway authentication is DISABLED), 1 email channel.
-
set imagewith a wrong container name is a clean no-op β androllout statusthen reports success. A green rollout that changed nothing. Caught twice, both times only by readingbuild_infoback. - 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 (1014428265962isnoetl-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.
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.
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.
-
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_idis never projected and is acked anyway, so under the publish-only gate (on in prod) it is an event permanently absent fromnoetl.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β withNOETL_COMMAND_BUS=ehdband no writer routes, the publish is not attempted at all: every command silently undelivered, every execution stalled. Production runsehdb. -
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.
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.
-
A claim I had already reported was wrong. I said a missing
noetl.commandrow blinds the orphaned-command guardrail. Both sweeps querynoetl.eventexclusively. The real consequence is different and still real:container_callback.rsresolvescatalog_idfrom 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
mainthat was 143 commits behind origin (server's was 120). Caught before reporting. The tooling fix:playbooks/lib/pinned_sets.pynow readsorigin/mainviagit showlike every other check, not the working tree.
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.
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.
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.
| 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. |
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 andevent_emit_failed_total{event_type}takes a free-form String. -
noetl_event_ingest_publish_skipped_total{reason}separates theno_transportfault fromgate_offandsystem_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.
- 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_envhas no non-test caller, so noAffinityDecisionis 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.
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.
-
kubectl set imagewas silently rejected β the container is namednoetl-server, notnoetl-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_deniedonserver: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 --failedfixed 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 whoseno_transportreason 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_activepermanently unreadable β the same defect, introduced by its own fix. A guard test now fails if aDispositionvariant is added unpinned. -
Gateway GHCR tags carry a
vprefix (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.
Prod untouched. server v3.62.1 / worker v5.95.3 / gateway v3.7.1.
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_ADDRorNOETL_KV_ADDRβ an explicitbail!. That is the opposite of the server and worker, whose equivalents fall back to a quiet default. -
/healthis 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
gatewayhas 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.
| 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.
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.
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.
| 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.
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 --libcompiles none of it βEnvGuardis behindcfg(test). Only--all-targetsor 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.
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.
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.
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).
Prod untouched. Nothing was applied to the cluster in this part; all reads.
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.
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.
| 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.
-
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.
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.
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.
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-1Deployment 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_legacywalks ordinals0..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 boundedprune_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 viaunwrap_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:137lists those vars inremove_env, so converging the IaC strips whatci/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.
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.
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.
#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.
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.
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.
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.
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.
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.
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.
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:.
| 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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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}.
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.
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).
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.
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.
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 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.
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.
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.
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.
-
worker v5.92.0 (worker#211, merged as
ddb4de8) β deployed by digestnoetl-worker-rust@sha256:ad1e96063bf5β¦81725. ai-meta pointer4f35c5d. Server (v3.60.2) and gateway (v3.7.1) not rolled β no new rev. -
#221 CLOSED β an unset/blank/typo'd
*_SOURCEon 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 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 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.
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.
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.
playbooks/223-ehdb-prod-runbook/P1-result.md Β· ai-meta 4f35c5d (pointer) + 25a77ff (result doc).
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.
-
ops#245 @
5e28543(branchfeat/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.
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.
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 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.
plan β converge β converge, with profile=kind, writer_storage_mode=claim, gateway_reconcile=true, autoscaler_enabled=false:
-
plan β the three claim names,
offserveron the server and both system pools, the gateway patch in nsgateway,4Gi, probes on:9101only, and the selector-replacement warning. -
converge β the StatefulSet adopts the three pre-existing PVCs verbatim (
volumes:, novolumeClaimTemplates, same PVs, no new volume); selector replaced;Service β 10.244.0.57; all nine faces up;VERDICT: PASSwithpublished == 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.generationmoved on any of the five workloads or the gateway, writer pod UID unchanged, 0 restarts. -
negative check β the old gateway targeting with
gateway_reconcile=truestill 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.
-
playbooks/223-ehdb-prod-runbook/P0-discovery.mdβ the discovery table, the corrections, the kind record -
playbooks/223-ehdb-prod-runbook/README.mdβ P3/P4 with the corrected command line and the confirm-at-apply-time table - ops#245 comment Β· #222 Β· #223
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.
| 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. |
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.
-
ops#245 does not thread
state_shard_writeor the threeclaim_*PVC names fromehdb_platform.yamlto its children, so--seton the entry point silently has no effect on either. The PVC one risks mounting the wrong claims understorage_mode=claim;state_shard_writedefaults 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_groupsdefaults 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.tomlstill reads5.91.2, an already-published tag;verify-versionhard-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.eventlog. 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.shis 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.
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.
| 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. |
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.
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.
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.)
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".
| 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 |
-
H5 (#221, newly filed).
EventSourceModestill fell back tonatson 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 atracing::error!and a dead materializer. Proven in-cluster:NOETL_MATERIALIZER_SOURCE=ehbdβ crashloop in ~30 s naming the var and the fix. -
#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.eventlog. Now a sequenced, awaited shutdown: stop ingest β quiesce β cursor β seal-and-hold. -
Face supervision. Every face was
tokio::spawn(serve_x(..))with theio::Resultdiscarded, so a dead face said nothing. Now logs at ERROR when an accept loop ends.
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.
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.
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.
-
ehdb#311 β
ehdb_feed::servehandshakes 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=shadowsilently 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/opsmain carries zero occurrences ofNOETL_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.
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.
- 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 |
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 toworker/src/dispatch.rs - the gateway's
NatsConfigbucket names and TTLs β renamedKvConfig(they configure EHDB KV; only the connection fields were dead) - the server's
coherenceKvReadAPI β reduced to a shell, API kept, becausestate.rsbranches on Hit/Miss/Unavailable and that distinction is real -
build_state_messageβ the EHDB feed reader calls it
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.
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.
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.
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.
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.
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.
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.
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 |
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.
-
set image '*'swapped thecurlinit container for the worker image. - The result + state materializers were still NATS-sourced, draining a stream nobody writes.
-
The off-server state builder was a 4th, uninventoried
noetl_eventsconsumer. With NATS gone every orchestrate returnedWAL chain incompleteand every execution stalled at RUNNING with no error anywhere. - The gateway hard-failed at boot on missing NATS (
?onstart_nats_listener). -
should_publishrequired a NATS connection. RemovingNOETL_NATS_URLmade 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.
#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.
| 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.
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.
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.
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.
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).
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 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.
| 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
|
events/project had no metric at all. The sole writer of the durable
even