The write-service protocol is loopback HTTP with a bearer token from the git common dir
Status: Accepted (2026-10-27). Operator rulings of 2026-10-25 (on #6675) and 2026-10-27 (the Decision Moments of Mission write-service-protocol-01M5Z6N5).
What was ruled and what is derived.
- Ruled by the operator (2026-10-25): loopback HTTP on
127.0.0.1with a random port; the port and a random bearer token in a file in the git common dir, one writer per clone; the service is authoritative and refuses with structured codes that match the CLI's; oneO_APPENDwrite of a full JSON line,fsyncbefore the ack, a batchedfsyncfor multi-event operations; Windows semantics documented under the lock.- Ruled by the operator (2026-10-27): the refusal
codereuses the CLI's codes verbatim and the guard reasons are minted as provisional URNs; the bearer scheme is declared natively in the contract (a narrow amendment of the contract layout check); protocol questions not yet ruled carry provisional defaults; the client side ships typed shapes and one binder with no transport; the shapes' dead-code debt is priced with the #6195 exit (Decision Moments DM-01M5Z6TDWPD1RGWDNXQGQW27C9 and DM-01M5ZC1PYHBB6QB02YQ4H1V52P: 11 dead-symbol entries incategory_c_write_service_client_6675and one dead-module entry).- Derived: clauses marked (derived).
- Open: the open questions at the end carry defaults that hold until the operator rules otherwise.
Date: 2026-10-27
Deciders: Stijn Dejongh (operator).
Technical Story:
- #6675 (this protocol), in epic #6117 (Mission Write API).
- It amends ADR 2026-10-22-1: open question 3 (transport and trust model) and D-7 (which append semantics are authoritative).
- Coordinated by reference, not changed: #6676 (the fail-closed launcher), #6193 (the lock-interop spike), #6195 (the lane-transition cut-over that binds the client), spec-kitty/spec-kitty-events#92 (the event envelope).
- The contract:
contracts/mission-write/openapi.yaml. The client shapes:src/specify_cli/status/write_service_protocol.py.
Reader: a maintainer building the write-service client behind the EventBridge port, or the Java write service, or its launcher.
Context and Problem Statement
ADR 2026-10-22-1 makes a resident Java service per repository the one event writer, reached through the CLI's EventBridge port. It left the transport open, with a local socket as the default (open question 3), and left the append semantics open (D-7). Nothing defined what the CLI sends, what the service answers, how a client finds and trusts the running service, or how the two agree on a protocol version.
The read service (services/mission-status-api) already sets a local precedent: it binds 127.0.0.1 only and refuses a foreign Host, a foreign Origin and a cross-site fetch. It has no token, because it only reads.
The CLI refuses writes today with codes scattered over many modules, and the lane state machine's guards raise free text with no code at all; only the orchestrator-api edge turns them into codes (TRANSITION_REJECTED, WP_ALREADY_CLAIMED, DEPENDENCIES_NOT_SATISFIED). Moving validation into the service must not change a code a user or a script sees.
Decision Drivers
- One writer per clone (ADR 2026-10-22-1 D-2).
- Fail closed: a stale endpoint, a recycled port, a foreign process or a version skew must refuse, never write to the wrong place.
- No lost history: a write is acknowledged only when it is durable.
- Parity: the service's refusals carry the codes the CLI raises today.
- Contract first: the client and the service are built against one checked boundary.
Considered Options
- A local socket, a named pipe on Windows (ADR 2026-10-22-1's default).
- Loopback HTTP on
127.0.0.1with a random port and a bearer token (chosen, ruled). - Standard input and output of a process per write (rejected by ADR 2026-10-22-1 D-2: a start per transition).
Decision Outcome
Chosen option: "Loopback HTTP on 127.0.0.1 with a random port and a bearer token". It reuses the read service's HTTP stack, guard and contract tooling, it is the same on every platform, and an OpenAPI contract describes it with the tools the repository already runs.
D-1: transport
- The service binds
127.0.0.1only, on a port the operating system picks (port 0), and neverlocalhost,0.0.0.0or IPv6 (derived from the read service's BindGuard). - Every request passes the read service's guard:
Hostis127.0.0.1:<port>, anOrigin(when present) names the same, andSec-Fetch-Site: cross-siteis refused. A refusal is403withREQUEST_REFUSED. - The contract is
contracts/mission-write, served under/api/v1, version1.0.0-SNAPSHOTuntil its first release.
D-2: the endpoint file and the token
- Location (ruled):
<git-common-dir>/spec-kitty-write-service/endpoint.json. Every worktree of a clone resolves the same git common dir, so all reach one service; a separate clone has its own. - Fields (derived):
schema_version(1),host(127.0.0.1),port,token,instance_id(a ULID minted at each start),pid(diagnostics only),protocol_versions(informative: the handshake's list is authoritative),started_at. - Token (ruled: random bearer token; derived: its form): 32 random bytes, URL-safe base64 without padding (at least 43 characters), new on every start. It is sent as
Authorization: Bearer <token>on every request and is never logged, echoed in a body or written anywhere else. - Permissions (derived): on POSIX the directory is
0700and the file0600, both owned by the user. The client refuses a file or directory with any group or other permission bit, a symbolic link, a FIFO or any other non-regular file, another owner, or a host other than127.0.0.1(WRITE_SERVICE_ENDPOINT_EXPOSED,..._NOT_LOOPBACK,..._MALFORMED,..._MISSING). It judges the file onlstatbefore it opens it, opens it once without following a link and without blocking, and no refusal carries the token or an exception that holds the file text. - Windows (derived): the directory and the file carry an ACL that grants access to the owner only; the service sets it. The client, which has no mode bits to read, still refuses a link or a non-regular file and checks the shape and the loopback host.
- One service per clone (derived from ADR 2026-10-22-1 D-2): before it publishes the file, the service takes an exclusive OS lock on
spec-kitty-write-service/service.lockand holds it for its whole life; a second service that cannot take the lock exits without publishing. It publishes by writing a temporary file in the same directory and renaming it into place, so a reader never sees a partial file. At exit it deletesendpoint.jsononly while the file still names its owninstance_id. Starting, health-checking and recovering a stale service is the launcher's (#6676).
D-3: the service is authoritative
- The service validates every unit against the lane state machine, its guards and dependency gating, verifies the request's write location against its own resolution, and refuses with a code. A CLI pre-check is advisory only: it may shape a message, never skip or replace the service's check.
- During the strangler the request carries rows, not a command (derived from ADR 2026-10-22-1 open questions 1 and 2). The client sends what the EventBridge port holds (
WorkPackageTransition): the rows the CLI built, in order, and the request context (actor, the checkout that holds the Mission directory, relative to the repository root checkout, the Mission directory inside it, and the CLI's write contract target aswriteSurface). The command form of ADR 2026-10-22-1 D-3, in which the service also stamps git-derived evidence (policy_metadata.lane_head) itself, arrives with migration step 4; until then the service does not change a row's content. Nothing a host path can leak through travels. - Every row of a unit names the unit's
wpId, and a row that names a Mission names the path'smissionId; otherwise the request isREQUEST_MALFORMED. - What the port does not hold yet (derived): the destination branch (
EventLogWriteContracthas none, sodestinationRefis null until placement moves into the service), the owned-checkout fact (the service resolves ownership itself and refuses withOWNED_MISSION_PATH_REFUSED), and themissionIdof the path (the #6195 adapter reads it from the Mission'smeta.jsonin the write location). - Parity of validation will be proven by the #6194 conformance corpus.
- The port's Mission-state-advance half (the run events of
RuntimeEventEmitter) has no operation in protocol major 1; those events stay outside #6195.
D-4: handshake and version negotiation
GET /handshakeandGET /healthneed the bearer token but no protocol header. The handshake returns the supported protocol majors, the service version, theinstanceIdand therepositoryId.repositoryId(derived) is the lowercase hex SHA-256 of the UTF-8 bytes of the git common dir's real path in forward-slash form (PythonPath.resolve().as_posix()), with the case the file system reports. Java computes the same string fromtoRealPath()with separators replaced; a cross-language fixture lands with the Java client of #6192.- The client requires
instanceIdto equal the endpoint file'sinstance_idandrepositoryIdits own digest, or it refuses. It then sends the highest major both sides speak, on every other request, inSpec-Kitty-Write-Protocol. A missing header, a value that is not an integer, or an unsupported major is400 PROTOCOL_VERSION_UNSUPPORTED. - A protocol major changes only with a breaking change of the contract; additive changes keep the major (derived from the contracts versioning rule).
D-5: durability
- A unit (one transition and its annotations) is appended in one
O_APPENDwrite of full JSON lines and synced withfsyncbefore the201. A batch of units is one write and onefsync, all or nothing. A refusal writes nothing. - This rules ADR 2026-10-22-1 D-7's open point: the single
O_APPENDwrite is authoritative, not the CLI's temporary-file-and-replace. - Encoding (derived from ADR 2026-10-22-1 D-7): the service owns the row encoding and writes the bytes the CLI's one encoder writes,
store.serialize_event_line:sanitize_event_for_logfirst (it strips host and developer fields such ashostnameanddeveloper_email), thenjson.dumps(..., sort_keys=True). It never writes the bytes as received, and it judges a retry by comparing sanitized rows as JSON values. The client sends rows unsanitized. - Windows: there is no
O_APPENDatomicity guarantee between processes; the service holds the repository's write lock around the write, and the lock's semantics on Windows are settled by the lock-interop spike (#6193). This record does not decide them. - Where the topology commits status, the commit joins the unit at migration step 4 of ADR 2026-10-22-1; until then the acknowledgement's
statusCommitis null. - A fully replayed batch writes nothing and acknowledges
fsyncs: 0;fsynced: truemeans the rows are durable, synced now or when they were first written.
D-6: refusals
- Every refusal is the shared
Problem(application/problem+json) with acodeand thestatusthat code fixes. The service checks a request in this order and answers the first refusal: the Host and Origin guard (403), the bearer token (401), the protocol header (400), the size cap (413), the body's shape (400), then the domain (404, 409, 422). - A code that names an existing CLI refusal is the CLI's string, verbatim (20 codes, from
orchestrator_api/wp_lifecycle.py,coordination/*,status/locking.py,status/store.pyandcli/selector_resolution.py). A wrapper the CLI raises around another code (BOOKKEEPING_POLICY_REFUSED,BOOKKEEPING_LOCK_TIMEOUT) is never returned; the wrapped code is. - The contract mints 9 protocol-only codes with no CLI counterpart:
REQUEST_MALFORMED,PROTOCOL_VERSION_UNSUPPORTED,WRITE_TOKEN_INVALID,REQUEST_REFUSED,WRITE_LOCATION_UNVERIFIED,EVENT_ID_CONFLICT,REQUEST_TOO_LARGE,SERVICE_BUSY(retry the same service afterRetry-After) andSERVICE_DRAINING(re-read the endpoint file). - A guard refusal also carries
type, a URNurn:spec-kitty:write-refusal:<reason>from a closed vocabulary of 15 reasons (provisional), each bound to one code. The CLI raises these reasons as text today; adopting the same vocabulary in the CLI is a follow-up. - A refused batch names the unit that failed in
instance, as the JSON pointer#/units/<index>of the request. - The codes and reasons are written as constants rather than an
enum, because the contract lint admits only snake_case and PascalCase enum values. Their governance is the parity inventorytests/contract/fixtures/mission_write_refusal_parity.yaml: a test reads each cited CLI file for the literal and holds the contract, the client vocabulary and the inventory equal.
D-7: the event envelope is referenced, not redefined
- A request row is the spec-kitty-events envelope (spec-kitty/spec-kitty-events#92). The contract requires only
event_idand leaves the rest of the row open; the envelope's owner defines it.
D-8: the client side
specify_cli.status.write_service_protocolholds the typed shapes of the contract (requests withto_wire(), responses withfrom_wire()), the refusal vocabulary, version negotiation, the repository id and the endpoint-file reader. It has no transport.runtime.next._internal_runtime.events.bind_event_bridgebinds both halves of the EventBridge port at the composition root, all or nothing. Open-core binds none.- Neither has a
src/caller until the client adapter binds at the #6195 cut-over, so both are priced dead-code debt (ADR 2026-09-30-1) with that exit.
D-9: the contract layout admits a security scheme
- A contract module's root
openapi.yamlmay declarecomponents.securitySchemesand nothing else undercomponents; anything more is still a bundled document (TRACKED_BUNDLE). This lets the bearer scheme be native, so a generated client sends it.
Consequences
Positive
- One checked boundary for the client, the service and the launcher.
- A stale file, a recycled port or another clone's service is refused before any write.
- Moving validation into the service changes no code a user sees.
- The durability point of every write is explicit.
Negative
- An HTTP server sits on the write path; a local process of the same user that can read the endpoint file can write.
- 20 codes now have two homes (the CLI and the contract) until the CLI writers are deleted at the cut-over; the parity test is what keeps them equal.
- Priced dead-code debt until #6195.
Neutral
- The read service and its contract are unchanged.
- The CLI writes events exactly as before; nothing binds the port in open-core.
Confirmation
- The contract checks pass for
contracts/mission-write(layout, structure, citations, examples, leak scan, lint, bundle, generator validation, client generation). tests/contract/test_mission_write_contract.py: six operations, the bearer scheme on all, the protocol header on all but the handshake and health, closed success bodies, aWriteRefusalfor every refusal, refusal parity with the CLI sources, and the client shapes validating against the contract.tests/specify_cli/status/test_write_service_protocol.py: the endpoint reader refuses an exposed, malformed, symlinked or non-loopback file and never names the token.
Open questions (each with a provisional default)
Every provisional element is settled before the contract's first release (1.0.0): after it, a change to a closed response is a major version.
- Idempotent retry. Default: a retry whose event ids are all persisted with identical rows (compared as JSON values) answers
201withreplayed: trueandappendedLines: 0; an event id persisted with different content, or a unit only some of whose event ids are persisted, is409 EVENT_ID_CONFLICT. - A torn tail. Default: a crash between the write and the
fsynccan leave an unterminated last line. The service checks the tail of a log before it serves its first write and refuses to append after an unterminated line until it is repaired; the repair rule belongs to the lock-interop spike (#6193). - Concurrency and backpressure. Default: the service serializes writes per repository with a bounded queue; beyond it, it answers
503 SERVICE_BUSYwithRetry-After. It never queues without bound. - Token rotation. Default: the token changes only at a service start; there is no in-life rotation.
- MCP mode. Default: none in protocol major 1; an MCP surface would be a separate deployable over the same use cases.
- Shutdown and idle exit. Default:
POST /shutdowndrains (health readsdraining, writes answerSERVICE_DRAINING) and exits; the service also exits after an idle period (ADR 2026-10-22-1 open question 4). Whether the launcher uses the operation, a signal or only the idle exit is open. - Request size cap. Default: 1 MiB per request (
413 REQUEST_TOO_LARGE). A batch of more than 64 units is a shape error (400 REQUEST_MALFORMED). - Write-location verification. Default: until resolution moves into the service (ADR 2026-10-22-1 open question 2), a mismatch between the request's location and the service's resolution is
422 WRITE_LOCATION_UNVERIFIED. - When the status commit joins the unit and whether the acknowledgement names it (
statusCommit). - An unknown refusal code. Default: the client treats a code it does not know as a protocol error and refuses the write; a new code is a contract change.
Pros and Cons of the Options
A local socket, a named pipe on Windows
- Pros: file-system permissions guard the socket; no port to discover.
- Cons: two transports to build and test (a Unix socket and a Windows named pipe); the read service's HTTP stack, guard and contract tooling do not apply; OpenAPI does not describe it.
Loopback HTTP with a bearer token (chosen)
- Pros: one transport on every platform; the read service's stack, guard and tooling apply; the contract is checked by the tools the repository already runs.
- Cons: a port any local process can connect to, so the token and the guard carry the trust; a file that names the port can go stale.
A process per write
- Pros: nothing resident.
- Cons: a start on every transition (rejected by ADR 2026-10-22-1 D-2).
More Information
- The read service:
services/mission-status-api/README.md(bind and guard rules). - Contract conventions:
contracts/README.md(the security-scheme rule is added there). - The current-state map: event ledger writers and readers.