Repository navigation
Bind hop stamps to exact methods, audiences and single-use admissions - #28
Merged
Merged
Conversation
Replace shared-proof admission with service-only Ed25519 signing, short validity windows and mandatory shared replay consumption in Go, Python and TypeScript. Refuse advisory policy bypasses and exercise the independent review probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…egression Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ication PR #26 and PR #28 both reworked the module-side verifier. The merge keeps both behaviours rather than choosing one. #28's signed admission replaces the shared `hop/PROOF` secret everywhere, so `stamp.Context`, `workcontext.Authenticate`, Python `bound_token` / `authenticate` and TS `boundToken` / `authenticateClaims` lose their proof argument and remain identity-only readers. #26's typed refusals now cover those paths too: `stamp.Check`, Python `check` and TS `checkCall` refuse with `refusal.Reason` values, and the scheme gains `stamp_method`, `stamp_audience`, `stamp_window`, `stamp_advisory`, `stamp_replayed` and `stamp_store` for the admission failures #28 introduced. No reason names a proof, token, digest, nonce or claim. #26's `NewTokenVerifier` / `VerifyToken` survive as the bare-token path: a process that receives no hop calls supplies neither the hop public key nor a replay store, and `Verify` refuses with `configuration` because it has nothing to check an admission against. #28's advisory refusal stands: an admission carrying `x-codefly-hop-advisory` is refused by `stamp.Check`, so `Verify` never reads that header as identity. #26's Connect routing in the hop service and its generated-route validation merge unchanged, and its connect-listener test now asserts the signed admission for the procedure it arrives on instead of a shared secret. Every Go module keeps the single core pin v0.19.7-0.20261026092055-b23dc13543b2 (canonical base64url decoding). TypeScript gains `refusal.ts`, which holds `Reason` and `Unauthenticated` so `hop-call.ts` can refuse with the same codes without importing `work-context.ts` back; `work-context` re-exports both. The three client READMEs are updated for the merged construction and the new admission reason codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libraries/hop/go, libraries/conformance/go and the local authority resolve authority-client from the proxy rather than a path replacement, so they must name the revision that carries both the signed-admission API and the typed refusal reasons: 4b1c57a, the merge commit. Every module is tidied, and the single core pin v0.19.7-0.20261026092055-b23dc13543b2 is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…livery Security review r164 on 33c436b. Each blocker and major, and the #26 conflicts it listed. B3, and R1/R2 with it: the admission window is judged once, at the instant the call starts. grpc-go decodes a unary request fully before it runs the unary interceptor, so a module serving large or slow uploads refused admissions the authority had granted; Python's interceptors already ran before the body. Verifier.TapHandle, installed with grpc.InTapHandle, records the arrival instant when the stream is accepted and Verify judges against it. It reads no header and runs no cryptography, because it runs on the connection's I/O goroutine. A server installing no handle judges the window when Verify runs, as before, so nothing is admitted by its absence. The redundant re-checks after the key read and the reservation are gone in all three languages. A two-second clock allowance (stamp.Skew, Python and TypeScript SKEW) applies identically in Go, Python and TypeScript: an admission may be issued that far ahead of the verifier's clock and stays admissible that far past its expiry. Being issued further ahead has its own reason, stamp_issued, so a verifier running behind is no longer diagnosed as an expiry. The signed span is still capped at Lifetime exactly. Reservations are retained to expiry plus the allowance, which is sound because the window is judged at arrival: a replay can only be admitted if it too arrives inside the window, and inside the window the nonce is still retained. B2: a REST face transcoded into a module's gRPC or Connect listener cannot be admitted. The hop signs an http route as "VERB /path" — all a published http route tells it — while that listener's verifier observes the full procedure, and a module cannot verify at both layers because the second would consume the nonce again. Signing the procedure instead needs the published route to carry its producer's template-to-procedure mapping, and the hop-routes schema has no such field, so routing.Validate refuses the composition at hop startup (routing.ErrTranscodedREST) rather than refusing every REST call at runtime. A module serving HTTP natively, with no procedural face on the same service, is unaffected. B1, the replay half: libraries/hop/go/replaypg is the durable shared store the contract required and nothing delivered, over a store Codefly does deliver; INSERT ... ON CONFLICT DO NOTHING over a primary key is the atomic reservation, shared by every replica. The console composes its own Postgres for it (modules/console/services/store) and drops hop-verification/REPLAY_DIRECTORY, which asked for a shared volume the platform cannot mount and in practice meant a per-pod directory. NewVerifier now refuses a store that does not declare itself shared outside a local run (hop.ErrReplayStoreNotShared); NewFileReplayStore does not declare it, because an absolute path to per-pod storage is indistinguishable from a shared mount, and NewSharedFileReplayStore is how an operator asserts otherwise. R4 and the #26 conflict behind it: one identity-only reader per language. workcontext.Authenticate, Python authenticate and TS authenticateClaims are gone; AuthenticateToken / authenticate_token / authenticateToken remain. A reader that took a call's metadata read as though it admitted the call, and after the shared proof's removal it accepted any token plus a self-computed digest, so a caller fixing a build break by deleting the proof argument lost admission silently. A caller holding metadata now extracts the bound token with stamp.Context first: two visible calls, neither an admission. B1, the verification key, is NOT fixed and is stated as open. Nothing produces hop-verification/PUBLIC_KEY: `codefly config generate` makes random bytes only, and a configuration value template assembles its producer's own primitives, so neither can derive a public half from the hop's seed. The agreed direction is the authority publishing the hop's admission key through its own Keys, which the verifier already trusts, caches and re-reads on rotation, and which the Work Context trust set ignores because it filters on algorithm. Keys is part of interfaces/authority, so that is a contract change: proposed in the handbook first, and implemented by accounts alongside the local authority. Until then a run configures the key by hand, and libraries/hop/README.md, the console's manifest and the playground group all say so rather than implying a producer exists. The playground also gains its hop-verification group, and the README states the documents/records adoption order the review found unsequenced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libraries/hop/go, libraries/conformance/go and the local authority resolve authority-client from the proxy, so they must name the revision carrying stamp.Skew, the stamp_issued and stamp_* admission reasons, and the single AuthenticateToken reader: 3d0a11c. Every module is tidied and the single core pin v0.19.7-0.20261026092055-b23dc13543b2 is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment claimed single-process use by convention; NewVerifier now enforces it, so the comment says which. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… while the call runs The follow-up security review of #28 at 8a3e953 reproduced, over the real grpc-go transport and a real Postgres, one single-use admission running its handler twice: two deliveries arrive inside the window, the first runs, the second is held before its body is decoded, collection removes the reservation after expiry plus skew, and the held delivery then consumes the nonce afresh. The tap handle only recorded the arrival instant; the nonce was consumed at body decode, and collection could drop a reservation a call still needed. Go: TapHandle now starts the admission check and the reservation when the stream is accepted (beside the call, not on the connection's I/O goroutine), and the interceptors await it and hold the reservation until the handler returns; a call cancelled before its handler releases its hold. A new hop.ReservationStore (Reserve/Finish) keeps a reservation until Finish AND expiry plus skew; replaypg implements it with an active column that Collect never removes, and a Reserve conditioned on the retention not having passed by the database's clock, so a delayed delivery can never revive a collected nonce (stamp_window). A store with only Consume keeps a nonce to its retention and a consumption that completes past it is refused on the window. Verify releases its hold before returning; Admit is Verify for an HTTP module that runs the call's work itself and holds the reservation until done. Python and TypeScript reserve before the key read with the same store protocol (reserve/finish, Expired), hold it through admit / admit_async, and now refuse at construction, outside the environment named exactly "local", a store that does not declare itself shared (replay_store_not_shared), as Go does. Go's local check now matches exactly "local": an unset or empty environment, or any other local-prefixed name, is refused. The console's migration adds active and grants UPDATE on that column only. CI starts the runner's Postgres for libraries/hop/go so the transport regressions run there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to fix/hop-stamp-binds-method Conflicts kept both sides: the hop's HTTP face signs with its Signer and answers a Connect caller in Connect's words (refuse takes the request); both proxy tests stay; local's go.mod keeps main's new indirect deps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….3.2) KeysResponse gains admission_keys and PublicKey gains purpose: the authority publishes the keys a hop signs call admissions with (purpose hop-admission) beside the Work Context keys (purpose work-context). They are a field of their own, never entries of keys, so a verifier that reads keys alone (an earlier release, or the JSON Web Key Set) can never trust a hop key to sign a Work Context. More than one is published while a hop key rotates. Regenerated only through codefly: generate proto in the local service, then generate client --no-facade for authority-client (CLI v0.1.215, whose Python template resolves protovalidate). noproxygate's manifest records the five regenerated Go files. The interface moves to 0.3.2, additive, so existing >=0.2.0 <0.4.0 consumers admit it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SignedBy names which of the authority's published hop admission keys an admission is signed by, so a verifier trusts every published key while a hop's key rotates. Published is a seed's public half as Keys carries it in admission_keys: Ed25519, purpose hop-admission, a key id derived from the key. Check keeps judging one key; the proof's parsing is shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host with bindings of its own (the local authority) links its own copy of host/authority/v1, so a stamp package importing the client's bindings would register the same proto files twice. Admission answers the key id and the raw key, and each caller builds the entry in its own bindings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ival on every listener; collect crashed holds Review r3 (#28 at 073a40e) B1, M1 and M2. B1. The Go verifier reads the hop's admission keys from the authority's Keys, the read it already caches and refreshes, and trusts every one published, so a rotation overlaps. NewVerifier no longer takes a public key; hop-verification/PUBLIC_KEY and the playground's empty file are deleted. A stamp signed by no published key is refused as stamp_proof and starts no key read; a verifier holding no trusted keys at all reads them, at most once per keyRetry, as readiness does. An authority publishing no admission key refuses every call as keys_unavailable and KeysError says why (ErrNoAdmissionKeys). A key is used for its own purpose only. The local authority derives the public half from the hop's own hop-signing/SEED in code and publishes it; hoptest publishes the hops a test names (PublishHop, WithdrawHop). M1. StatsHandler replaces TapHandle. gRPC tags every call with it in handleStream, on its own listener and on the Connect listener that reaches the same server through ServeHTTP, where no tap handle runs. The console installs it; its regression drives the generated server on both listeners and over the Connect protocol with a body that arrives after the window. M2. replaypg.Collect removes every reservation past its retention by the database clock, active or not: Reserve refuses past retention on that clock, so nothing is revived, and a holder that stopped before Finish leaves no permanent row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… admission key from Keys Review r3 B1 and M3, the Go verifier's rules in both languages: no public_key/publicKey argument; the admission keys come from the Keys read the verifier already makes (admission_keys, purpose hop-admission), every published one is trusted while a key rotates, a stamp signed by none is refused as stamp_proof without starting a read, an authority publishing none refuses every call as keys_unavailable and keys_error/keysError says why (NoAdmissionKeys), and Work Context trust keeps only work-context keys. M3: Python treats a store as reservation-capable only when it has both reserve and finish (reserve_async and finish_async on the async path), as TypeScript does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lection The hop README's verification-key section states what is built (Keys' admission_keys, purpose, rotation overlap, local derivation, the accounts follow-up) and drops the open note; the Go, Python and TypeScript READMEs construct verifiers without a key; the playground README no longer asks for a hand-written one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p-binds-method Kept both: #31's rule that a service's gRPC and Connect endpoints are one destination, and this branch's refusal of a REST face transcoded into a procedural listener; both routing test sets stay. #31's dual-publication proxy test signs with the hop's Signer, as every proxy test here does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r4 (#28 at 2552d5c) B1': the local authority derived the hop's admission key from hop-signing/SEED, a secret a composed run never delivers to it (it cannot depend on the hop, which depends on the authority), so the playground published no admission key. AuthorityService gains RegisterAdmissionKey: the hop presents its public key with its workload credential, the authority refuses any other workload, and Keys publishes the key in admission_keys. A registration lapses unless renewed, so a key no running hop signs with is withdrawn and a rotation overlaps. Additive: consumers' >=0.2.0 <0.4.0 admits 0.3.3. stamp.KeyID is the one derivation of the published key id, used by the signer and by the authority it registers with. Regenerated only through codefly v0.1.215: generate proto in the local service, then generate client --no-facade for authority-client; the no-proxy gate's manifest records the five regenerated Go files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rives nothing Review r4 (#28 at 2552d5c) B1' and N1. B1'. The local authority read hop-signing/SEED for the hop, which no composed run delivers to it, so the playground published no admission key and the console refused every call as keys_unavailable. That derivation is deleted. The hop declares module-identity, so the run vouches for it, and registers its public key over its workload credential (hop.Registration.Keep): at boot, then renewing at a quarter of each lease and every 5s after a failure, reporting each change of outcome in its log. The local authority attests the caller, refuses any workload but authority/hop by name, and publishes the key in Keys until its lease (AdmissionLease, 2 min) lapses: re-registration renews one entry, a rotation publishes both keys while both are renewed, and a key no running hop renews is withdrawn. At most 8 keys are published at once. A verifier whose last read published no admission key (it read before the hop registered) reads again on the next call or readiness check, at most once per keyRetry, instead of waiting out keyMaxAge: Go, Python and TypeScript alike. N1. replaypg.Collect removes only rows CollectMargin (1 min) past their retention, so a collection committing between Reserve's clock check and its insert can no longer revive a nonce near expiry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The logger masks a field named key, so the boot line said key=****; it now names the admission key id and when its registration lapses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antoinetoussaint-byte
added a commit
that referenced
this pull request
Oct 26, 2026
…tup page hardened Follow-ups from the reviews of #28 (round e) and #30. #28 F1 (proof of possession): hop.Registration signs each registration with the admission key itself (stamp.ProveRegistration) over the audience its credential names, the credential's digest and the key, fresh; the local authority verifies it (stamp.CheckRegistration), refuses a missing, mismatched, stale or replayed proof, and remembers each nonce while the proof could pass as fresh (bounded). A captured hop credential alone now registers nothing. Pins hop/go and the local service to dc49cde. #28 F2: the authority also requires the hop's trust domain to be the host's: the one trust domain the run's anchor declares when it starts. #28 F4: the hop names the published key by its id in a line the run's log redaction leaves alone ("the authority ... key admission=" was masked as "admission=****"), and a refusal by its reason; never the seed. #30 F1: hop and edge answer in Connect's words only a caller that says it speaks Connect (WithRequireConnectProtocolHeader) or gRPC; REST and form callers keep {"error": reason} and the status mapping they always had. #30 F2: sign-in and setup send X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'. #30 F3: /auth/setup is mounted only where the host serves OfferService (asked once without a credential; Unimplemented is 404, unreachable 503). #30 F4: a test that another organization's administrator is refused ListOffers and ReviewOffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
lazybox has finished working on this. claim detail |
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.
A captured hop stamp could be moved from a read procedure to a write, and composed workloads could fabricate stamps using the shared run proof. This replaces that admission contract in Go, Python and TypeScript.
The hop signs the exact procedure, audience, token digest, fresh 256-bit nonce and a maximum 30-second validity window using a service-scoped Ed25519 seed. Verifiers authenticate those bindings and the Work Context, then atomically consume the nonce through a mandatory shared durable replay store before entering the handler. HTTP, Connect, unary gRPC and streaming paths are covered. Single-use capabilities are still consumed only by the hop's live Check; downstream consumes the separate admission nonce.
This is intentionally breaking: no shared-proof or method-less admission remains. Advisory policy refusals cannot be signed, and legacy warning mode is refused. The libraries reuse Core's ReplayStore contract, with no implicit per-replica memory fallback.
Merged with main (PR #26)
Both PRs reworked the module-side verifier, and the merge keeps both behaviours rather than choosing one. #26's Connect routing, bare-token verification and typed caller-safe refusals all survive; #28's signed admission replaces the shared
hop/PROOFsecret everywhere. Concretely: the identity readers lose their proof argument, #26's refusal scheme now covers #28's admission failures (stamp_method,stamp_audience,stamp_window,stamp_issued,stamp_advisory,stamp_replayed,stamp_store), andNewTokenVerifier/VerifyTokenremain as the bare-token path — a process that receives no hop calls supplies neither the hop public key nor a replay store, andVerifyrefuses withconfiguration. Every Go module keeps one core pin,v0.19.7-0.20261026092055-b23dc13543b2(canonical base64url decoding).Security review r164
An independent review of 33c436b found one blocker, two majors and six medium findings. All but the key producer are addressed here.
Verifier.StatsHandler(withgrpc.StatsHandler) records the arrival instant when gRPC tags the call and reserves the nonce then, and the window is judged against it — once, in all three languages. (It replacedTapHandlein round 4, see below.)stamp.Skew,SKEW), applied identically in Go, Python and TypeScript. A verifier running behind no longer refuses genuine calls at second boundaries, and being issued too far ahead has its own reason rather than reading as an expiry. The signed span is still capped at 30 seconds exactly.VERB /pathwhile that listener observes the full procedure, and a module cannot verify at both layers.routing.Validaterefuses such a composition (routing.ErrTranscodedREST) instead of refusing every REST call at runtime. Native HTTP faces are unaffected.libraries/hop/go/replaypgreserves nonces in a module's own Postgres, and the console composes one (modules/console/services/store).hop-verification/REPLAY_DIRECTORYis gone — Codefly delivers a store, not a shared volume, and an absolute path to per-pod storage looked identical to a shared mount. Outside a local runNewVerifiernow refuses a store that does not declare itself shared.AuthenticateToken/authenticate_token/authenticateToken. The header-taking variants are gone: after the proof's removal they accepted any token plus a self-computed digest, so a caller fixing a build break by deleting the argument lost admission silently.Security review round 3 (073a40e): B1, M1, M2, M3
B1 — the hop's admission key is published through
Keys(owner's decision; code first, the handbook proposal follows).codefly.dev/authority@0.3.2(additive; consumers declaring>=0.2.0 <0.4.0admit it):KeysResponse.admission_keysandPublicKey.purpose. Admission keys (purposehop-admission) are a field of their own beside the Work Context keys (purposework-context), so a verifier that readskeysalone — an earlier release, or the JSON Web Key Set — can never trust a hop key to sign a Work Context. Regenerated only through Codefly:codefly generate protoin the local service, thencodefly generate client --no-facadefor authority-client (CLI v0.1.215, whose Python template resolves protovalidate).tools/noproxygate/generated.jsonrecords the five regenerated Go files.The local authority derives the public half from the hop's own— replaced in round 4 by the hop's registration (below).hop-signing/SEEDKeysread they already cache and refresh.hop-verification/PUBLIC_KEY, thepublicKey/public_keyconstructor arguments and the playground's emptyhop-verification.envare deleted. Every published key is trusted, so a rotation overlaps. A stamp signed by no published key is refused asstamp_proofand starts no key read (review r44's "an unauthenticated caller neither reads nor starves the keys" still holds); a verifier holding no trusted keys reads them at most once perkeyRetry, as readiness does. An authority that publishes no admission key leaves every call refused askeys_unavailable, and the key error says why (ErrNoAdmissionKeys/NoAdmissionKeys). Each key is used for its own purpose only.TestAVerifierTakesTheHopsKeyFromTheAuthoritysKeysAlone,TestAStampSignedByAnUnpublishedKeyIsRefusedByName,TestARotationsOverlapAdmitsBothKeysUntilTheRetiringOneIsWithdrawn,TestAKeyIsUsedForItsOwnPurposeOnly,TestNoPublishedAdmissionKeyRefusesEveryCallAndSaysWhy(hop/go);TestSignedByNamesThePublishedKeyAProofIsSignedBy,TestAdmissionIsTheSignersStablePublishedEntry(stamp);TestTheHopsAdmissionKeyIsDerivedFromItsSeedAndPublished,TestAMalformedHopSeedRefusesToBoot(local); the console's arrival tests start a verifier from published keys alone; the same five cases in Python and TypeScript.M1 — the window is judged at arrival on the Connect listener too.
TapHandleis replaced byVerifier.StatsHandler: gRPC callsTagRPCinhandleStreamon every transport, includinggrpc.Server.ServeHTTP, which the generated Connect listener (vanguardgrpc) uses and where no tap handle runs. The console installs it. Tests (console, the real generated server, both listeners):TestALateBodyIsJudgedAtArrivalOnBothListeners(gRPC to the gRPC and to the Connect listener),TestALateConnectBodyIsJudgedAtArrival(Connect protocol),TestOneAdmissionOnBothListenersRunsOnce. With the stats handler removed, all three late-body cases fail asstamp_window.M2 — crashed holders leave no permanent row.
replaypg.Collectdeletes every reservation whose expiry plus allowance has passed by the database clock, active or not:Reserverefuses past retention on that clock, so nothing is revived. Tests:TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock(real Postgres; fails on the old statement),TestAReservationIsHeldToItsRetentionWhetherOrNotItsCallFinished,TestCollectingARunningCallsReservationReopensNothing(transport + Postgres).M3 — Python reservation-store parity. A store is reservation-capable only with both
reserveandfinish(reserve_async/finish_asyncon the async path), as TypeScript requires. Test:test_a_store_with_reserve_but_no_finish_is_consumed_not_reserved(fails without the fix).Merged with main twice: #30 (local setup) and #7 (installation-scoped grants), then #31 (one destination per service; its rule kept, both routing test sets kept).
A
codefly runof the playground was not verified in round 3; round 4 below has a live run.Security review round 4 (2552d5c): B1' and N1
B1' — the hop registers its admission key; the local authority derives nothing (coordinator's decision). The local authority read
hop-signing/SEEDfor the hop, a secret no composed run delivers to it (it cannot depend on the hop, which depends on it), so the playground published no admission key and the console refused every call askeys_unavailable.codefly.dev/authority@0.3.3(additive;>=0.2.0 <0.4.0consumers admit it) addsRegisterAdmissionKey(actor, public_key) → (key, expires_at_unix). Regenerated only through Codefly v0.1.215 (generate protoin the local service,generate client --no-facadefor authority-client);tools/noproxygate/generated.jsonrecords the five regenerated Go files.module-identity, so the run vouches for it, and registers its public half under its own workload credential (hop.Registration.Keep,libraries/hop/go/register.go): at boot, then renewing at a quarter of each lease, every 5 s after a failure, logging each change of outcome. The seed never leaves the hop.authority/hopby name (the caller is console/api, not the hop (authority/hop)), then publishes the key inKeys.admission_keys(purposehop-admission) until its lease (AdmissionLease, 2 min) lapses. Re-registration renews one entry (a restarted hop is idempotent; a restarted authority learns the key at the next renewal, ≤30 s); a rotation publishes both keys while both are renewed; a key no running hop renews is withdrawn. At most 8 keys are published at once (refused by name past that).stamp.KeyIDis the one key-id derivation.hopAdmissionKey, its tests and the seed read are deleted.keyRetry, instead of waiting outkeyMaxAge— Go, Python and TypeScript alike. Callers insidekeyRetrystill drive no read.TestTheHopsRegisteredKeyIsPublishedInKeys,TestANonHopCallerIsRefusedByName,TestReRegistrationAtRestartIsIdempotent,TestARotationOverlapsUntilTheRetiringKeyLapses,TestThePublishedAdmissionKeysAreBounded(local issuer);TestUnderThePlaygroundsIdentityTheHopsRegisteredKeyAdmitsItsStamps(local, over the wire: the hop's process environment as the playground run gives it — workspaceauthority-playground,authority/hop, its own seed, its projected credential — and none of it on the authority; Keys is empty before, publishes the hop's key after, a genuine stamp is signed by the published key and checks, an impostor's is not,documentsis refused by name);TestTheHopKeepsItsPublicKeyRegisteredUnderItsCredential,TestARefusedRegistrationIsReportedAndRetried,TestAVerifierThatReadBeforeTheHopRegisteredLearnsItsKeyWithinKeyRetry(hop/go; fails without the re-read); the same verifier case in Python and TypeScript (each fails without its fix). The module-side Go verifier links the authority-client bindings of the same protos, which cannot share a test binary with the local service's own, so the verifier half of the end to end is driven from Keys in its module and proven in the live run below.accountsimplementingRegisterAdmissionKey(attest the hop's workload, publish what it registers). Until it does, a hop fronting modules on a cell logs that its key is not published and those modules refuse every admission askeys_unavailable. The handbook record of the 0.3.3 interface follows the code.keyMaxAge(a stamp by an unknown key starts no read), so start a hop with the new seed that long before calls are routed to it, while a hop with the retiring seed keeps renewing the retiring key (libraries/hop/README.md).N1 — collection cannot revive a nonce near expiry.
replaypg.Collectdeletes only rowsCollectMargin(1 min) past their retention by the database clock, so a collection committing betweenReserve's clock check and its insert finds nothing to delete. Tests (real Postgres):TestACrashedHoldersReservationIsCollectedOnTheDatabaseClock(a row 61 s past is collected, 30 s past is kept),TestConcurrentCollectionNeverRevivesANonceNearItsExpiry(20 rounds of Collect racing Reserve across expiry: never admitted, never collected inside the margin); both fail on the old statement. The transport regressions age the row by the margin before collecting.Live run (codefly v0.1.215,
--naming-scope fix28r5, 2026-10-26). The repo'splayground/does not load on this CLI, independent of this PR:records0.5.0 ships nomodule.contract.codefly.yaml, so generating hop routes fails (hop routes for records: openat module.contract.codefly.yaml), and 0.6.0 renamed the modulerecord-store. So I ran a scratch copy of the playground workspace composingauthorityandconsolefrom this checkout, with the hop fronting the console only and no edge routes (codefly run service console/api authority/hop --local-agents --naming-scope fix28r5); nothing about the hop was configured on the authority. Observed:the authority publishes this hop's admission key;Keyson the local authority:admissionKeys: [hop-67d0eb32a0bbae3a, purpose hop-admission].WhoAmIthrough the hop:maya,human,acme,console.identity:read(the console's verifier accepted the hop's genuine stamp);Version:0.1.0.stamp_proof. No Work Context through the hop: refused.RegisterAdmissionKeyunder the console's own projected credential:PermissionDenied: the caller is console/api, not the hop (authority/hop); with no credential: refused; Keys still publishes one key.Docs updated
libraries/hop/README.md— the wire contract: the clock allowance, the window judged at arrival (the stats handler, both listeners), the transcoded-REST refusal, the Postgres store, collection and the startup guard, "Delivering the verification key" (published inKeys, rotation overlap, the accounts follow-up), and the adoption order.libraries/hop/go/README.md— the module-side example, now withreplaypg, no key, andgrpc.StatsHandler.README.md— the 0.3.2 and 0.3.3 interface notes.libraries/authority-client/python/README.md,libraries/authority-client/typescript/README.md— the single identity reader, the admission reason codes, and construction without a hop key or replay store.modules/console/services/store/README.md— new: why the console composes a store.playground/configurations/README.md— no hop key is configured; the hop registers it;hop-verification.envis deleted.libraries/hop/README.md— the hop's registration, the re-read while no key is published, the rotation procedure, the collection margin.modules/authority/services/hop/service.codefly.yaml—module-identity: true.modules/authority/services/local/service.codefly.yaml— nothing about the hop is read.modules/console/services/api/service.codefly.yaml,modules/console/module.codefly.yaml— the store dependency; thehop-verificationgroup is gone.modules/authority/services/local/service.codefly.yaml— what the local authority reads to publish the hop's key.Validation
go vetandgo test -race -count=1pass in all 11 Go modules, includingtools/noproxygate, withHOP_TEST_POSTGRESon a local Postgres 17 (the transport and store regressions ran);go mod tidy -diffand gofmt clean (round 4 at b9b716b; the hop service re-run at 655fe4f).Addresses the admission gap reported in module-robin #512 finding 2 and module-evaluation #91 finding 1; related authority issue #5. Downstream owners must adopt the breaking API (no key argument; install
StatsHandler), have their authority implementRegisterAdmissionKeyand publish what the hop registers, and supply a durable replay backend shared by every replica.🤖 Generated with Claude Code