M1-15: Sequence counters and instance record¶
Status: ready | Depends on: M1-11, M1-13 | Milestone: M1
Goal¶
After this ticket the module instance owns the tables instance and sequence_counters, offers
the port Counters that only the phase hooks of authz, sync and audit may use, records the
instance identity, schema version and minimum server version during bothy migrate up, exposes
the setup state, and keeps a server whose version is below instance.min_server_version from
becoming ready. The counter rows are the serialization points that make event sequence, audit
chain and permission version gapless and commit-ordered (B8, ADR 0009, 03 R-DM-05).
Read first¶
docs/spec/03-data-model.md, "Principles" (items 1, 2, 11, 12), "Instance (instance)" tablesinstanceandsequence_counters, "Transaction rules" ("Global event sequence", "Lock order", "Sync epoch and restore"), "Decisions of this chapter" (R-DM-01, R-DM-05, R-DM-26).docs/spec/04-sync-protocol.md, "Global, commit-ordered sequence" ("Pattern with a counter row", "Other counters").docs/spec/02-architecture.md, "Module list" (instance), "Ports, events and sources per module" (Counters), "Allowed dependencies" (instanceuses nothing), "Flow", "Lock order" (only the phase hooks lock counters, checked against the registrations), "Other checks in the gate", "Start" (steps 4 and 5), "Health", "Commands of the binary" (migratesetsinstance.min_server_version).docs/spec/16-operations.md, "Two listeners", "Database" ("Roles"), "Upgrade" (item 4).docs/operations/upgrade.md, "Rollback" and "Schema check at startup" (check nameschema_requires_newer_server).docs/spec/08-identity.md, "Initial installation" (meaning ofsetup_completed_at).docs/spec/18-release.md, "Versions" rules (fileVERSION, SemVer withoutv).- ADR 0007, ADR 0009.
- What earlier tickets built:
internal/kernel/txn,internal/platform/pgunit of work andinternal/wireregistry plus golden file (M1-13);migrations/,cmd/bothy migrate,pgtest(M1-12);internal/platform/healthreadiness checks (M1-11);internal/instance/README.md. docs/PLAN.mdsection "Way of working per ticket";docs/quality/gates.md;CONTRIBUTING.md;docs/quality/red-team-gate.md.
Tables: instance, sequence_counters. No API operation 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). - Tables exactly as 03 defines
instanceandsequence_counters(columns, types, nullability, defaults, checks, keys, retention classconfig); the three counter rowsevent_seq,audit_seq,permission_versionare created by the migration that creates the table (03). - IDs are UUIDv7 generated by the application, never by the database (R-DM-01).
- Counter values only increase (03 "Rules / notes" of
value); locking a counter is the last step before commit forevent_seqandaudit_seqand the first step forpermission_version(03 "Lock order", 04 "Pattern with a counter row"). - Only the phase hooks of
authz,syncandaudituseCounters; no use case calls it (02 "Lock order"). - Only the module
instancereads and writes its tables (03 principle 12). bothy migratesetsinstance.min_server_version; a server with a smaller version does not become ready and/readyznamesschema_requires_newer_server(02 "Commands of the binary", 16 "Upgrade" item 4, upgrade.md "Schema check at startup" item 2). A newer, compatible schema without this lock is allowed (rollback path, upgrade.md "Rollback").- Without
setup_completed_atonly the setup path starts (02 "Start" step 5; the setup path itself is M1-37).
Scope¶
All Go paths are relative to services/server/.
- Migration (goose, SQL, in
migrations/): tablesinstanceandsequence_countersper 03;INSERTof the three counter rows with value0; aBEFORE UPDATEtrigger functioninstance_counter_monotonic()that raises whenNEW.value < OLD.valueor the name changes; grants forbothy_app:SELECT, UPDATE (value)onsequence_counters,SELECToninstance; noINSERTorDELETEforbothy_appon either table. Follow the grant pattern M1-12 set up. Name the file with the next free sequential number of the base branch (ADR 0007 rule 4); renumber before the finalmake checkif another ticket took it. internal/instance(port):type CounterName stringwithCounterEventSeq = "event_seq",CounterAuditSeq = "audit_seq",CounterPermissionVersion = "permission_version".type Counters interface { Advance(ctx context.Context, name CounterName, n int64) (last int64, err error); Current(ctx context.Context, name CounterName) (int64, error) }.Advancelocks the row until commit (UPDATE ... SET value = value + $n ... RETURNING value) and returns the new value, so the numbers arelast - n + 1 .. last.Currentreads the value without a lock (inside the hook that already holds the lock, or the committed value).type Record struct { InstanceID ids.ID; SetupCompletedAt time.Time; SchemaVersion int64; MinServerVersion string; SyncEpoch ids.ID; CreatedAt time.Time }withfunc (r Record) SetupCompleted() bool(zero time means not completed; no JSONnullanywhere later, ADR 0053).type State interface { Load(ctx context.Context) (Record, error); RecordMigration(ctx context.Context, schemaVersion int64, minServerVersion string) (Record, error) }.Loadreturnsnot_foundkind when the row is missing.RecordMigrationcreates the row if missing (new UUIDv7 forinstance_idandsync_epoch) and setsschema_versionandmin_server_version; it never changesinstance_id,sync_epochorsetup_completed_at.
internal/instance/domain: counter name validation; phase rule (see Design notes);CompareSemVer(a, b string) (int, error)after SemVer 2.0.0 precedence including pre-release identifiers (https://semver.org/spec/v2.0.0.html, section 11), hand-written because the domain layer imports no third-party library;CheckServerVersion(own, min string) error.internal/instance/app: services implementingCountersandStateover an outgoing repository port.internal/instance/adapters/postgres: sqlc queries underqueries/, repository; entry insqlc.yaml; generated code checked in.- Server version:
internal/platform/buildinfo.Version()from M1-10 (18 "Components" rule 5). - Schema compatibility constant (18 "Components" rule 6, upgrade.md "Schema check at startup"):
in the package that embeds
migrations/(M1-12), addconst MinServerVersion = "<content of services/server/VERSION>"with a doc comment: the oldest server version allowed to run against the schema these migrations produce; per upgrade.md rule M1 it is the previous release, a deliberate break sets the own version; before the first release it equals the current version. cmd/bothy migrate up: after the migrations, if the database version equals the newest migration this binary knows, callState.RecordMigration(goose version, MinServerVersion); if the database is newer than this binary, leave the row unchanged and exit 0 (upgrade.md "Rollback" item 1). The write runs in a unit of work on the migrate connection.- Start (
internal/wire,internal/platform/lifecycle): after the schema check of M1-12, load the record (missing row: abort start with a message telling the instance operator to runbothy migrate up); keep it for later tickets (setup state,instance_id,sync_epoch); runCheckServerVersion(buildinfo version, record.MinServerVersion); on failure register a failing readiness check namedschema_requires_newer_serverwith the API ofplatform/health, keep running, never become ready. Applies to all roles. - Phase guard and injection:
Countersis constructed only ininternal/wireand passed only to phase hook constructors (none exist yet). - Static check
TestCountersOnlyUsedByPhaseHooksininternal/wire(see Tests). - Docs:
internal/instance/README.md(status, tables,Countersrule,State, start check); CHANGELOG. 02 "Lock order" and "Other checks in the gate" (phase guard, static check), 03instance(row created bymigrate up), 18 (version constants) and upgrade.md already say what this ticket builds.
Out of scope¶
instance_settings,feature_flags,server_signing_keysand their portsSettings,Flags,Keys(M1-46, M1-47, M1-32).- Setting
setup_completed_atand the503 setup_requiredbehavior (M1-37); the setup path is only read here. - A new
sync_epochafter restore (bothy restore finalize, M7). - The phase hooks that use
Counters(M1-16sync, M1-24audit, M1-29authz). - The start abort on an older schema (goose version check, M1-12).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-15-instance-counters. - Conventional Commits with scope (for example
feat(instance): ...) 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 and listeners bind127.0.0.1and port 0 or 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¶
- Phase guard (02 "Lock order": enforce the lock order at run time instead of relying on
review): the
Countersservice checkstxn.CurrentPhase(ctx):Advance(permission_version)only inrights_begin;Advance(event_seq)only insync;Advance(audit_seq)only inaudit;Current(...)only in one of the four hook phases. Any other call returns kindinternalwith a message naming counter and phase.n < 1is an internal error. Unknown names are rejected in the domain before SQL; the tableCHECKis the second line. - Static check:
TestCountersOnlyUsedByPhaseHooksparses the Go packages underinternal/(golang.org/x/tools/go/packagesif already ingo.mod, elsego/parserplusgo/types) and lists every package that referencesinstance.Countersor its methods. Allowed:internal/instanceand its subpackages,internal/wire, and the packages of the hook handlers in the realtxn.Registryregistrations (fieldHandlerof the M1-13 registrations, package derived from the function name). Any other package fails the test. A fixture package underinternal/wire/testdatathat referencesCountersmust be reported (negative test). - Commit order: the row lock taken by
Advanceis held until commit; inREAD COMMITTEDa waiting transaction rereads the committed value (04 "Pattern with a counter row" items 1 to 4). Nothing in this module may runSELECT ... FOR UPDATEon the counters outsideAdvance. instancerow:id = 1(CHECK (id = 1)),instance_idandsync_epochUUIDv7 fromkernel/ids,schema_versionfrom goose,created_atdefault. Created bymigrate up(03instance). Concurrentmigrateruns are safe byINSERT ... ON CONFLICT (id) DO NOTHINGfollowed by the update.- Readiness message:
/readyznames only the check (schema_requires_newer_server), no versions (16 "Two listeners"); the log line at start gives both versions. - Start check runs once at start (02 "Start" step 5 and 16 "Upgrade" item 4: the process keeps running but never becomes ready).
Tests (write them first)¶
Integration tests use pgtest and connect as bothy_app unless stated.
TestMigration_InstanceTablesMatchSpec: columns, types, nullability, defaults, checks and keys of both tables read frominformation_schemaandpg_constraintequal the 03 definition.TestMigration_CounterRowsCreated: three rows with value 0.TestCounters_ConcurrentAdvanceGaplessInCommitOrder: many goroutines run units whose testsynchook callsAdvance(event_seq, k)with random k and writes the range into a scratch table; a random sleep afterAdvanceand random rollbacks; a reader polls the scratch table and at every read the visible ranges form one contiguous block from 1; at the end the block ends at the committed counter value.TestCounters_RollbackLeavesNoGap.TestCounters_PhaseGuard:Advanceinwork,Advance(event_seq)inaudit,Advance(audit_seq)insync,Advance(permission_version)insync,Currentinworkall fail with kindinternal; the allowed combinations succeed.TestCounters_RejectsUnknownNameAndNonPositiveN.TestCounters_ValueNeverDecreases: a directUPDATElowering a value (asbothy_owner) raises;bothy_appcannotINSERTorDELETEcounter rows.TestCountersOnlyUsedByPhaseHooksand its fixture case.TestState_RecordMigrationCreatesAndKeepsIdentity: first call creates the row with UUIDv7 IDs; second call keepsinstance_id,sync_epochandcreated_at, updates the versions.TestState_LoadMissingRow.TestMigrateUp_RecordsSchemaAndMinServerVersion(command level): aftermigrate upthe row has the goose version andMinServerVersion; with a database newer than the binary (simulate by applying an extra test migration) the row stays unchanged and the exit code is 0.TestStart_MinServerVersionAboveOwn_NotReady: setmin_server_versionabove the own version; start the server on port 0;/readyzreturns 503 namingschema_requires_newer_server;/healthzreturns 200.TestStart_InstanceRowMissing_AbortsWithHint.TestCompareSemVer: table including pre-release ordering from SemVer 2.0.0 section 11 examples and invalid input.TestMinServerVersionNotAboveOwnVersion:MinServerVersionis valid SemVer and not greater than the build version.
Commands that must pass:
cd services/server && go test -race -count=1 ./internal/instance/... ./internal/wire/... ./cmd/...
make migrations-lint
make codegen-check
make arch
make check
Acceptance criteria¶
- Concurrent increments are gapless and follow commit order, also with rollbacks.
- Counters can only be advanced in their phase; the static check limits
Counterstoinstance,wireand registered phase hooks (fixture proves it fails otherwise). - Counter values cannot decrease;
bothy_appcannot insert or delete counter rows. -
migrate uprecordsinstance_id,sync_epoch,schema_version,min_server_versionand leaves a newer schema untouched. - A start with
min_server_versionabove the own version does not become ready and/readyznamesschema_requires_newer_server. - The setup state is available from
Record.SetupCompleted()at start. - README and CHANGELOG updated; 02, 03, 18 and upgrade.md still match the build;
make checkgreen.
Definition of done¶
Code, tests and docs are complete: internal/instance/README.md current (status, ports, tables,
rules), doc comment on every public symbol (purpose, parameters, return value, errors, example
where useful), comments explain why. No new ADR (R-DM-05 and ADR 0009 cover the decision); rulings
go into docs/DECISIONS.md. CHANGELOG entry under "Unreleased" in services/server/CHANGELOG.md.
The migration has its final sequential number before the merge. 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>.