feat!: the address is a registry key, not a DNS locator (#307) - #333
Merged
Conversation
Additive only. `formatAddress` and `parseKeyAddress` land the registry-key grammar with round-trip and rejection tests; the outgoing `handle@host` `parseAddress` stays live and wired up so nothing downstream moves yet. An address is a key, not a locator. A hostname appears in an address for one reason — federation — and cross-organization routing is a permanent non-goal, so nothing ever resolves an AgentCall address. The grammar is deliberately unable to express a host: dots are absent from both bodies, and the rejection tests assert that every DNS-shaped form fails to parse. `ORG_RE` tightens from 63 characters to 20 in the same change, because the two are one decision: at 63 an address could be `@acme-corporation-platform- engineering/ken`, trading a vendor domain for a self-inflicted one. All three patterns now derive from one pair of shared bodies so they cannot drift. Why this slice stops here: `packages/shared/src/keys.ts` holds a second, independent `handle@host` `ADDRESS_RE`, and the host inside it is load-bearing crypto. `identityTranscript` and `encryptionKeyTranscript` both sign the address, and the relay origin inside it is what stops a record published on one relay from being presented as valid on another. Cutting the format over without first making `relay_origin` an explicit signed field would silently drop that binding. The CLI builds envelopes validated by that grammar, so the layers are coupled and the crypto slice has to go first. Spec: docs/superpowers/specs/2026-08-05-address-as-registry-key.md
…d field (#307 slice 2) The cross-relay binding used to ride inside `address` as the host part, which tied it to addresses being DNS-shaped. keys.ts said so directly: The relay origin is part of the signed identity so a record published on one relay cannot be presented as valid on another. `identityTranscript` and `encryptionKeyTranscript` both sign the address, so that host is load-bearing crypto rather than decoration. Removing it in the address cutover (#307) without doing this first would have silently dropped the property: `@acme/ken` signed on a self-hosted relay would be indistinguishable from `@acme/ken` on the hosted relay, and a published key record could be replayed across the two. `relay_origin` is now an explicit field on `IdentityRecord` and `EncryptionKeyRecord`, covered by both transcripts. It is populated with the same org-scoped host the E2EE envelopes already put in their own `relay_origin`, so the signed binding and the wire binding name the same thing. Two tests assert the property directly: records differing only by relay must not share a transcript. BREAKING: both records go to v2 and both transcript labels to `agentcall/{identity,encryption-key}/v2`, because the file's own rule is that adding a field means a new record version. Signatures and the `prev` chain links between encryption-key epochs do not survive. Free at zero users, and the version bump means a v1 verifier refuses rather than mis-verifies. The known-peer trust store gains `relay_origin` too, and that is correctness rather than plumbing: lines may sit on different relays while the store is per-machine, so a pin is only meaningful together with its origin — and the identity transcript now covers it, so a stored peer without it cannot have its fingerprint recomputed. Existing local `known_peers.json` and published keys will not load; re-run setup. RELAY_ORIGIN_RE moves from e2ee.ts to keys.ts, which is the direction the existing import already runs. Spec: docs/superpowers/specs/2026-08-05-address-as-registry-key.md
The address is a registry key now. `@acme/ken` is canonical; nothing composes a hostname, nothing parses one back out, and no composed address is stored or sent. Deleted rather than rewritten: - `contacts.ts::relayHostWarning` and its twenty-line comment. It existed because an address named a relay while the call was dialled on the calling line's relay, so "calling a hosted address from a line registered elsewhere actually reaches whichever `ken` is on that other relay." With no host in an address that situation cannot be constructed. The hazard, the warning, the explanation and the regression history all go. - `contacts.ts::addressTenant`, `tenant.ts::registrationAddressHost`, `config.ts::addressHost` and `relayAddressHost`, `RegisterResponse.address`, and the hostname branch of `requestOrg`. Two boundaries get stronger, not weaker: - The cross-tenant rejection (#66) read the target org by matching a DNS suffix. It reads the parsed org now, so it no longer depends on how a host is spelled. - `requestOrg` had two sources for one boundary: an `X-AgentCall-Org` header and the request hostname. The credential already settles it — `authenticatedHandle` scopes its lookup by (org, handle), so a token from one org cannot authenticate against another. The hostname fallback is gone and a request that names a tenant only in its hostname now resolves to nothing. `a2a-card` and `self-host` assert that rejection where they used to assert the derivation. - `fetchKeys` checked `address.split("@")[0] !== handle`, which only worked while an address was `handle@host`. It parses now and also binds the org, so a relay cannot answer with a same-named handle from another tenant. `pickOutboundLine` selects by organization instead of relay host. That is the rule the host match was approximating: a line may only call inside its own org, and with the host gone from addresses there is nothing else to match on. The e2ee golden transcripts are regenerated. Checked rather than accepted: the new bytes contain `@acme/alice` and still contain the relay hostname, which is the shape this change intends — relay_origin stays a host, addresses become keys. BREAKING: `/v1/register` no longer returns `address`; envelope `from`/`to`, published key records, and the known-peer trust store all use the new grammar. Spec: docs/superpowers/specs/2026-08-05-address-as-registry-key.md
Sweeps the published surface: README, the Mintlify guides, and the generated CLI reference. The prose that described an address as `handle@host` now describes what it is, and `contacts add` rejects a bad address with the grammar it actually enforces. `docs/site/reference/cli.mdx` is regenerated rather than hand-edited — the argument descriptions in `packages/cli/src/commands/*` are the source, and editing the output directly is how that file drifted before (#317). Left alone deliberately: `AGENTCALL_POLICY_EXT` still names benree.tech. It is a protocol namespace identifier rather than an address, so changing it is a wire break with its own decision to make, and #307 scoped it out explicitly.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Reverses the version bump from slice 2. The rule that argued for it — "adding a
field means a new record version" — exists to protect deployed verifiers, and
there are none: nothing has ever published an identity or encryption-key record.
A v2 whose v1 never existed in the world is worse than no version change. It
permanently implies an earlier shape someone might still hold, invites "do we
still need to accept v1?" from every future reader, and left `keys.ts` as the
only v2 in a protocol surface that is v1 everywhere else — e2ee payloads, the
HPKE AAD, request and response transcripts, room join proofs.
So v1 is this shape, including `relay_origin`, and the transcript labels go back
to `agentcall/{identity,encryption-key}/v1`. The rule starts applying from here;
the comment in keys.ts now says so, since the labels are stable from this point
but were not across this change.
Also corrects the record: #307's decision text says the long `handle@host` form
would be accepted as input indefinitely. That is compatibility for users who do
not exist, and it was never built — `parseAddress` rejects every host-shaped
form and a test asserts it. The code was right; the decision text was stale.
The packed-CLI consumer job asserts that `doctor` and `status` both refuse an install with no lines, by grepping for "No agentcall config found". It probed with `status nobody@example.invalid`. That address is host-shaped, so under the new grammar `resolveAddress` rejects it as malformed before `pickOutboundLine` is ever reached, and the pinned string never prints. The job failed on a correct behaviour change: a malformed address should be reported as malformed. What was stale is the probe. `@acme/nobody` parses and resolves to nothing, so it exercises the path the check is actually about. Changed in both `.github/workflows/ci.yml` and `scripts/ci-local.sh`, which have to move together — a local gate that has drifted reports green for a rule CI would fail. Found by `scripts/ci-local.sh packaged`; `fast` does not pack or run the CLI as a consumer would, so it could not have caught this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the implementation half of #307. The canonical address is
@acme/ken; the bare handlekenworks inside an organization. No hostname appears in an address, and no composed address is stored or sent.Spec:
docs/superpowers/specs/2026-08-05-address-as-registry-key.mdWhy
A hostname appears in an address for exactly one reason: federation.
ken@acme.comcarries a host because a stranger's mail server must be told where to deliver. AgentCall has no strangers — every caller is authenticated to one relay, may only reach their own organization (federation non-goal), and already holds the relay URL incfg.relay. Nothing ever resolves an AgentCall address.The grammar always agreed:
HANDLE_REandORG_REboth forbid dots, and the comment onHOSTED_RELAY_HOSTalready said "deployment configuration, not protocol." The DNS wrapper was a costume over a key that was already flat.Four slices
formatAddress/parseAddress, rejection tests asserting every DNS-shaped form fails to parse.ORG_RE63 → 20 chars, because at 63 an address could be@acme-corporation-platform-engineering/ken.relay_originbecomes an explicit signed field onIdentityRecordandEncryptionKeyRecord, covered by both transcripts. (Bumped to v2 here, then reset to v1 in a later commit — see below.)The finding that reordered the work
keys.tsheld a second, independenthandle@hostADDRESS_RE, and the host inside it was load-bearing crypto:Both transcripts sign the address. Cutting the format over without first making
relay_originexplicit would have silently dropped that property —@acme/kensigned on a self-hosted relay would be indistinguishable from@acme/kenon the hosted relay. Two tests now assert it directly: records differing only by relay must not share a transcript.What this deletes
relayHostWarningand its twenty-line comment. It existed because "calling a hosted address from a line registered elsewhere actually reaches whicheverkenis on that other relay" — a silent wrong-recipient hazard. With no host in an address it cannot be constructed. Also gone:addressTenant,registrationAddressHost,addressHost,relayAddressHost,RegisterResponse.address, and the hostname branch ofrequestOrg.Three boundaries get stronger
requestOrghad two sources for one boundary — a header and the hostname.authenticatedHandlescopes by(org, handle), so the credential already settles it. The fallback is gone;a2a-cardandself-hostnow assert the rejection where they asserted the derivation.fetchKeyscheckedaddress.split("@")[0], which yields""under the new grammar. It parses now and also binds the org.pickOutboundLineselects by organization rather than relay host — the rule the host match was approximating.BREAKING
Three stacked breaks, free at zero users and permanently expensive later:
relay_origin, so signatures and theprevchain links between encryption-key epochs do not survive. They stay at v1: nothing has ever published a record, so v1 is this shape rather than the shape before it — minting a v2 would have implied an earlier version someone still holds.from/to, published records, and the known-peer trust store use the new grammar./v1/registerno longer returnsaddress.Any existing local install needs a fresh
agentcall setup;known_peers.jsonand published keys will not load.Verified
scripts/ci-local.sh fast— build, typecheck, 1,648 tests, 8 invariants@acme/aliceand still contain the relay hostname, which is the intended shape —relay_originstays a host, addresses become keys.Not in scope
AGENTCALL_POLICY_EXTstill names benree.tech. It is a protocol namespace identifier, not an address; changing it is a wire break with its own decision, and #307 scoped it out.Note for #312
This overlaps PR #312 (#310) in 11 files and lands first, which cuts that PR's work roughly in half — the address fixtures it was rewriting no longer contain a host. #312 wants a rebase rather than a merge.
🤖 Generated with Claude Code
A correction in the history
Slice 2 bumped the key records to v2, and a later commit puts them back to v1. The rule that argued for the bump — "adding a field means a new record version" — protects deployed verifiers, and there are none. A v2 whose v1 never existed permanently implies an earlier shape someone might hold, and it left
keys.tsas the only v2 in a protocol surface that is v1 everywhere else. The commits are left in that order because it is what happened and the reasoning is worth reading.The same instinct withdrew the long-form input allowance from #307's decision text: accepting
handle@hostforever is compatibility with nobody. It was never built, and a test asserts every host-shaped form is rejected.