Skip to content

feat!: the address is a registry key, not a DNS locator (#307) - #333

Merged
KenTaniguchi-R merged 6 commits into
mainfrom
feat/307-address-as-key
Aug 5, 2026
Merged

feat!: the address is a registry key, not a DNS locator (#307)#333
KenTaniguchi-R merged 6 commits into
mainfrom
feat/307-address-as-key

Conversation

@KenTaniguchi-R

@KenTaniguchi-R KenTaniguchi-R commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes the implementation half of #307. The canonical address is @acme/ken; the bare handle ken works 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.md

Why

A hostname appears in an address for exactly one reason: federation. ken@acme.com carries 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 in cfg.relay. Nothing ever resolves an AgentCall address.

The grammar always agreed: HANDLE_RE and ORG_RE both forbid dots, and the comment on HOSTED_RELAY_HOST already said "deployment configuration, not protocol." The DNS wrapper was a costume over a key that was already flat.

Four slices

  1. Grammar, additiveformatAddress / parseAddress, rejection tests asserting every DNS-shaped form fails to parse. ORG_RE 63 → 20 chars, because at 63 an address could be @acme-corporation-platform-engineering/ken.
  2. relay_origin becomes an explicit signed field on IdentityRecord and EncryptionKeyRecord, covered by both transcripts. (Bumped to v2 here, then reset to v1 in a later commit — see below.)
  3. The cutover — grammar flipped everywhere atomically.
  4. Docs — README, guides, and the regenerated CLI reference.

The finding that reordered the work

keys.ts held a second, independent handle@host ADDRESS_RE, and the host inside it was load-bearing crypto:

The relay origin is part of the signed identity so a record published on one relay cannot be presented as valid on another.

Both transcripts sign the address. Cutting the format over without first making relay_origin explicit would have silently dropped that property — @acme/ken signed on a self-hosted relay would be indistinguishable from @acme/ken on the hosted relay. Two tests now assert it directly: records differing only by relay must not share a transcript.

What this deletes

relayHostWarning and its twenty-line comment. It existed because "calling a hosted address from a line registered elsewhere actually reaches whichever ken is 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 of requestOrg.

Three boundaries get stronger

  • Cross-tenant rejection (feat: scope identities by tenant #66) derived the org by matching a DNS suffix; it reads the parsed org now.
  • requestOrg had two sources for one boundary — a header and the hostname. authenticatedHandle scopes by (org, handle), so the credential already settles it. The fallback is gone; a2a-card and self-host now assert the rejection where they asserted the derivation.
  • fetchKeys checked address.split("@")[0], which yields "" under the new grammar. It parses now and also binds the org.

pickOutboundLine selects 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:

  • Key records gain relay_origin, so signatures and the prev chain 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.
  • Envelope from/to, published records, and the known-peer trust store use the new grammar.
  • /v1/register no longer returns address.

Any existing local install needs a fresh agentcall setup; known_peers.json and published keys will not load.

Verified

  • scripts/ci-local.sh fast — build, typecheck, 1,648 tests, 8 invariants
  • e2ee golden transcripts regenerated and checked, not accepted: the new bytes contain @acme/alice and still contain the relay hostname, which is the intended shape — relay_origin stays a host, addresses become keys.

Not in scope

AGENTCALL_POLICY_EXT still 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.ts as 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@host forever is compatibility with nobody. It was never built, and a test asserts every host-shaped form is rejected.

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.
@mintlify

mintlify Bot commented Aug 5, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
agentcall 🟢 Ready View Preview Aug 5, 2026, 4:48 AM

💡 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.
@KenTaniguchi-R
KenTaniguchi-R merged commit 98ffa38 into main Aug 5, 2026
12 checks passed
@KenTaniguchi-R
KenTaniguchi-R deleted the feat/307-address-as-key branch August 5, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant