Absorb spec §4.1/§4.2 canonicalization and §9 key-management simplification #10
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
posta/server#10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
The posta wire-protocol spec (
code.bas.es/posta/spec) changed twice in May 2026 andposta-serverhas not absorbed either change:4a5cf80) replace the old "lowercase scheme/host + drop default port" rule with a deterministic, total canonicalization algorithm. The wire fieldssender,recipient, and actor-docurlMUST now be in canonical form, and §5.2.1 / §7.2 comparisons are byte-equality on the canonical string. 25 normative conformance vectors ship undertestdata/vectors/url-canonical/.7dbbf57) collapses key rotation and revocation into editing the actor doc. The 30-day "retain window" concept and theretiringSincestate are gone. Verifier-side actor-doc cache is now capped at the timestamp window (5 min, §5.3) — that 5 min is the propagation bound for both replay protection and key removal.Today the server:
posta.NormalizeURL(which does not implement §4.1) at five call sites — peer URLs intocontacts,messages, the per-sender rate limiter, the daemon's host derivation, and the inbox seed.peer_urlrows that the new §4.1 would either normalize differently or reject outright. Two contacts withhttps://x/inboxandhttps://x/inbox/are currently two rows; under §4.1 they are one identity.internal/daemon/manifest.go IdentityHostthat diverges from §4.1 (no IDN handling, no trailing-slash strip, no userinfo/IP-literal/query/fragment rejection).internal/keys/: aRetiringSincefield on theKeystruct, aCurrent()that skips "retiring" keys, and package/doc comments that describe a workflow the spec no longer defines.fetchActorNamedoes its ownhttp.GetandDecodeActorDoc).The server is not currently conformant with the spec it claims to implement, and operators have no way to type a participant address in display form (
alice.example/inbox) — every input surface demands a fullhttps://…URL that's then run through a normalizer that won't even reject obviously-invalid inputs.Solution
Absorb both spec changes end-to-end:
Canonicalize()(precondition issue inposta/spec). Internal storage and the wire fields hold canonical form only. The CLI and the contact-list UI accept and display §4.2 display form.peer_urlrows are re-canonicalized in a schema migration that handles the collision cases the strict form introduces.keyIdwithin the 5-min cache cap.ActorCache, inheriting §5.3 freshness, singleflight, and the URL-match check.User Stories
forge identity add --url alice.example/inbox(display form), so that I do not have to remember whether the protocol wantshttps://or the trailing slash.https://alice.example:443/inbox/to be accepted and canonicalized tohttps://alice.example/inbox, so that small typing differences do not surface as configuration errors.POST /api/v1/messagesto be canonicalized server-side, so that therecipientfield on the wire is canonical regardless of how the client typed it.alice.example,posta.no/u/arne), so that the address book reads as identities rather than as URLs.café.example) to be accepted as input and stored as their A-label canonical (xn--caf-dma.example), so that two clients typing the U-label and the A-label end up at the same identity.recipientis non-canonical to fail with421 wrong-recipientrather than be quietly accepted, so that misconfigured senders are caught at the protocol seam.urlfield is non-canonical to be rejected so that the cascade tobad-signaturehappens at §5.2.1 rather than being masked.keys.json, restart, and have the new key advertised in the actor doc, so that I do not need to know any retain-window or retiring-state vocabulary.keys.jsonand trust that verifiers stop accepting it within five minutes, so that I have a real revocation path bounded by the timestamp window.internal/keys/, I want the data model to match the current spec, so that I am not misled byRetiringSinceand "retiring" comments that describe a workflow the spec deleted.%7Ealice), I want it normalized to~aliceso that two clients exchanging that contact via different paths end up at one row.peer_urlrows to retain the older row's id, fold message counts and read-watermarks deterministically, and log every merge so that I can audit what the upgrade did.POST /api/v1/contactsto return400with a typed rejection category (non-https-scheme,userinfo-present,ip-literal-host,malformed-host,malformed-port,query-present,fragment-present,malformed-path) when my URL is non-canonical and unsalvageable, so that the client can show a precise error.IdentityHostto be removed (or trivially derived fromCanonicalize), so that there is no parallel set of URL rules in the codebase.Implementation Decisions
Precondition: spec library lands
Canonicalize()This PRD is gated on
posta/specshipping aCanonicalize(input string) (canonical string, err error)API inpkg/posta, with rejection categories surfaced as typed errors and a vectors-driven test suite overtestdata/vectors/url-canonical/. The same library work also tightensDecodeActorDocto reject actor docs whoseurlfield is not canonical. That issue lives in the spec repo and is not in scope here, but every server module below assumes it.Module A —
internal/keyssimplificationDrop the
RetiringSincefield from theKeystruct. Thekeys.jsonschema becomes a flat list of{id, publicKey, privateKey, createdAt}. Strip the package-level "rotation" comment and theCurrent() skips retiringsemantics; the remaining notion of "current" is "the signing key the runner is configured to use" — pick the most-recently-created entry, no special state. Fix the error string indaemon/runner.gothat mentions "non-retiring." Update the inbox'sPublishedKeysdoc comment to drop "retiring keys still inside their retain window."No data migration on disk: nothing was writing the
retiringSincefield in production keys files. JSON unmarshalling tolerates unknown fields, so older files keep working; newer ones omit it.Module B — daemon host derivation
Replace
daemon.IdentityHostwith: canonicalize the manifest URL viaposta.Canonicalize, then derivehost[:port]from the canonical string. The dispatch table indaemon.Daemonis keyed on the canonical-derived host. Manifest validation rejects entries whose URL is rejected by §4.1, with the spec's rejection category in the error message.Module C — URL-input adapter (deep, new)
A new module (likely at
internal/postaor as helpers on the API package, name TBD in implementation) that takes whatever the user typed and returns canonical URL or a typed rejection. Responsibilities:https://scheme; prepend if absent before callingposta.Canonicalize.DisplayForm(canonical) stringderived from §4.2 for response payloads and CLI output.This module is the single seam every input surface uses:
POST /api/v1/contacts,POST /api/v1/messages(peer field), the search endpoint, theidentity add --urlCLI, and any future ingest path. The five existingposta.NormalizeURL(...)call sites and the rate limiter's normalization key all migrate to this module.Module D —
peer_urlrewrite migration (new)A new schema migration in
internal/storethat walksmessagesandcontacts(and any otherpeer_url-bearing table) and re-canonicalizes each row. Collision policy:peer_url, keep the row with the olderid(or oldercreated_at, depending on table).messages: nothing to merge — both rows are independent envelopes, just with the new canonical form on the duplicate.contacts: fold the surviving row'sdisplay_name(keep the older row's),pinned(logical OR),last_message_at(max), and any read-watermark fields (max). Drop the loser row.slog.Infowith both original URLs, the merged canonical, and the kept/dropped ids, so the operator has an audit trail.internal/store.Module E — contacts via inbox.Cache
Delete
fetchActorNameininternal/api/contacts.go. Replace its single caller with a call through the inbox's sharedActorCache(already exposed viaInbox.Cache()). The cache'sResolve(...)API takes(senderURL, keyID); for the "I just want the name" use case, add a thin helper (FetchDoc(senderURL)or similar) on the inbox/cache surface that returns the validated*posta.ActorDoc. This eliminates the bespoke HTTP path and inherits §5.3 freshness, singleflight collapsing, and the actor-doc-URL-match check.Module F — inbox/actor-doc wiring
After the spec library tightens
DecodeActorDoc, audit the inbox's actor-doc construction (dynamicActor()ininternal/inbox/inbox.go) to ensureopts.URLis canonical at construction time. The natural place to do this is in the per-identity runner builder: canonicalize the manifest URL once, propagate the canonical string into both the inboxURLand any other consumer.Out-of-scope cross-references
The PR will not introduce its own
Canonicalizefunction in the server. If the spec library ships later than the server work needs to land, gate this PRD behind that work; do not shim.Testing Decisions
What makes a good test here. Tests exercise external behavior at module seams, not internal SQL or struct layout. Vectors-driven tests are preferred for anything URL-shaped: the spec ships 25 conformance vectors and the server's URL-input adapter is the natural place to consume them. For the migration, the test contract is "what does the table look like after migrating from a representative pre-canonical fixture DB" — not the specific SQL.
Modules under test:
Keystruct unmarshals legacykeys.jsonfiles (with strayretiringSincefields) without error and ignores the field, thatCurrent()returns the most-recently-created entry, and thatPublishedEntries()returns every key in the file. Prior art: there is currently minimal coverage oninternal/keys/; this is the moment to add a small focused test file.testdata/vectors/url-canonical/produces the listed canonical output, every reject vector produces the listed rejection category, and display-form round-trips for accept vectors are bijective. Also test the "no scheme → prependhttps://" path and a few HTTP-shaped tests that REST inputs return 400 + the rejection category in the response body. Prior art:internal/daemon/manifest_test.goalready builds table-driven cases over URL inputs (https://Arne.Posta.NO:8443→arne.posta.no:8443style) and is the closest existing pattern.:memory:with rows that collide under §4.1 (trailing slash, percent-encoded unreserved, IDN U-label, case-only-distinct hosts that survive lowering). Run the migration. Assert: rows merged on the older id, counts/watermarks folded correctly, log lines emitted. Prior art:internal/store/sqlite_test.goalready constructs:memory:stores and asserts post-state; same harness applies.ActorFetchercounting calls). Prior art:internal/api/api_test.go newRigalready wires a realinbox.Cacheand intercepts SSE; the same fixture pattern applies, swapping the cache's fetcher for a counting fake.Modules not getting their own tests (B, F):
manifest_test.gohost-extraction cases, which will be updated to the canonical-form output.opts.URLis non-canonical at runner construction, the spec library's tightenedDecodeActorDocwill reject the seeded doc — the existing inbox tests will fail loudly. No new test needed.Out of Scope
Canonicalize(), the rejection-category error type, and tighteningDecodeActorDoc. That work belongs inposta/specand is the precondition for this PRD.keys.jsonor changing its on-disk path. The schema simplification stays backward-compatible by virtue of JSON unmarshalling tolerating unknown fields; no rewrite of existing files.key addCLI separate fromidentity add) is a follow-up.messagesare immutable by construction — they were already canonical under the rules in force when signed, or they would not have verified).Cache-Control: max-age=300is correct against §5.3 and stays as-is.Further Notes
posta/spec/testdata/vectors/url-canonical/are normative for the spec library, but consuming them in the server's URL-input adapter test gives the server a regression net at zero ongoing cost — every spec-library update that adds vectors strengthens the server's coverage automatically.posta/spec: open a parallel issue there forCanonicalize()+ tightenedDecodeActorDoc. Land that first; the server PR can then go in without shims.keys.jsonsimplification (Module A) is the only piece that is fully independent of the spec library; it can land on its own ahead of the canonicalization wave if useful for unblocking iOS/TUI work that touches the keys format.--dry-runmode on the migration that prints the merge plan without applying it.Umbrella PRD. No code goes directly into this issue; the implementation lives in four child slices:
internal/keys(independent, AFK)inbox.Cache(AFK)peer_urlrows to canonical form (AFK)Spec-library precondition (
Canonicalize(), tightenedDecodeActorDoc) landed inposta/speccommits5aa3aa3and5c19573. All four children are unblocked.Closing this issue is a maintainer decision after the four children land — hence ready-for-human rather than ready-for-agent.
Category: enhancement
State: ready-for-human