Implementation plan
How Phase 1 gets built. Scope says what each phase contains; this says in what order, with what proving each step actually works.
This page is sequencing, not decisions. Anything load-bearing that gets decided while building it becomes a decision record, and this page is edited to match. If the two ever disagree, the records are right.
What shapes this plan
Three constraints, chosen deliberately, and every structural choice below follows from them.
Phase 1 ends at a local PostgreSQL, deployed nowhere. The exit criterion is an integration test, not a running system. That keeps identity federation, cloud deployment, relays and the console entirely out of Phase 1, and it means the spine can be got right without also getting a deployment right at the same time.
One person, alongside other work. So: strict sequencing rather than parallel tracks; steps sized to finish in a single sitting, because a half-finished step picked up three weeks later costs more than it saved; and the scaffolding that prevents rework goes first, since rework is what an intermittent schedule punishes hardest.
The grammar comes early. Six of the eight milestones depend on it and it is the piece with the most unknowns, so it is built second rather than discovered late (EDR-0039).
Definition of done
Every milestone, without exception:
make build test lintgreen, in CI, on a pull request.- A changelog entry — a new file, per the house rule.
- Any load-bearing decision made along the way written as a record before the milestone closes, not afterwards from memory.
- Its exit criterion demonstrated by a test that has been seen to fail for the right reason. A guard nobody has watched fail is a guard nobody knows works.
What "green in CI" covers
Four platforms are supported. They are not all checked the same way, and pretending otherwise would mean either a claim CI does not meet or a matrix bought for no reason. Three tiers, each with the reason it stops where it does:
| Tier | Platforms | What it proves |
|---|---|---|
| Build and smoke | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 | The release matrix builds on a native runner and the binary it produces runs. This is the tier that must not shrink: it is the only thing standing between a broken platform and a release. |
| Test suite | linux/amd64, darwin/arm64 | That the suite passes on these two platforms, and nothing about the other two. One runner per operating system is a sample. Each runner asserts which platform it actually is, so the row cannot quietly stop being true when a runner label moves. |
| Integration | linux/amd64 — arrives with M1 | Containers. macOS is excluded by the platform: GitHub-hosted runners there have no Docker daemon and service containers are Linux-only. linux/arm64 has Docker and is excluded by choice — the suite exercises the driver and the schema, not the architecture. |
The test tier is a cost decision, not a proof, and it is written that way on purpose. Two
arguments for it look like proofs and are both false, so they are written down here rather than
re-made later. "There is no assembly, no unsafe and no cgo" is not true of the compiled program —
every binary reaches architecture-specific assembly in runtime and syscall, and the tests
additionally compile protobuf's uintptr arithmetic — and it would not imply
architecture-independence if it were, since Go permits FMA contraction on arm64 and not amd64.
"The first-party tree has no concurrency and no floating-point arithmetic" fails the same way: the
committed generated code uses sync.Once, and make test runs -race, which is itself built per
architecture. Two runners are a sample, chosen because a defect that shows on one architecture and
not another is unlikely here today rather than impossible. Unlikely is a cost judgement, and that
is the honest name for it.
M2 is where the judgement expires: pg_query_go puts a C parser in the dependency graph, and a
grammar that classifies a statement differently on one architecture is a soundness bug the
build-and-smoke tier cannot see. That obligation is written into M2's own exit criterion rather than
left here, because whoever implements M2 will read M2.
The milestones
| Milestone | Proves | |
|---|---|---|
| M0 | Scaffolding | The toolchain, CI and the release skeleton work on an empty service |
| M1 | Walking skeleton | The six steps connect end to end — with nothing secured yet |
| M2 | The grammar | A statement can be classified and its scope extracted, provably |
| M3 | Marques | A grant is a signed artefact that verifies offline and cannot be stripped |
| M4 | Identity | Every principal is authenticated and every token is sender-constrained |
| M5 | The fence | A statement outside a delegated scope aborts, in every way it could escape |
| M6 | The logbook | The record is append-only against Marque's own database role |
| M7 | Phase 1 close | All of it, together, as one test and one written-down getting-started path |
M0 — Scaffolding
The rework-preventing milestone. Everything here is cheap now and expensive to retrofit.
- Go module;
cmd/marque,cmd/harbourmaster,cmd/pilotas three binaries that print their version and exit. Four components with sharply different trust means separate binaries, not one with a role flag (EDR-0001). buftoolchain;proto/marque/v1/common.protowith thesafeandidempotencyextensions; generation into a committedgen/. Wire upbuf breakingagainstmainand the build failure for an unannotated method now, while there is one method to annotate (EDR-0020).Makefilewith the uniform verbs —build test lint docs dev clean— andCGO_ENABLED=1throughout.- CI: build, test, lint on pull requests; least-privilege permissions; actions pinned to SHAs.
goreleaserconfigured but not releasing — a native runner per platform, proven by a snapshot build on each. Discovering the release matrix is broken at version 0.1 is a bad day; discovering it now costs an afternoon. Note what a snapshot proves before the grammar exists: that each runner is available, that the release plumbing works there, and that the binary it produces runs. Nothing in the dependency graph is C untilpg_query_golands in M2, so this starts testing a cgo matrix then — which is the point at which it would be expensive to learn that one of these platforms cannot build it.- The conformance-vector harness: an empty vector file and the test that executes it.
Exit: the build-and-smoke and test tiers green — the integration tier has no job until M1, and a
criterion that cannot fail is not one; buf breaking rejects a deliberately-broken schema change.
M1 — Walking skeleton — done
The thinnest path that touches every component, so integration is proved before anything is deep.
Its exit criterion is met: internal/e2e runs the six steps against a real PostgreSQL and asserts
the row changed, and the import rule has been seen to refuse a driver in each place that has ever
escaped it. What follows describes what was built; the
changelog entry has what it does not do.
Submit a statement → it is stored → approve it → run it against a local PostgreSQL → the result and the statement land in a table. No signing, no grammar, no identity, no fence.
This milestone deliberately builds something insecure, so it is contained by construction:
- Every command that touches anything refuses to start without
MARQUE_INSECURE_SKELETON=1, and prints a banner naming this milestone and the record-free state of what it is doing.versionis the exception and is meant to be: inspecting a binary should not require acknowledging what it would do if you ran it. - M5 deletes the flag, and a test asserts no such path exists in the binary. That test is written now, skipped with a reason, and un-skipped in M5.
Three things arrive with the store
(EDR-0042): the tenant-partitioned schema
and its first migration; a migrator that is an explicit command, refuses a divergent history, and
records each migration's content digest in the transaction that applies it and runs as its own role
rather than the runtime one, which M6 needs and which costs nothing at migration one; and the rule
confining a target engine's driver to the two packages that may hold one — the Harbourmaster's store
and the Pilot's adapter — landing in the same change as the first package that opens a
connection. That rule asks go list -deps what each binary links, with a filesystem walk as a
cross-check and a depguard block as the edit-time report.
Exit: an integration test against a real PostgreSQL running the six steps and asserting
the row changed; and the import rule seen to fail, by adding a driver import to a Harbourmaster
package that is not the store and watching the confinement test refuse it. It asks go list -deps
what each binary links, with a walk that parses every first-party file as a cross-check — and not
because a linter is incapable, since several claims of that shape were made here and every one was false
(EDR-0042). A linter's reach is its flags and exclusions, a walk's is its own skip rule, and
go list's is the patterns and tags it is given, so none of them may be asserted and all of them
are probed. The rule replaces a mechanism EDR-0005
lost, so a version of it that has never bitten is not a replacement. The first genuine end-to-end
signal, available in week one rather than month three.
It runs on linux/amd64 only, and behind a build tag so make test stays offline — see the tiers
above. make test-integration starts a disposable PostgreSQL in Docker on an ephemeral port,
creates the runtime role before the first migration because the schema grants unconditionally, and
removes the container on exit — or, with MARQUE_TEST_DSN already set, uses that server and starts
no container, because the suite needs a PostgreSQL rather than Docker specifically. No testcontainers dependency: a library that drives Docker is a
large dependency graph in the module the control plane ships from, and a Makefile target and a CI
job do the same job here. The DSN comes from the environment, and its absence is a failure
rather than a skip — a build-tagged suite that skips itself when unconfigured reports success
having run nothing.
M2 — The grammar
The foundation the rest of the authority model stands on (EDR-0039).
internal/grammaroverpg_query_go, behind an engine-shaped interface that names no PostgreSQL concept.- The subset walk: an allowlist over node types returning
in_subset/out_of_grammar/unsupported. Start smaller than feels useful — single-relationUPDATEandDELETEwith a conjunctive predicate over literals. A too-narrow subset produces a good error message; a too-broad one produces a soundness bug. - Scope extraction: relation, columns written, and the predicate the fence will be built from — spelled as EDR-0041 has it, which is what the loader already enforces.
- The conformance corpus, populated. Include the cases that should fail — a function call in a
predicate, a CTE, a second relation appearing via
FROM, a subquery — because a corpus of only happy paths tests nothing. - A differential harness: throw statements at both the parser and a real PostgreSQL, and assert they agree on what parses.
- A scheduled check that fails when
pg_query_gopublishes a new major.pg_query_gouses semantic import versioning, so/v6and/v7are different module paths and Dependabot will never propose that upgrade — the configuration isolates its minor and patch bumps and can do nothing about the major. A subscription in somebody's inbox is not a mechanism this repository can check, so the watch is a job that compares the module path pinned ingo.modagainst the latest released major and fails when they diverge. It goes inCODEOWNERSlike anything else with an owner. When a major does arrive it is not a dependency bump: a newer grammar parses statements the previous one refused, so the corpus is re-run and any changed verdict bumps the subset version (EDR-0039).
Exit: the corpus passes on all four supported platforms — this is the milestone the test tier widens for, because from here the parser is C and a grammar that classifies a statement differently per architecture is a soundness bug the build-and-smoke tier cannot see; the subset version is recorded on extracted scopes; widening the allowlist by one node type shows up as a corpus diff; and step 6's release watch has been seen to fail, by pointing it at a major newer than the one pinned.
M3 — Marques
The heart of the security argument (EDR-0004, EDR-0030).
- The payload: statement digest, target, role, submitter, not-before, expiry, budget, and the
per-stage
approvalsblock. - JWS general JSON serialisation with two signature limbs, ES256,
algrestricted to a configured set with no "whatever the header says" path. - Offline verification in the Pilot: signatures, then recomputation of the approval requirement from the anchored policy rather than trusting the payload's copy of it (EDR-0036).
- The principal roster, anchored outside the control plane (EDR-0031).
- The signed marque replaces M1's
approvalstable (EDR-0042), which is a row per approver rather than a signature — carryingstage, so it is not the flat shape EDR-0030 exists to refuse. M3 builds the marque; M7 wires it into the M1 path and deletes the stub.
Exit: the negative tests, each seen to fail first — a marque with only the control plane's limb
is rejected; stripping one of two signature entries is rejected; a two-stage marque is not
satisfied by two signatures from the same stage, which is the exact defect
EDR-0030 exists to prevent; an
alg outside the configured set is rejected.
M4 — Identity
- OIDC against a local test issuer; the bootstrap discovery document (EDR-0002) so a client needs one URL and no other configuration.
- DPoP proof-of-possession on every call (EDR-0003).
- The CLI's key store, and the
es256signing envelope for approvers (EDR-0023). Thewebauthnenvelope waits for the console in Phase 2 — the envelope split exists precisely so it can. - Freshness on producing an approver signature, not on execution (EDR-0035).
tenant_idcomes from the authenticated principal, replacing M1's single configured development tenant (EDR-0025, EDR-0042). It is never a request field, and the column has been there since the first migration precisely so this is a change of source rather than a change of schema.
Exit: a replayed token fails; a token presented without its proof fails; a token bound to a different key fails.
M5 — The fence
The milestone with the most ways to be subtly wrong, so it is mostly adversarial tests (EDR-0007, EDR-0033).
- Session setup:
REPEATABLE READ, pinnedsearch_path, statement and lock timeouts — plusstandard_conforming_stringsandbackslash_quote, which are read by the lexer and so must be settled in an earlier round trip and verified withcurrent_setting()rather than assumed from theSET. All three pins are re-verified before every step that follows code the Pilot did not compose, not once atBEGIN: a fence conjunct may call a function, aBEFOREtrigger may, and so may a deferred constraint trigger fired bySET CONSTRAINTS ALL IMMEDIATE— which runs immediately before the write-set assertion, not at setup. - The pre-check and the post-assert are TRUE-only, neither of them
NOT (…); the row-count assertion is the separate numeric one that enforcesmax_rows. A fence is a list of conjuncts, so each is composed(c1) AND (c2) AND …and the whole conjunction is wrapped again beforeIS NOT TRUE(EDR-0041). Both halves fail open if skipped, and both look right without them. - The write-set assertion over everything the engine wrote, not only the named relation.
- The execution nonce, claimed before the statement runs, with the budget consumed by the claim (EDR-0011).
- Delete
MARQUE_INSECURE_SKELETONand un-skip the test asserting it is gone.
Exit: each escape route proven to abort — a row whose fence predicate is NULL; a row an UPDATE
moves out of scope; a cascade the fence never named; a deferred constraint trigger that would
otherwise fire after the write set was read; a two-conjunct fence whose first conjunct carries a
top-level OR, composed both correctly and as c1 AND c2, where only the second admits a row
outside the fence; the same fence composed as (c1) AND (c2) IS NOT TRUE rather than
((c1) AND (c2)) IS NOT TRUE, where a row failing c1 alone goes uncounted; a conjunct that closes
its own parenthesis; a conjunct carrying a $n parameter reference, a comment token or a control
character; an empty fence array, an empty-string conjunct and a duplicate conjunct, each refused by
the Pilot rather than assumed away at authoring; a BEFORE trigger on the target that calls
set_config to move search_path out from under the checks that follow the statement; a deferred
constraint trigger that calls set_config between SET CONSTRAINTS ALL IMMEDIATE and the write-set
assertion; a fence conjunct that calls set_config during the pre-check and leaves the pins moved,
caught before (b); and a crash between claim and commit losing the attempt rather than the count.
One escape route is deliberately not on that list, because it does not abort: a conjunct whose
function restores search_path on exit, or which simply returns a spoiled answer, defeats the
pre-check without leaving anything for a later check to see. That is the gap pinned to
issue #25, and M5 records it rather than implying a
control it does not have.
M6 — The logbook
- The append-only schema and the hash chain (EDR-0012).
- The grant setup as a migration: Marque's role holds
INSERTandSELECTand does not own the table, because an owner can grant itself anything. - Chain verification, and the tail.
- The journal becomes authoritative. M1 made
requests.statethe truth because there was no journal to project from (EDR-0042); from here current state is a rebuildable projection, which is what EDR-0012 decided.
Exit: a test connecting as Marque's own role proves UPDATE and DELETE are denied, and that
it cannot grant them to itself. The immutability claim is worth exactly as much as that test.
M7 — Phase 1 close
Wire M2–M6 into the path M1 stubbed, and delete the stubs. Write the getting-started guide by following it on a clean machine, which is the only way that document is ever correct.
Exit — and the Phase 1 exit criterion: one integration test in which a statement is submitted, classified by the real grammar, approved into a doubly-signed marque by an authenticated approver, executed under a fence that aborts when it should, and appended to a chained logbook — against a local PostgreSQL, in CI.
What not to build yet
Named because they are the interesting parts, and interesting parts are what an intermittent schedule drifts toward.
| Not yet | Until |
|---|---|
marque psql and the loopback proxy |
M7. They are the most fun and the least load-bearing, and neither can be right before the grammar is |
| The console | Phase 2. It needs the webauthn envelope, which needs a signing path that exists |
| The Leadsman | Phase 2. An analyser with nothing to analyse is a demo |
| Delegation and compiled sentences | Phase 3. M5 builds the fence; granting through it is a separate problem |
| Agents | Phase 3b, in that order, for the same reason |
| Relays, cross-cloud, deployment | Phase 3 and beyond. Phase 1 deploys nowhere on purpose — which is also why backup, restore and roll-forward for the control plane's store are absent, and why a forward-only schema is acceptable until they exist (EDR-0042) |
| Slack | Phase 2. It rides the WAL stream, so it is cheap whenever it lands |
Decision debt
Decisions that will have to be made during Phase 1 and do not have a record yet. Each becomes one before the milestone that needs it closes.
The control-plane storage schema and its migration tooling — M1.Settled by EDR-0042.- Where the Pilot keeps its execution ledger — M5. It is the fence (EDR-0011), it must be Pilot-local and survive a target transaction rolling back, and the target database is the wrong place for it: issue #34.
- Where a target's connection parameters live — M1's Pilot, which is the change that will otherwise settle it by accident: issue #36.
- The Go module and package layout, specifically where the engine-shaped boundary sits so a second engine is an implementation rather than a fork — M2.
- The test-issuer and local-development story, which is also the adopting team's first experience — M4.
- The console's build stack — Phase 2, constrained already to static, same-origin and CSP-strict (EDR-0024).
After Phase 1
Deliberately low resolution: planning it now would be fiction, and Scope already fixes what each phase contains. The two sequencing points worth stating in advance:
- Deployment comes before usability. Phase 2 opens by getting a Harbourmaster and a Pilot running somewhere real with federated identity, because the deployment story is the thing Phase 1 deferred and everything else in Phase 2 is easier to judge against a system that exists.
- The proxy and
marque psqlcome before the console. The scope doc's top risk is people routing around it; the surface that addresses that risk is the one that makes Marque disappear into the tools people already use.
The first session
git switch -c m0-scaffolding
go mod init github.com/sixfathoms/marque
mkdir -p cmd/marque cmd/harbourmaster cmd/pilot proto/marque/v1 internal/grammar
Then M0 step 1: three binaries that print a version and exit, a Makefile with the six verbs, and a
CI workflow that runs them. It is a deliberately small first step, and it is the one that makes every
step after it cheaper.