M1-13: Unit of work with phase hooks and in-transaction events¶
Status: ready | Depends on: M1-06, M1-08, M1-12 | Milestone: M1
Goal¶
After this ticket every write use case of the server runs in exactly one PostgreSQL transaction
that internal/platform/pg opens, carries through the context (ambient transaction), runs the
pre-commit phase hooks in the fixed order authz, sync, audit, delivers in-transaction
events synchronously and retries the whole unit on deadlock or lock timeout. This is the
mechanism that makes the commit-ordered event log (B8, ADR 0009), the I1 check and the audit
hash chain correct: the lock order of 03 "Lock order" is enforced in one place instead of
in every use case. No module hook is built here; the phase hooks of authz, sync and audit
arrive with M1-29, M1-16 and M1-24 and plug into the registry this ticket builds.
Read first¶
docs/spec/02-architecture.md, sections "Transactions and Unit of Work" (all of "Flow", "Lock order", "In-Tx events", "Idempotency in the Unit of Work", "What never happens in a transaction"), "Cross-cutting ports (internal/kernel)", "Layers within a module", "Allowed dependencies", "Automatic check of the rules" ("Module register as the source", "go-arch-lint", "Other checks in the gate"), "Error and timeout strategy" ("Error kinds", "Deadlines", "Retries", "Panics"), "Decisions of this chapter" (R-AR-04 to R-AR-07, R-AR-18), "Configuration values of this chapter".docs/spec/03-data-model.md, "Principles" (item 11 "Isolation level"), "Transaction rules" ("Global event sequence", "Lock order", "I1 check").docs/spec/04-sync-protocol.md, "Global, commit-ordered sequence" (why the counter step is last and whyREAD COMMITTEDis required).docs/spec/06-api.md, "Errors" (the generated problem code table; only codes listed there exist on the wire).- ADR 0007 (
docs/adr/0007-typed-database-access-sqlc-and-migration-tool.md), ADR 0008 (docs/adr/0008-hexagonal-modules-and-checked-dependency-rules.md), ADR 0009 (docs/adr/0009-commit-ordered-event-log-with-a-global-sequence.md, section "Confirmation"). docs/observability/signals.md, sections "Spans" and "Attributes in spans and logs".- What earlier tickets built:
services/server/internal/kernel/(M1-05:ids,errs,clock, the declaredtxnport),services/server/internal/platform/pg/andpgtest/(M1-12: pool,SET LOCALtimeouts, disposable PostgreSQL 18 container),services/server/internal/wire/,services/server/architecture/modules.yaml,tools/archgen/,.semgrep/(rule plus fixture pattern, seebothy-go-sql-string-building.*). docs/PLAN.mdsection "Way of working per ticket" and section 7 "M1 Foundation";docs/quality/gates.md;CONTRIBUTING.md;docs/quality/red-team-gate.md.
This ticket touches no API operation, no table of 03 and no action id.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: write each test, run it and see it fail (keep the red output for the report), then write the code until it passes, then clean up.
- No en dash or em dash characters in anything you write (code, comments, docs, commit
messages). Check every changed file with
uv run --project tools --locked --quiet python tools/hooks/text_rules.py <file>; it must exit 0 (plaingrep -Pfails on macOS). - White label: no company names, company or mail domains, person names, host names, real IP
addresses or paths under a user home directory. Examples use
example.comand the names Alex, Sam, Robin. - Configuration values only through named keys that appear in
docs/operations/configuration.mdand the config schema in code (Ruling L4). No literal limit, timeout or retry count in code outside the schema defaults. - Every write use case runs in exactly one transaction at
READ COMMITTED; a call into another module joins the ambient transaction; onlyplatform/pgopens and ends transactions (02 "Flow", R-AR-06). - Phases before commit in fixed order: permission begin hook (only with
RightsChange), work,authzcheck hook (only withRightsChange),synchook,audithook, commit (02 "Flow"). A veto in the work phase or in theauthzcheck rolls everything back. - In-transaction events are delivered synchronously, in registration order, inside the same transaction; a subscriber may write, append durable events and veto the commit with an error; every subscriber gets its own OpenTelemetry span (02 "In-Tx events").
- Registration of hooks and subscriptions happens only in
internal/wire, as one list; a golden file records it; a subscription may only point from a higher module to a lower one (02 "Other checks in the gate", R-AR-04). - On SQLSTATE
40P01(deadlock) or55P03(lock not available) the whole unit is retried at mostdatabase.tx_retry_maxtimes (02 "Lock order", "Retries", R-AR-18). - Write transactions never run at
REPEATABLE READorSERIALIZABLE; a test proves it (ADR 0009, "Confirmation"). - Every transaction applies
database.statement_timeout,database.lock_timeoutanddatabase.idle_in_tx_timeoutwithSET LOCAL(02 "Deadlines").
Scope¶
All Go paths are relative to services/server/. Where M1-05 or M1-12 already created a file or a
name below, extend it and keep the existing name if it is compatible; the shapes below are the
contract later tickets (M1-14, M1-15, M1-16, M1-24, M1-29) build on.
internal/kernel/txn(port, standard library andinternal/kernel/ids,internal/kernel/errsonly):type Phase uint8withPhaseNone,PhaseRightsBegin,PhaseWork,PhaseRightsCheck,PhaseSync,PhaseAudit;String()returnsnone,rights_begin,work,rights_check,sync,audit.type UnitOfWork interface { Do(ctx context.Context, fn func(ctx context.Context) error, opts ...Option) error }.type Option;func RightsChange(affected ...ids.ID) Option(02 names the optionRightsChange);func AffectedPrincipals(ctx context.Context) ([]ids.ID, bool).func CurrentPhase(ctx context.Context) Phase(returnsPhaseNoneoutside a unit).func InUnit(ctx context.Context) bool.type Hook func(ctx context.Context) error.type Registry;func NewRegistry() *Registry;func (r *Registry) SetHook(p Phase, h Hook) error(allowed phases:PhaseRightsBegin,PhaseRightsCheck,PhaseSync,PhaseAudit; each at most once; afterSealan error);func Subscribe[E any](r *Registry, handler func(ctx context.Context, ev E) error) error;func (r *Registry) SetTracer(t Tracer);func (r *Registry) Seal();func (r *Registry) Sealed() bool;func (r *Registry) Registrations() []Registration.type Registration struct { Kind string /* "hook" or "subscribe" */; Phase Phase; Event string /* "<module>.<TypeName>", kernel events "kernel.<TypeName>" */; Publisher, Subscriber string /* module names */; Handler string /* runtime function name */ }. Publisher and subscriber modules are derived, not declared: the publisher from the Go package path ofE(segment after/internal/;kernelforinternal/kernel/...), the subscriber and hook owner fromruntime.FuncForPCof the handler (same rule). A handler whose module cannot be derived (for example a closure defined ininternal/wire) is rejected with an error, so registrations always name the real owner. Hooks and handlers are therefore method values or functions of the owning module.func Publish[E any](ctx context.Context, ev E) error: deliversevto the subscribers of typeEin registration order. Errors: outside a unit; phase notPhaseWorkorPhaseRightsCheck; first subscriber error is returned unchanged (veto) and later subscribers do not run.- Per-unit state:
type Key[T any];func NewKey[T any](name string) *Key[T];func Load[T any](ctx context.Context, k *Key[T]) (T, bool);func Store[T any](ctx context.Context, k *Key[T], v T) error(error outside a unit). The state is fresh for every attempt and dropped on rollback; this is where M1-16 buffers durable events and M1-24 buffers audit records. type Tracer func(ctx context.Context, spanName string) (context.Context, func(err error)); the registry calls it once per subscriber delivery with span nametxn.subscribe <Subscriber>(see Design notes).type Unit interfaceplusWithUnit(ctx, Unit) context.ContextandUnitFrom(ctx) (Unit, bool): the minimal contractplatform/pgimplements so the kernel helpers above can read phase, registry, options and state. Document that onlyplatform/pgimplements it.
internal/platform/pg(implementation):type UnitConfig struct { RetryMax int; StatementTimeout, LockTimeout, IdleInTxTimeout time.Duration }, filled ininternal/wirefromdatabase.tx_retry_max,database.statement_timeout,database.lock_timeout,database.idle_in_tx_timeout.func NewUnitOfWork(pool <M1-12 pool type>, cfg UnitConfig, reg *txn.Registry) *UnitOfWorkimplementingtxn.UnitOfWork.Dobeforereg.Seal()returns an internal error.func Tx(ctx context.Context) (pgx.Tx, error): the ambient transaction, internal error outside a unit (used by repositories for writes and by M1-14 for RiverInsertTx).func Querier(ctx context.Context, pool <pool type>) <sqlc DBTX>: the ambient transaction if there is one, else the pool (reads outside a unit).
internal/wire:- one function
registerTxn(reg *txn.Registry, c *<container>) errorcalled by every role, the only place withSetHookandSubscribecalls; thenreg.Seal(). Today it registers nothing except the tracer (no module hook exists yet). func TxnRegistrations(reg *txn.Registry) string: the golden rendering (format in Design notes).func CheckTxnDirections(regs []txn.Registration, modules <register>) errorusingarchitecture/modules.yaml(reuse the loader fromtools/archgenor the YAML library already ingo.mod; do not add a second YAML library).testdata/txn_registrations.golden.- module constructors used by
registerTxnmust not open network connections, so the golden test can build the real registration without a database.
- one function
.semgrep/bothy-go-tx-only-in-platform-pg.yamlwith fixture.semgrep/bothy-go-tx-only-in-platform-pg.go: flags$X.Begin(...),$X.BeginTx(...),pgx.BeginFunc(...),pgx.BeginTxFunc(...)in Go files outsideservices/server/internal/platform/pg/(test files excluded). Message cites 02 "Flow". If a later ticket needs a raw transaction elsewhere (for example River internals), that is library code and not scanned.- Documentation: 02 "Flow" (registry, outermost
RightsChange, phases forPublishand events, hooks and subscribers as functions of the owning module), 02 "Retries" (exhausted retries end with503 unavailable), 02 "Lock order" (the idempotency wait is not retried) and the spantxn.subscribe <Subscriber>in signals.md already state this; change them only if the build proves a statement wrong. Write:services/server/README.md(kernel and platform tables if wording changes),internal/kernel/txndoc.go,internal/platform/pg/README.md(create if missing): phases, retry rule, timeouts, examples.services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdrows only for decisions the spec does not state.
Out of scope¶
- The phase hooks of
authz(M1-29),sync(M1-16) andaudit(M1-24); the counters andinstance.Counters(M1-15). - Idempotency keys inside the unit (M1-20; that ticket decides how its own
55P03wait maps to409 request_in_progress, see 02 "Idempotency in the Unit of Work"). - River and
InsertTx(M1-14). - Mapping errors to HTTP problems (M1-18); this ticket only returns
kernel/errskinds. - Metrics (M1-68).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-13-unit-of-work. - Conventional Commits with module scope (for example
feat(pg): ...,feat(txn): ...) and DCO sign-off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except the docs and changelog entries named here.
- Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. Test containers bind127.0.0.1and a random port (P-09, P-10). - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
Design notes¶
- Isolation: begin every transaction with
pgx.TxOptions{IsoLevel: pgx.ReadCommitted}explicitly; never rely on the database default. - Timeouts: right after
BEGIN, apply the threeSET LOCALvalues with the helper M1-12 built; if M1-12 has none, add it inplatform/pg. - Order inside one attempt: begin; if
RightsChange: phaserights_beginhook; phasework: runfn; ifRightsChange: phaserights_checkhook; phasesynchook; phaseaudithook; commit. A missing hook for a phase is skipped. The phase is visible throughtxn.CurrentPhase. - Nesting:
Doinside a unit joins it, runsfnin the current phase and never commits, rolls back or retries by itself; any error propagates to the outerDo. A nestedDowithRightsChangeis an internal error unless the outer unit declaredRightsChangewith a superset of the principals (ruling: the permission begin hook must lockpermission_versionbefore any domain row, 03 "Lock order" item 1, so the option cannot appear late). - Retry: only the outermost unit retries, only when
errors.Asfinds a*pgconn.PgErrorwith code40P01or55P03, only whilectx.Err() == nil, at mostdatabase.tx_retry_maxtimes (attempts = 1 + retries). Each attempt starts with fresh per-unit state and re-runsfn, so in-transaction events are re-published. After the last failed attempt return an error of kindunavailablewith codeunavailablethat wraps the last database error (02 "Error kinds", "Retries"; the SQLSTATE stays in the log). Any other error is returned unchanged and not retried. - Rollback on every non-nil error and on panic; after rollback a panic is re-raised so the HTTP
chain (M1-18) turns it into
500. - Context: a cancelled context aborts the transaction (pgx does that); no retry after cancel.
Publishfrom a subscriber is allowed (nested delivery runs in the same phase); recursion depth is bounded by the call stack only; document it.- Registry derivation of module names: package path segment after
/internal/; forinternal/kernel/...the module iskernel. Event names in registrations are<module>.<TypeName>. -
Golden format, one line per registration, sorted by kind then phase order then registration order:
With no module registered yet, the file holds only the header line.# txn registrations; regenerate with: go test ./internal/wire -run TestTxnRegistrationsGolden -update hook <phase> <module> <handler> subscribe <publisher>.<Event> -> <subscriber> <handler> -
Direction rule: a subscription is valid if the publisher is
kernelor the publisher is independs_onof the subscriber inarchitecture/modules.yaml. A hook owner must be the module named for that phase (authzfor both permission phases,sync,audit). - Span per subscriber: name
txn.subscribe <subscriber module>,bothy.outcomeokorveto; the tracer comes fromplatform/oteland is set ininternal/wire. Without a tracer the registry runs subscribers without spans. - Configuration (02 "Configuration values of this chapter"):
database.tx_retry_max(2),database.statement_timeout(5s),database.lock_timeout(2s),database.idle_in_tx_timeout(10s). If M1-07 has not registered any of them in the schema, register them with these defaults somake config-doc-checkstays green.
Tests (write them first)¶
Integration tests use pgtest (PostgreSQL 18 container on a random port; without a Docker
runtime the test fails, P-09) and a scratch table the test creates.
internal/platform/pg:
TestUnitOfWork_PhaseOrderWithRightsChange: fake hooks append their phase to a slice; resultrights_begin, work, rights_check, sync, audit.TestUnitOfWork_PhaseOrderWithoutRightsChange:work, sync, audit; permission hooks not called.TestUnitOfWork_SubscriberVetoRollsBack:fninserts a row, stores a value under atxn.Key, publishes an event; the subscriber returns an error;Doreturns that error (errors.Is), the row is absent, the sync and audit hooks never ran, and a new unit sees no stored value.TestUnitOfWork_RightsCheckVetoRollsBack: the permission check hook returns aninvariant_violationerror; nothing is committed; sync and audit hooks did not run.TestUnitOfWork_RetriesDeadlockUpToMaxandTestUnitOfWork_RetriesLockTimeoutUpToMax:fnreturns a*pgconn.PgErrorwith40P01(resp.55P03) for the first k attempts; withRetryMax = 2: k = 2 succeeds after 3 attempts; k = 3 fails after 3 attempts with kindunavailablewrapping thePgError(errors.As).TestUnitOfWork_DoesNotRetryOtherErrors:23505and57014are returned after one attempt.TestUnitOfWork_RealDeadlockIsRetried: two units lock rows A then B and B then A behind a barrier; both finally commit; one needed a retry.TestUnitOfWork_FreshStatePerAttempt: a value stored in attempt 1 is not visible in attempt 2.TestUnitOfWork_NestedJoins: innerDosees the sametxid_current(); an inner error rolls back the outer work; the inner unit never retries alone.TestUnitOfWork_NestedRightsChangeRejected: innerRightsChangewithout a covering outer one is an internal error.TestUnitOfWork_ReadCommittedEnforced(ADR 0009 Confirmation): withALTER DATABASE ... SET default_transaction_isolation = 'serializable'on the test database,SHOW transaction_isolationinsideDoreturnsread committed.TestUnitOfWork_TimeoutsApplied:SHOW statement_timeout,SHOW lock_timeout,SHOW idle_in_transaction_session_timeoutinsideDoequal the configured values.TestUnitOfWork_PanicRollsBackAndRepanics.TestUnitOfWork_NoRetryAfterCancel.TestUnitOfWork_RefusesBeforeSeal.
internal/kernel/txn:
TestPublish_RegistrationOrder,TestPublish_OutsideUnit,TestPublish_WrongPhase(sync,audit),TestPublish_SpanPerSubscriber(fake tracer recordstxn.subscribe <module>in order and the outcome),TestRegistry_SetHookTwice,TestRegistry_SealRejectsChanges,TestRegistry_RejectsUnderivableHandler,TestKey_OutsideUnit.
internal/wire:
TestTxnRegistrationsGolden(with-updateflag) renders the real registration of all roles and compares withtestdata/txn_registrations.golden; it also runsCheckTxnDirections.TestCheckTxnDirections_ReverseSubscriptionFails: fixture registrations whereinstancesubscribes to achatevent; the check fails and the message names both modules.TestCheckTxnDirections_WrongHookOwnerFails.
Semgrep: the fixture contains // ruleid: cases for each pattern and // ok: cases.
Commands that must pass:
cd services/server && go test -race -count=1 ./internal/kernel/txn/... ./internal/platform/pg/... ./internal/wire/...
make semgrep
make arch
make codegen-check
make config-doc-check
make check
Acceptance criteria¶
- Hook order is logged as specified with and without
RightsChange(tests above). - A subscriber veto rolls back domain rows and per-unit state (buffered events).
- Simulated
40P01and55P03are retried at mostdatabase.tx_retry_maxtimes; the exhausted case returns kindunavailable; other errors are not retried. - A real deadlock between two units is resolved by the retry.
- A nested call joins the transaction; nested
RightsChangewithout cover is rejected. - Transactions run at
read committedeven with a serializable database default. - The golden test fails for a subscription in the wrong direction (fixture).
- The Semgrep rule flags
Begin/BeginTx/BeginFunc/BeginTxFuncoutsideplatform/pg. - READMEs and CHANGELOG updated; 02 and signals.md still match the build;
make checkgreen.
Definition of done¶
Code, tests and docs are complete: module README current (status, ports, phases), doc comment on
every public symbol (purpose, parameters, return value, errors, example where useful), comments
explain why. No new ADR is needed (R-AR-06 and ADR 0009 cover the decision); the rulings go into
docs/DECISIONS.md. CHANGELOG entry under "Unreleased" in services/server/CHANGELOG.md.
Migration-free. make check green. Independent review (make review plus a code review over the
diff) and the red team gate (docs/quality/red-team-gate.md) over the whole diff with verdict
Urteil: JA recorded in the report; findings fixed with a test each and the delta re-checked.
Merge locally into the base branch only if review and red team are green, then
git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red output of
the first test run), what is not proven, and follow-ups that could not be done in scope with the
reason. Then write {{DONE_PATH}} with exit=0 sha=<merge sha> or exit=1 <reason>.