Repository navigation
br-service-engine 0.3.0
0.3.0 - 2026-09-22
lane: reset
2. Reset paths — the reported race was not reproduced
The reported "lost delta" race — a queued delta not yet read, then a projector
reset that hands back a stale view — was not reproduced on 0.2 (s196 green on
0.2). render/deliver.rs writes every rendered upsert/remove into last_sent
before discarding it under a reset, so a reset always carries the latest rendered
view; the projector-reset path re-renders the dirtied members from the store, so
the Reset carries the committed view, never the stale one the unread delta
held. The three reset paths (deliver.rs incremental, repair.rs::resnapshot,
connect.rs attach) were left as is — collapsing them onto resnapshot was
not rated worth the churn on the delivery hot path this release. Every reset
still costs a full window render — watch service_engine_resets_total. No API
change; the write-set check this item once carried is dropped in favour of the
change-detecting mirror kit (see N3., lane: mirror).
12. Dead-letter at the render choke point
A projection failure is now dead-lettered at every render entry point, not
only during a normal render pass. The Renderer records the poison document
(DeadLetterSource::Render, keyed on projector plus key, deduped) the moment
project fails, so the attach snapshot (runtime/connect.rs), the page render
(runtime/paging.rs) and the repair re-snapshot (render/repair.rs) all land
the ops row that only the pass path recorded before — those three built a
dead-letter-less renderer. render/pass.rs::dead_letter_projection is gone; the
choke point is the Renderer itself. No API change.
lane: write
3. Transition-aware policies, delete policies
Aggregate now requires Clone, and the pipeline retains the loaded image of
every aggregate it loads. A post-save policy takes Saved<'_, A> { prior, next, events } instead of the bare saved aggregate: next is what was just saved,
prior is the stored image (None on a create), events its pending events, and
Saved::transitioned(|a| …) reports whether a projection of the two differs. The
same shape drives a new post-delete policy: register_post_delete_policy::<A>
/ require_post_delete_policy::<A>, run before a hard delete, refusing or allowing
it exactly like a save policy. One PostSave context serves both hooks; the seams
are declared and boot-checked together (EngineError::UnhonouredSeam).
N5. Persistence::delete and cx.delete
Persistence gains delete(conn, key), defaulting to a refusal
(EngineError::DeleteUnsupported) so a store that never deletes writes nothing.
cx.delete(&aggregate) runs the delete policy, issues Persistence::delete, then
stages the aggregate's offer and blob reconciliation; a refusal or a delete failure
rolls the transaction back like a save. A raw DELETE outside cx.delete is a
lock-domain violation (see N6), never an engine path.
6. Offer trigger types
OfferTrigger<O>: Aggregate with key_from(&self) -> Result<KvKey, EngineError>
(the offer's KvKey) and row_key(&self) -> Result<…::Key, EngineError> (the offer
row's typed store key the drain loads), registered by
Engine::register_offer_trigger::<O, T>() (refused when T is the offer's own
row). A change to a trigger aggregate stages the offer's dirty key, so the offer
re-publishes when a fact it derives from — not its own row — changes; because
row_key is typed as the offer row's store key, a trigger on a different
aggregate can no longer silently retract the offer by staging its own key. The
trigger must live in the offer's slice. Offer gains const VERSION: u16 = 1.
Retires the hand-written dirty_service class. Additive.
15. Engine-owned integration outbox
OUTBOX_TABLE is now service_engine.integration_outbox, created by the engine
migration 9113000024 and owned like every service_engine.* table; the six SQL
sites read the constant. A migrating pod adopts any legacy public.integration_outbox
rows through adopt_legacy_outbox, an idempotent post-migration step (not folded
into the migration: the fresh-database order would leave an orphan public table),
which drains the rows and drops the legacy table. The whole adoption runs in one
transaction holding a constant pg_advisory_xact_lock, so concurrent migrate init
containers on a multi-replica roll serialize: the second pod waits for the first to
commit, then sees no legacy table and returns without a double-DROP crash. The
sample and example outbox DDL is gone.
N2. cx.create refuses an existing key
cx.create now loads under the aggregate advisory lock and refuses an already-held
key with EngineError::KeyReused, surfaced as the stable code KEY_REUSED, instead
of overwriting or hitting a raw unique violation. Two concurrent creates leave exactly
one winner. Removes the principle-5 guard every service hand-wrote.
N6. One lock domain; Persistence::row_lock
Persistence::row_lock(conn, table, key) is a defaulted helper issuing the common
SELECT … FOR UPDATE on the aggregate row — the one-line lock implementation for a
store that wants a row lock beside the engine's advisory lock. Persistence::lock
keeps its no-op default (the advisory lock already serializes every pipeline load).
The one-lock-domain rule holds: every write to an aggregate's rows goes through
cx.load/cx.save/cx.delete, which take the per-key transaction advisory lock,
so two commands on one aggregate serialize even with the no-op lock.
lane: boot
4. The boot kit splits into three argv entry points behind one run_service /
BootPlan. engine/boot.rs becomes engine/boot/{mod,migrate,serve,env}.rs.
migrate runs under the owner role, from DATABASE_URL_OWNER only (no fallback
to DATABASE_URL), retries the owner connection with backoff up to
EngineConfig::migrate_connect_timeout (default five minutes), applies the engine
set then the service set on one shared _sqlx_migrations ledger — the kit sets
ignore_missing on both migrators — waits for the app role to exist, then
grant_engine_access + grant_app_access, and exits. serve runs under the app
role: it refuses to run — EngineError::MigrationsPending { engine, service }, logs
REASON_MIGRATIONS_PENDING and exits non-zero before it binds any port — while
either set is unapplied (checked through the app role against the shared ledger), then boots
the engine and serves. schema prints the SDL and touches no infra. run_service
dispatches on argv (migrate / serve / schema; no argv serves).
EngineConfig::from_env() reads the ops contract in one place and dispatches on the
same argv the boot kit does: serve reads the whole app-env group — ENGINE_CHANNEL,
HOSTNAME (replaces POD_ID), PORT/HOST (replace HTTP_ADDR), NATS_URL,
APP_ROLE, and the optional session/lease/beat timings; migrate reads only
APP_ROLE (placeholder channel/pod, default connect timeout) so the migrate init
container needs the owner Secret and APP_ROLE alone, never the serve env; schema
reads nothing. A service main reads no engine env var by hand; BootPlan.config is
from_env()? plus the service's own with_service / with_blob_storage /
with_session_ttl. BootPlan
loses nats_url and app_role (both now read by from_env into EngineConfig).
with_edge_observability is crate-private: serve is the one boot door, and no
observability helper is re-exported at the crate root.
N1. serve derives message_retention from the bound streams' max_age
(inbound::derive_message_retention, Nats::stream_max_age) after the reactions
are registered, taking the max over the subscription streams and refusing an
unlimited stream. A service no longer sets with_message_retention by hand, and the
pre-boot second NATS connection every adopter opened to read the stream max_age is
gone. An explicit with_message_retention still wins when larger. The existing
retention-mismatch boot check (s174) is unchanged.
N11. Shutdown is latching, so a stop can no longer be lost. The engine's internal
stop signals were bare tokio::sync::Notify: notify_waiters() wakes only the tasks
already parked on the signal and stores nothing, so a worker that had not yet reached
its first poll when shutdown was raised never saw it, and run then blocked forever
joining it. A pod shut down shortly after boot could hang until the kubelet's
terminationGracePeriod expired and SIGKILL landed; the scheduled-message loop was
the one this bit most often, because it is spawned last and joined first.
service_engine::stop::Stop replaces Notify on every internal stop path — an
atomic latch beside the notify, where stop() raises the latch before waking and
stopped() reads it on its first poll — so the signal is level-triggered and the
order of stop and first poll no longer matters. Public signatures that take a stop
handle move from Arc<Notify> to Arc<Stop>: AccumulatorRuntime::run,
Beat::run, MirrorSupervisor::start, SessionRuntime::run and graphql::serve.
Engine::shutdown_handle() is unchanged.
14. readiness re-exports Readiness / ReadinessHandle / readiness_route
from br-util-axum-readiness = "v1.3.0"; the engine holds no copy. Paths are kept,
and the shared crate's readiness: UP / readiness: DOWN tracing wording — the one
the black-box battery greps — is the wording the engine now emits.
lane: chart
- Library chart
br-engine-service, ops contract v1. The engine now
publishes the shared deployment topology as a Helm library chart
(charts/br-engine-service/,type: library) on its own version line starting
at1.0.0— the crate version appears nowhere in the chart. Named templates
render the topology from a thin chart's values:br-engine-service.deployment
(Recreate; init containermigrate= the service image with argvmigrate
and the owner Secret +APP_ROLEmounted there only; main container argv
servewith the app env only;readinessProbe /readyz,livenessProbe /livez, onehttpport bound toPORT;HOSTNAMEfrommetadata.name),
.service,.serviceaccount,.pdb, and.networkpolicy(ingress selectors
are values; the chart names no namespace). Postgres DSNs are read whole from a
Secret (DATABASE_URL,DATABASE_URL_OWNER) — no password interpolation, a
role password with a URL-reserved character must never be interpolated into a
DSN — andTRUSTED_NETWORK_HOSTScarries the per-host plaintext opt-out.charts/br-engine-service/ci/thin-example/is the fixture
thin chart (one dependency on the library plus values) and the shape a
downstream thin chart takes. - Chart major = ops contract version, under its own name. Chart major 1 is
ops contract v1. A change to any contract row (an entry point, an env var name,
a probe path, the roll strategy) is a chart major shipped under a new
chart name (br-engine-service-v2); the old chart keeps serving old images.
check-chart-version.shfails acharts/**change that does not bump
Chart.yamlversion, but it cannot tell a minor from a contract-breaking
major — the README states the rule and the reviewer enforces it. - CI.
ci.ymlgains achartjob (helm lintthe library,helm dependency build+helm lint+helm templatethe fixture — asserting a Deployment with
initmigrate, mainserve, probes/readyz//livez,Recreate— then
check-chart-version.sh). Newchart-release.ymlpackages and pushes the chart
tooci://ghcr.io/botresources/charts/br-engine-serviceand tags
chart/br-engine-service/v<version>on the firstmainpush that changes
Chart.yamlversion, independent ofrelease-tags.yml. The README gains the
"Ops contract v1" section. - Engine crate: no change. This lane ships no Rust; there is no semver break
and no adopter code migration. The GitOps follow-up (thin charts per engine
service, the Warehouse chart-path and library-OCI subscriptions, thehelm-update-chart
promotion steps, deletion of the hand-written templates) is a separate,
sequenced-after change; service crates ship no chart.
lane: cohort
5. Structured cohort descriptor
Cohort { dimension, value }withCohortValue::{Uuid, Text, Bool, Int}(#[non_exhaustive]) replacesCohortKey::of. A cohort is a(dimension, value)the service names —Cohort::uuid("manager", id),Cohort::text,Cohort::flag("public", true),Cohort::int— andCohort::key()is its routing image (tag + dimension + typed value bytes).Visibility::{cohorts, memberships}returnVec<Cohort>, andCohortIndex::keys_in_cohortstakes&[Cohort]and binds against each row's natural columns through the helpersCohort::uuids/texts/holds(WHERE manager_id = ANY($1) OR $2) — the shadowcohort_key byteacolumn and its writer are gone from the sample.CohortKey::ofis removed;CohortKey::principal(the RLS render group) stays. The cohort-column rule text moved out ofpersistence.rsinto the README H5 paragraph.- Honest line: this does not change the windowed views of a service whose visibility is clock-windowed — a time-windowed active link is a service-side write (
end_date IS NULL), not an engine cohort, and the engine will not schedule a midnight wake for one.
N8. DB-backed inverse for link-table dependencies
InversegainsLookup(InverseLookup<K>): the store answers "which keys depend on this foreign key" with a query, so a dependency held in a link table — not derivable from the key (Keys) nor a predicate over the window (Query) — declares its inverse instead of falling back to aprojector_reset. The engine resolves each lookup once per impact with a pooled connection at the pass boundary (render/pass.rs), then routing stays synchronous and treats it asKeys.- The four inverses are now
Keys,Query,Lookup,None. Aview::Projectordeclares one throughfn inverse(foreign) -> Inverse(defaultNone); the low-levelprojector::Projectoralready carried the hook. The engine names no producer — the mirror namespace is the service's.
lane: mirror
- 7. Engine offers publish a manifest; consumer verdict at scan. An offer leader now writes
OfferManifest { prefix, version }undermanifest_key(prefix)— a sibling key outside the data prefix — at every reconcile (CAS, idempotent), carrying the new defaultedOffer::VERSION. A consuming mirror reads that manifest at scan (boot and every periodic reconcile) againstConsumed::manifest(): a version or prefix mismatch dead-letters the whole prefix once (DeadLetterSource::Mirror, keyed(mirror, prefix)) and leaves that prefix's shadow empty (known_*follows to empty) — at scan and, once rejected, the watch path drops that prefix's puts too so it stays empty until the next reconcile; an absent manifest is the pre-0.3 producer case and is applied silently; readiness is untouched. A consumed prefix must end with/(refused at registration throughMirror::validate) somanifest_keyis a true sibling outside the data prefix, and the manifest is read from the consumption's own bucket, not alwaysPUBLISHED_LANGUAGE. The consumption carries the manifest key as aResult, so a non-/prefix surfacesEngineError::Configfromvalidaterather than panicking atconsume. This closes the 0.2 disclosure that the manifest verdict was declared but unwired. It removes no guard on a non-engine producer, which has no engine manifest — those are covered bywire_version(N9). - N9. Per-value
wire_versiononConsumed.Consumed::wire_version(&value) -> Option<u16>(defaultedNone) lets a consumer of a non-engine producer declare the producer's per-value version field. The engine compares it toConsumed::VERSIONat scan and at watch when it returnsSome; a mismatch dead-letters that one value (keyed(mirror, key)), leaves its shadow row absent, and projects the rest of the prefix;Noneis never judged. Readiness is untouched. Not a manifest — the producer's shape is the consumer's declaration, so nothing producer-specific enters the engine. - 8.
require_keyon a mirror.Mirror::require_key::<C>(key)names one key underC::PREFIXas configuration;validaterefuses a key outside the prefix (EngineError::RequiredKeyOutsidePrefix), a required key whose prefix names noconsume::<C>on the mirror, and a syntactically invalid key (EngineError::Config), so a typo'd generic or key fails boot instead of holding readiness DOWN forever. The mirror is not ready (REASON_REQUIRED_KEYS,/readyznamingmirror: key,service_engine_dependency_up{dependency="required_keys"}) until the key is present in the shadow, evaluated after every scan and watch event through awatch::Receiverbeside the mirror-health board. Nothing is dead-lettered, no restart budget burns, the mirror stays converged and keeps projecting. A mirror that declares no required key is never affected. - N3. Change-detecting
upsert/replacein the mirror kit.Projection::upsertcompares the stored row (INSERT … ON CONFLICT DO UPDATE … WHERE row IS DISTINCT FROM excluded RETURNING (xmax = 0)) and returnsWritten::{Inserted, Changed, Unchanged};Unchangedstages no impact.replacediffs the incoming key set against the scope's previous keys and stages an impact only for a key that entered or left the set: its granularity is the key set, so areplacescope carries keys-only link rows and per-value diffing isupsert's job. This retires the hand-written roster comparison every adopter carried and is the replacement for the dropped write-set check of item 2. - N10. The watch advances the per-bucket read boundary over sibling keys. The mirror watch now advances its committed watermark to the stream revision of every message on the bucket, projecting only the keys under
C::PREFIX. Boot already adopted the whole-streamlast_sequence; the watch previously advanced only for its own prefix, so any sibling write in the same bucket — the item 7OfferManifeston a bucket the service both offers into and consumes from — pushedlast_sequencepast a boundary the leader's watermark never reached, and a standby that read afterward stayedNotReady { reason: "waiting for the registered mirrors to converge" }forever. A wire-version-rejected value now advances the same boundary. Convergence (s191) held in-process; this closes it for a second pod on such a bucket.
lane: graphql
- Subscription-union envelope types are derived from the SDL (item 10).
SchemaSlices::verifyandSliceFragment::deriveexempt the object members of any union that also listsLanesPausedandLanesResumed— the reactive delta envelope thesubscription_union!macro always emits — so two subscription slices sharing one delta union compose with no synthetic slice claiming the*Payloadtypes. The syntheticreactiveslice is gone from the sample. - Coded refusals on queries and subscriptions (item 11).
graphql::coded_error(code, message)andgraphql::forbidden()(codeFORBIDDEN) are new, re-exported atservice_engine::andservice_engine::graphql::;mutation_erroris now implemented overcoded_error. A resolver refusal rides async-graphql's own framing — HTTP200witherrors[].extensions.codeon a query, and anextpayload carrying the error thencompleteon a subscription open, never a transporterror.
lane: metrics
- 13. Leader gauge per leased loop. One
service_engine_leader{kind,name}gauge (metrics::LEADER) reports whether this pod holds a leased loop's lease:kindisrelay,cron,offerormirrorandnameis the slot,1on the holder and0on a standby, carrying the usualservice/podidentity labels.observe::record_leader(kind, name, holder)sets it. It replaces the three unprefixed per-loop names proposed in #128 with one fact keyed bykind. The relay and cron loops record it from the shared slot claim (housekeeping/leader/mod.rs: a won claim is1, a lost claim0, so a standby that competes reads0and a leader that stops winning a slot reads0on its next claim); the offer records it at its singleton claim and every renewal (offers/leader.rs); the mirror records it every beat fromon_beat(mirror/runtime/lead.rs), so a standby holds0and a failover moves the1to the pod that takes the expired lease. The gauge is a level, so a stale value self-corrects on the next claim or beat and a gracefully finished process stops emitting when its scrape stops.
lane: prefix
- Mandatory root-field prefix.
compose_service!now takes a mandatoryprefix = <snake_ident>;afterprincipal; a block without it does not compile. Every root field a service exposes must be<prefix><UpperName>—RootPrefix::from_snake(re-exported atservice_engine::andservice_engine::graphql::) validates the ident (non-empty, lowercase-first,[a-z0-9_], no leading/trailing/double_, ≤ 40 chars) and derives the lowerCamel prefix the field carries;RootPrefix::owns(field)is true ifffieldis the prefix followed by one uppercase ASCII letter and any tail (a bare-prefix field is not owned).Engine::declare_root_prefixrecords it (a second, different value →EngineError::RootPrefixRedeclared),compose_service!'s generatedregisterdeclares it before any slice, and a service that hand-registers fragments must call it itself.SchemaSlices::assemble(fragments, prefix)andverify(sdl)refuse a root field outside the prefix (RootFieldOutsidePrefix) and refuse registered fragments with no prefix declared (RootPrefixUndeclared). New boot errors:RootPrefixInvalid,RootPrefixUndeclared,RootPrefixRedeclared,RootFieldOutsidePrefix— all fail the boot before the pod serves. The gate is the only defense against two plain-SDL services shadowing one root: the gateway composer merges a duplicate plain-SDL root silently and routes it to one graph (probe recorded in the plan), so the engine refuses the duplicate at boot instead. - Library-slice embed.
compose_service!gains aslice <name> ["feat"] from <lib>::<macro> { … }arm: the named library macro is invoked at(prefix = <prefix> ; principal = <P>)insidepub mod <name>, contributing its own root objects andregister.service_engine::pasteyis re-exported so a library writes::service_engine::pastey::paste!without its own dependency. - Generic
gated!andsubscription_union!arms.gated! { generics [P: Bound] ; Aggregate<P>, Principal ; … }emitsimpl<P: Bound>blocks so a library writes its affordance gate once, generic over the principal.subscription_union! { generics [P: Bound] ; view = … ; delta = … ; … }makesfrom_erased/from_deltageneric over the principal (the emitted GraphQL types stay non-generic, one name each); a callback resolver callsDelta::from_delta::<P>(&delta). The non-generic arms are unchanged. - Conformance:
s210(a fragment root field outside the declared prefix fails the boot loud with slice/field/prefix),s211(fragments registered with no prefix declared →RootPrefixUndeclared),s212(a prefixed service boots, servessampleWidget/sampleCloseWidget/sampleWidgets, and every composed root field is under the prefix). Every hand-registered root field, resolver method and GraphQL literal in the conformance sample moves under thesampleprefix, and the example-service underexample.
lane: migrate
- Library migration chain.
BootPlangainslibraries: Vec<LibraryMigrations>
(vec![]when a service embeds no library). ALibraryMigrationsnames the library,
the Postgres schema it owns, a reserved version band (RangeInclusive<i64>)
and itssqlx::migrate!set.migrateapplies engine → each library (inVecorder)
→ service on the one shared_sqlx_migrationsledger, then grants the app role every
schema (engine, each library, public);grant_engine_accessis generalized to
grant_schema_access(pool, schema, role). A library's first migration creates its own
schema in the service database (CREATE SCHEMA IF NOT EXISTS …) — a schema inside the
service's own database is not infra. A cross-schema foreign key from a service table
into a library schema is supported by the fixed order. - Boot-time validation before any SQL (
libraries::validate, run by bothmigrate
andserve): a library schema that is not a lowercase Postgres identifier or that
shadowspublic/service_engine→EngineError::InvalidSchemaName; a duplicate
library name or schema →EngineError::DuplicateLibrary; the engine's reserved range
and every library band must be pairwise disjoint →EngineError::MigrationBandOverlap
naming both; a library migration outside its band →EngineError::MigrationOutsideBand;
a service migration inside a reserved band →EngineError::ServiceMigrationInReservedBand. servenames the pending set.EngineError::MigrationsPendinggains a
libraries: Vec<&'static str>field and its message andREASON_MIGRATIONS_PENDING
now name the engine, library and service sets;serverefuses a store where any
declared library set is unapplied and reports the pending library.- Ledger fact (upgrade path). All sets share one ledger with
ignore_missing, so
sqlx applies any set's unapplied versions regardless ofmax(applied): a 0.2 adopter
that later declares a library at a low band gets those versions applied below the
highest applied version on the nextmigrate, nothing to renumber (s217). apply_migration_chainandensure_migratedare public onengine::bootfor
black-box conformance (s213–s217, real Postgres).
lane: blobs
C1. Verified upload — checksum and exact size pinned in the presign
cx.blob_verified::<Kind>(name, content_type, UploadExpectation { size, sha256 })
and blob_owned_verified stage a blob with a per-call UploadExpectation
(Sha256Digest — hex/base64, serialized as hex). The POST policy then carries
{"x-amz-checksum-algorithm":"SHA256"}, {"x-amz-checksum-sha256": <base64>} and
["content-length-range", size, size], and the form carries both x-amz-checksum-*
fields, so object storage refuses a mismatching upload (XAmzContentChecksumMismatch,
EntityTooSmall/EntityTooLarge) and the object never lands. expect.size > max_bytes is refused at stage as EngineError::BlobOverPolicy before any presign.
Single-part only (≤ 5 GB); no composite/multipart checksum. BlobPolicy is
unchanged — the expectation is per call. The unverified cx.blob /
cx.blob_owned path is byte-for-byte unchanged (open [0, max_bytes] range, no
checksum).
C2. BlobReader::head — a live storage HEAD from a mutation
engine.blob_reader().head(reference) -> Option<BlobHead>. BlobHead { state, expected, size, etag, sha256 } fixes its provenance: state and expected come
from the blob row; size, etag and sha256 come always from a live
ObjectStore::head (never the row's recorded columns), so a mutation running in
the pending window (before the reaper sweeps) reads the storage checksum
immediately, with state: Pending and verified() == Some(true). verified() is
Some(size == expected.size && sha256 == Some(expected.sha256)) when an
expectation exists, None otherwise. None = no row, or no object yet, or the
store unbound. head never mutates. ObjectStore::head sends
x-amz-checksum-mode: ENABLED and reads x-amz-checksum-sha256.
C3. Post-upload policy — run inside the promotion transaction
register_post_upload_policy::<Kind>(|Uploaded, &mut PostSave| -> Result<(), PostUpload>) / require_post_upload_policy::<Kind> (boot-checked like save/delete,
EngineError::UnhonouredSeam). One policy per kind (a second is
EngineError::Config). The reaper runs it on the promotion connection through a
PolicyRunner that builds the same PostSave context a reaction gets; the blob
row carries no aggregate key, so the policy finds its referencing key
(ps.connection()) and impacts its own view (ps.impact_caused), reaching
subscribers as any post-save impact. The promotion connection carries no
principal and no RLS session context — a policy reading its referencing row
over an RLS-scoped table must query it with an unscoped statement, or it will see
no row and silently promote with no impact. Outbound identity of the reaper: the
reaper holds no principal, so emit/command from a policy go out as
service_actor(service), correlation = the blob row id, no causation, producer =
service.
The policy result is now Result<(), PostUpload> with two error variants — a
terminal refusal and a retryable fault:
PostUpload::Refused(viaps.refuse(reason).into()) is terminal: the
promotion transaction rolls back, the row goesfailedwithfailed_reason = policy:<code>, the object is deleted, and no impact or outbox row commits.PostUpload::Fault(EngineError)is a transient infra fault: any DB or
connection call inside the policy propagates with?intoFault(there is no
honest reason to.expect()an infra read any more). The promotion transaction
rolls back, the row stayspending, the sweep counts it in
ReaperRound.failures(logged with the blob id), and the next sweep retries it.
A panic is folded into the same retry path bycatch_unwindso the beat is never
taken down. There is no attempt budget in 0.3.0: a deterministic fault
retries every sweep indefinitely rather than resolving tofailed; a finite
delivery budget is a follow-up.
C4. Inline vs attachment disposition on the download
Query::download::<View>(key, reference, Disposition) and
BlobReader::download_url(reference, Disposition) (test-support only; the
production mint is Query::download) take a
blobs::Disposition { Inline, Attachment } (a GraphQL enum), rendering
response-content-disposition as inline; filename="…" /
attachment; filename="…". (Named blobs::Disposition at the module path — the
crate root Disposition is the inbound message-budget one.)
C5. Reaper under a leader slot, transactional promotion
The reaper runs under a reaper:blob leader slot (SlotName::Reaper,
SlotKind::Reaper) claimed per interval with the cron idiom
(claim_current_slot on a pooled connection, complete_slot after), so exactly
one pod sweeps per interval; ReaperRound gains skipped and failed. There is
no advisory xact lock — the slot row claim is the whole guard. Promotion runs
the HEAD outside the transaction, then a short transaction holds only the
state='pending'-guarded UPDATE … RETURNING plus the post-upload policy; no
FOR UPDATE. The new state model of service_engine.blob (9113000025) adds
expected_size, expected_sha256, etag, sha256, failed_reason,
failed_at; states are pending | uploaded | orphaned | failed. A failed row
resolves to no download URL. Failed and orphaned rows are reaped after
orphan_after (reap_detached generalized): the object is deleted first (an
idempotent DELETE, a no-op on a missing key), the row second, so a crash between
the two leaves a failed/orphaned row the next sweep finishes — nothing leaks
past the orphan window. The two steps are not one transaction.
C6. BlobConfig.public_endpoint — browser-facing URLs
BlobConfig::with_public_endpoint(url) (validated at boot). ObjectStore holds a
second bucket on the public host; the upload POST URL and the download presign use
the public bucket (SigV4 signs Host, so a browser needs the public host), while
ensure_bucket, head and delete stay on the internal in-cluster host. The
engine reads no env — the service binary maps S3_PUBLIC_ENDPOINT into the config.
For an unverified kind the presigned POST stays valid for its full upload_ttl
even after the reaper promotes the row, so the object it points at can be replaced
after promotion (a plain property of an S3 presigned POST). This is not defended in
0.3.0: pin anything approval-sensitive with a verified expectation (the checksum is
frozen in the presign, so a replacement cannot match), or keep upload_ttl short.
The verified path is immune; nothing in these docs claims an unverified object is
immutable.
C7. A pending row is downloadable only once nothing is left to judge
download_url (and the gated Query::download) mints a URL for a pending row
only when the row's kind has no post-upload policy and the row carries
no upload expectation; otherwise it returns None until the row is uploaded.
A verified row (expectation) or a policy-bearing kind therefore hands out no
download in the pending window — before the reaper has verified the checksum or the
policy has judged the upload — and resolves normally once promoted. A plain
unverified kind with no policy stays downloadable the moment its object lands, as
before.
C8. orphan_after must cover the upload window
At bind, a blob kind whose BlobPolicy::orphan_after is shorter than the store's
BlobConfig::upload_ttl is refused with EngineError::BlobOrphanWindowTooShort
(readiness reason blobs.policy). A shorter orphan window would let the reaper
delete an incomplete pending row while its presigned POST is still valid, so a late
upload would land an object no sweep ever sees. Set orphan_after >= upload_ttl.
lane: example-lib
- The roster slice is extracted into
crates/example-lib-roster, a standalone
library crate, and embedded byexample-servicethrough the host prefix
exampleandBootPlan.libraries— the first end-to-end proof of the whole
library path: root fields prefixed by the host, value types unchanged, migrations
chained, grants and cross-schema access, with therosterfeature still
removable. roster_slice!(#[macro_export], arm(prefix = $prefix:ident ; principal = $p:ty)) emits the root objects, the root methods (<prefix>_person→
examplePerson,<prefix>_roster_deltas→exampleRosterDeltas) and the slice's
register, reachingpasteythrough::service_engine::pastey.example-service
embeds it withslice roster ["roster"] from example_lib_roster::roster_slice { … }
androster = ["dep:example-lib-roster"].RosterUsers<P>is generic over aRosterPrincipalbound the host implements
in one line, and the delta union is declared once, outside the callback macro, with
the genericsubscription_union! { generics [P: RosterPrincipal] ; … }arm.known_personsmoves to schemarosterin band
9_120_000_001..=9_120_999_999;example_lib_roster::migrations()returns the
LibraryMigrations, andbin/service.rspasses it inBootPlan.libraries. The
library owns the read-slice (table, projector, GraphQL, migrations); the host wires
the directory mirror that feedsroster.known_personsand implements
RosterPrincipal— the project-specific seam.- No engine public-API change; value types keep their names in every embed (R4).
lane: example-blobs
The reference service (example-service, reply slice) now exercises the blob
additions end to end, so a service author has a copyable surface:
exampleAttachReplygains an optionalexpected: UploadExpectationInput { size: Int!, sha256Hex: String! }. Present → the mutation stages a verified upload
(cx.blob_verified); absent → the unverifiedcx.blobpath, unchanged.exampleReplyDownloadgains adisposition: Disposition! = ATTACHMENTargument
(INLINE|ATTACHMENT), passed straight toQuery::download— the hard-coded
Disposition::Attachmentis gone.- The reply slice registers a post-upload policy on its
reply_attachmentkind
(register_post_upload_policy+require_post_upload_policy) that finds the
referencing reply on the promotion connection and impacts the reply view with
causeAttachmentUploaded; the seam is declared, so an unhonoured policy fails
boot. example-servicee2e scenarios (tests/scenarios/blobs/{unverified,verified,disposition}.rs) cover the verified
round trip against real MinIO (aheadin the pending window reads the storage
checksum withverified() == Some(true); a swept run promotes the row to
uploaded), a wrong-checksum upload refused by the store with no download URL,
and the inline-vs-attachment download disposition on the presigned GET.- The example
Serviceexposesblob_reader()and the harness a
start_blobs_swept(pod, reaper_interval)world so a scenario can observe a HEAD
and a reaper sweep.
lane: tidy
House-rule debt closed before 0.3.0 ships; no API change, no behaviour change.
- Every source file over ~300 lines across the workspace is split by capability — in
service-engine(pipeline/ops,engine/register,engine/run,config,pipeline/policy,inbound/deadletter,dyn_compat/projector,runtime,mirror/runtime,mirror/builder) and in theconformance-service-enginesample library (sample/engine_pipelineinto per-flavour boot helpers,sample/mirrorinto shared types + publish + projection);EngineErrorstays one file, being a single#[non_exhaustive]enum. Public paths are preserved through the module facades. Every pre-existing comment and rustdoc is removed (///,//!,//), including the migration and CI prose; the operative why the CI notes carried now lives in the step names. - New conformance scenarios:
s226proves thePersistence::row_lockhelper takes a realSELECT … FOR UPDATE(a second connection cannot lock the row under NOWAIT while it is held, and can once the holder commits);s227ands228prove the two message-retention boot branches at runtime — an unbounded (max_age 0) bound stream is refused withREASON_MESSAGE_RETENTION, and, over a stream whose max_age (5400s) exceeds the default retention (3600s), only thewith_message_retentionoverride (7200s) makes boot reach ready, so the override is load-bearing (the default would be refused, the mirror ofs174). - Duplicate scenario numbers are renumbered so each names one file: the five
s194files keep one ats194and move tos229–s232; the twos195files keep one and move tos233.
Fixed
- conformance
s176no longer observes the NATS-down verdict through a fixed 2.4 s window (grace starts at disconnect detection, notnats.stop): it captures the beat's progress, then bounded-polls housekeeping (heartbeat + leader slots advance) and the readiness handle (REASON_NATS_UNREACHABLE) to generous deadlines, failing only on the deadline. Test-only; the engine is unchanged and matches the readiness contract ("thenats_graceprobe alone takes the pod DOWN").
Adopter migration
-
prefix: add
prefix = <snake_ident>;afterprincipalincompose_service!and rename every root method to<prefix>_<method>(the served field becomes<prefix><Method>; a method named exactly the prefix is refused). A service that hand-registers fragments (nocompose_service!) must callengine.declare_root_prefix(RootPrefix::from_snake("…")?)beforerun.SchemaSlices::assembletakes a secondOption<&RootPrefix>argument. Clients regenerate from the new SDL; the gateway supergraph changes once at the first deploy. -
write:
Aggregate: Clone— deriveCloneon every aggregate. -
write: a post-save policy takes
Saved<'_, A>—saved.nextis the former saved-aggregate argument andsaved.priorthe stored image (Noneon a create); read the transition withsaved.transitioned(…). Addregister_post_delete_policywhere you hard-delete, implementPersistence::deleteon stores that delete and route hard deletes throughcx.delete, and keep only the same-content idempotent ack of the hand-writtencx.createkey guard, through the lockedcx.load— drop its different-content branch and any raw-connection read, the engine refuses a different-content reuse withKEY_REUSED. -
write:
OUTBOX_TABLEis nowservice_engine.integration_outbox; never createintegration_outboxyourself again — keep an existing*_outbox.sqlonly where a database has already applied it (its rows are adopted at migrate time, then the legacy table is dropped). -
boot:
BootPlanlosesnats_urlandapp_role; buildconfigas
EngineConfig::from_env()?.with_service(..)(which now readsNATS_URLand
APP_ROLE) and drop those two fields from theBootPlanliteral. -
boot:
run_servicedispatches on argv — the pod runsmigrateas the library
chart's init container andserveas the main container; e2e harnesses spawn
migratethenserveinstead of one no-argv process. -
boot: delete every hand-read engine env var from
main;EngineConfig::from_env()
reads them. RenamePOD_ID→HOSTNAMEandHTTP_ADDR→PORT/HOSTin the
deployment;servereadsDATABASE_URL,migratereadsDATABASE_URL_OWNER
(strict, no fallback). -
boot: delete the pre-boot NATS connection that read the stream
max_ageand the
hand-setwith_message_retention;servederives it. -
boot: delete
set_ignore_missing(true)from the service migrator handed to the
kit; the kit sets it on both sets. -
boot:
with_edge_observabilityis no longer public; a hand-wiredmainuses
serveinstead. -
cohort:
Visibility::{cohorts, memberships}now returnVec<Cohort>andCohortIndex::keys_in_cohortstakes&[Cohort]— replace everyCohortKey::of(&[…])with a typedCohort(Cohort::uuid("manager", id),Cohort::flag("public", true),Cohort::text,Cohort::int), and bindkeys_in_cohortsagainst the row's natural columns withCohort::uuids/texts/holds. -
cohort:
CohortKey::ofis removed — a routingCohortKeyderived from a declared cohort is nowCohort::…(…).key(); a per-principal render group staysCohortKey::principal(id). -
cohort: drop the shadow cohort-key column with one migration (accounts
cohort_keys bytea[], runners0001_runners.sql:34); theCohortIndexseam now reads the natural columns. -
cohort:
Inversegains aLookupvariant — a service matchingInverseexhaustively adds the arm; a link-table dependency (accounts Orgs) replaces itsprojector_resetfallback with aLookupthat queries the link table (defaulted, so consumers that never matchInverseneed no change). -
migrate (break):
BootPlangainslibraries: Vec<LibraryMigrations>— addlibraries: vec![]to everyBootPlan { .. }.EngineError::MigrationsPendinggains alibrariesfield — exhaustive matches update. A service embedding a library declares eachLibraryMigrations { name, schema, band, migrator }and passes them here. -
metrics (13, additive): a new
service_engine_leader{kind,name}gauge; no adopter code change — dashboards and alerts gain the per-loop leader series. -
mirror (N3, break):
Projection::upsert/replacereturnWritteninstead of(); delete the hand-written roster comparison that avoided a spurious impact set. -
mirror (7, additive): delete per-projector version guards on engine-produced offers; the engine now reads the offer manifest and dead-letters a prefix mismatch.
-
mirror (N9, additive): delete the hand-written per-value guards on a non-engine producer and implement
Consumed::wire_versioninstead. -
mirror (8, additive): a mirror that needs a configuration key declares
Mirror::require_key::<C>(key)(the key must live underC::PREFIX); aproject-timeErr(EngineError::Config)guard on that key is now the mirror's readiness declaration. -
graphql (item 10): delete any synthetic slice that claimed the
*Payloadsubscription-envelope types — the engine derives them from the delta union that also listsLanesPaused/LanesResumed. -
graphql (item 11): replace hand-rolled
forbidden()copies (accountsgraphql.rs,context.rs) withservice_engine::graphql::forbidden(); a query or subscription refusal that needs another code usesgraphql::coded_error(code, message). -
blobs (break):
Query::downloadandBlobReader::download_urltake a thirdblobs::Dispositionargument — passDisposition::Attachmentto keep 0.2 behaviour. -
blobs (break):
ReaperRoundgainsskippedandfailed— a downstream exhaustive struct pattern or literal adds../ the fields. -
blobs (break):
BlobConfiggains the public fieldpublic_endpoint— construct throughBlobConfig::new(..)(then.with_public_endpoint(..)for a browser-facing host), not a struct literal. -
blobs (additive):
BlobReader::headreportssize/etag/sha256always from a live storage HEAD,state/expectedfrom the row. -
blobs (behaviour): a post-upload policy emits/commands as the service actor (correlation = the blob id, no causation); a message that must carry a human actor stays on the referencing mutation, not the policy.
-
blobs (break): a post-upload policy now returns
Result<(), PostUpload>(wasResult<(), Refused>). A refusal becomesErr(ps.refuse(reason).into()); an infra read propagates with?intoPostUpload::Fault— remove every.expect()on a policy's DB call. AFault(or a panic) rolls the promotion back, leaves the rowpendingand retries on the next sweep (no attempt budget in 0.3.0); only aRefusedfails the row. -
blobs (break): a
pendingrow of a kind that has a post-upload policy, or a row that carries an upload expectation, mints no download URL until it is promoted — a viewer must wait for the reaper. Plain unverified, policy-less kinds are unchanged. -
blobs (break): a blob kind whose
orphan_afteris shorter than the store'supload_ttlis refused at bind (EngineError::BlobOrphanWindowTooShort, readinessblobs.policy); setorphan_after >= upload_ttl. -
migrate (break): a library migration that creates a relation outside its declared schema now fails migrate with
EngineError::LibraryMigrationEscapedSchema(naming the library and the object); a library owns exactly one schema and may not touchpublicor another library's. -
graphql (additive):
with_sdl_route(app, sdl)is public — a hand-wiredmainthat serves throughapp(notserve) can mount the/sdledge route itself.
Replaced or dropped
Persistence::lockkeeps its no-op default: the engine's advisory lock serializes every pipeline load. Stores that need a row lock use the newrow_lockhelper (SELECT … FOR UPDATEon the row'sid).Extendedis unchanged; unknown extensions stay denied. A flattened producer is read through a typed struct with#[serde(default)]fields.- No write-set check: the mirror kit stages nothing for an unchanged write —
upsertdiffs the row,replacediffs the key set (keys-only link rows). serveis the one boot door;with_edge_observabilityis crate-private and no observability helper is re-exported.