plan: "0021" title: "Account modes and market transport modes: managed, associated, existing; XRPC, firehose, both" status: todo source: > The old TypeScript market tree (atproto-market/hono-bidder, request-vm-ssh, market-identity-*, atproto-event-streams-client) supported three account behaviours and two practical transport combinations. The Go rewrite preserved the API names but not the behaviour: MarketIdentity modes beyond localRepo are declarative only, RemotePDS auth is unread, AtprotoRecord has no reconciler, AtprotoRecordWatch ignores match/source, and no market service consumes a firehose. This plan restores the full matrix without reintroducing Deno application components. principle: > An application owns a MarketIdentity (account/sign-in), an AtprotoRepo (repo DID plus local Pds or RemotePDS plus credentials), declared AtprotoRecord objects, and a CommS policy. Local and remote PDS differ only in the AtprotoRepo binding. XRPC and firehose differ only in transport. Domain state lives in kcp objects; transports are durable EventStream and StateSync objects, not in-memory maps or time-window dedupe. old_behavior: account_modes: | Managed associated: localRepo plus association RequireApproval. New local PDS/DID. The operator writes a canonical badgeBlueKeys claim into the operator's repo; the app writes its side. The operator account is otherwise untouched. Managed unassociated: localRepo plus association Skip. New local PDS/DID. No badgeBlueKeys record; the operator account is untouched. only-me policy denies an unassociated peer unless the policy is open or association is supplied separately. Existing account: the real account's repo becomes the market repo. Bidder: oauthQr, oauthBrowser, oauthDelegated, or pdsAccount (handle/password to --atproto-pds-url). Requester: oauthQr or oauthBrowser only. OAuth session is the association proof; no badgeBlueKeys record is created. Every market record is authored as the account DID in the account's PDS. account_details: | Bidder selection precedence: explicit MarketIdentity mode, KCP mode, --atproto-oauth -> oauthBrowser, --atproto-oauth-qr -> oauthQr, handle plus password/vault -> pdsAccount, otherwise localRepo. OAuth QR: cliDid is the did:key of the secp256k1 CLI key; qrOrigin/#oauth=&n=; browser posts the full DPoP session to com.fedfork.atprotoOauthQR; CLI polls with a service-auth JWT (aud did:web:, lxm com.fedfork.atprotoOauthQR); session TTL 5m; session stored in a file or vault; refresh on access-token expiry; SessionExpired -> signInAgain. Local bidder association records historically used an inverted shape (challenge=bidder, keyId=operator); requester and operator webapp used canonical (challenge=operator, keyId=bidder). New Go trust expects canonical only. Bidder writes bidder_attestation (keyId=attestation did:key, challenge=account DID, service=bidder_attestation) in all modes. The old requester has no equivalent attestation binding. comms_modes: | Both: XRPC submit endpoints and event streams both active. This is the old default. Firehose/relay only: --no-ingress-proxy removes the custom XRPC ingress; discovery and record observation come from subscribeRepos or JetStream. XRPC only: not selectable from the old CLI. Only library embedders that constructed the market without an eventStreams client could express it. The old --firehose-mode off did not disable default relays or default JetStream URLs. --firehose-mode jetstream was effectively unused, and --firehose-url was consumed only by subscriberepos. The old subscribeRepos watcher accepted JSON frames only; real relay CBOR was discarded. old_dedupe: | Event client: in-memory uri|cid, 15-second window. Accept dispatch: acceptUri, in-memory 1-hour TTL. Bid collector: bid.uri set per contract. acceptToContract: acceptUri#acceptCid, restored only when a contract store exists. vm.delete: receipt URI plus FS tombstones when a contract store exists. No durable cursor and no cross-process dedupe. new_gaps: | AtprotoRecord has no controller. Only reconcileOperatorRecords writes spec.repo: operator records; spec.repo: pds records are never applied. AtprotoRecordWatch decodes only repo and collection. It ignores match, source.relayUrls and source.jetstreamUrls, and always generates a local Pds EventStream. Relay-sourced watches cannot verify records. EventStream handler sink has no registered handlers. The registry exists; no market handler registers. Relay sinks cannot fetch records without a PDS source. No JetStream implementation exists in Go. RemotePDS.spec.auth is never read. Resolver.ResolveCR uses URL/DID/TLS only. appctl never creates or consumes a RemotePDS CR. pdsAccount is partial. Bidder profiles force localRepo; a requester pdsAccount/callback without PdsRef silently falls back to the local repo. RequesterSpec has no PDS URL or session-ref field. MarketIdentity.KeysRef, SessionRef, AssociateWith and AssociationWatch are mostly unused. No Go code sets pendingAction. App readiness is not gated on association. PdsAccount and AtprotoSession status objects cannot become Ready: no AccountSource/SessionSource is wired and the session resolver has no tokens. Requester StateSync bids/events are passive mailboxes drained by the requester loop, not reconciled by the StateSync controller. Bidder and requester have separate RecordStore abstractions over the same pdsclient.PDS. GatewaySpec has no identity; gateway requester is hardcoded local. SshGateway is hardcoded local/owner-session. BidderIdentityMode hardcodes localRepo for every profile. No tests cover OAuth modes, RemotePDS auth, EventStream cursors/sinks/ sources, watch match/source, or firehose market consumption. target_model: identity: | Managed associated: MarketIdentity mode=localRepo, association=RequireApproval. AtprotoRepo points at the app Pds. Canonical claim on the operator side and canonical association record on the app side. Managed unassociated: mode=localRepo, association=Skip. Same repo, no association record; Associated=True/AssociationSkipped. Existing OAuth: mode=oauthQr|oauthBrowser|oauthDelegated. No local Pds component. AtprotoRepo points at the account PDS. No association record; SignedIn is the proof. Existing PDS account: mode=pdsAccount. AtprotoRepo points at the target RemotePDS. Password from vault. No association record. repo: | AtprotoRepo is a binding, not a status mirror: spec.did, spec.pdsRef {kind: Pds|RemotePDS, name}, spec.identityRef, spec.authRef status.phase, status.head, conditions Ready/InSync. Every controller and service resolves through one RepoResolver returning ResolvedRepo {pdsclient.PDS, repo DID, TokenSource, PDSRef}. records: | AtprotoRecord.spec.repoRef selects an AtprotoRepo. The controller resolves valueFrom references, writes/deletes through pdsclient.PDS, and writes Applied/Pending/Failed plus ReferencesResolved/InSync conditions. AtprotoRecordWatch is a filtered verified view over one or more EventStreams. It honors match and source URLs, fetches the record from the author's PDS, and persists per-source cursors. comms: | spec.comms.mode: XRPC | Firehose | Both on MarketBidder, MarketRequester, Gateway and SshGateway. PDS writes happen in all modes. XRPC: submit/callback endpoints active; no market EventStreams; relay listReposByCollection/static DIDs still used for discovery. Firehose: no market submit calls; EventStreams are the only market ingress. Both: both paths; durable dedupe. dedupe: | Immutable record version key: uri|#|cid. One-shot side-effect keys: accept URI, receipt URI, termination URI. StateSync object names are deterministic hashes of those keys; Create returning AlreadyExists is success. EventStream cursor is persisted in status. No in-memory time-window dedupe. state_rules: - Read AGENTS.md first. status is one of todo, doing, done, blocked. The lead flips statuses. - One writer per directory. Tracks that touch the same file are serialized even when the wave is parallel. - Every item ends with gofmt, go build ./..., go vet ./..., and the relevant integration gate. No push. - The old TypeScript tree is reference only; no Deno application component returns. - Secrets stay in OpenBao. Status and CR specs carry refs, never tokens, passwords, JWTs, DPoP private keys or callback codes. waves: - id: W0 title: "Contracts and test matrix" serial: true tracks: - W0-CONTRACTS - W0-MATRIX gate: "api types, deepcopy, schemas, exports and the mode matrix are agreed; no behavior changed." - id: W1 title: "Shared plumbing" parallel: true tracks: - name: repo items: [W1-REPO] - name: eventstream items: [W1-EVENTSTREAM] - name: oauth items: [W1-OAUTH] gate: "local and fake remote PDS resolve; watch sources and match decode; shared OAuth session compiles; go build/vet." - id: W2 title: "Records, watches, identity" parallel: true tracks: - name: records items: [W2-RECORD, W2-WATCH] - name: local-identity items: [W2-IDENTITY-LOCAL] after: [W2-RECORD, W2-WATCH] - name: remote-identity items: [W2-IDENTITY-REMOTE] - name: status-fixtures items: [W2-STATUS, W2-FIXTURES] gate: "association records apply and revoke; local associated/skip work; pdsAccount and OAuth sessions resolve; status objects Ready." - id: W3 title: "Transport modes" parallel: true tracks: - name: market-events items: [W3-MARKET-EVENTS] - name: statesync items: [W3-STATESYNC] - name: discovery items: [W3-DISCOVERY] - name: service-wiring items: [W3-SERVICE-WIRING] after: [W3-MARKET-EVENTS, W3-STATESYNC, W3-DISCOVERY] gate: "XRPC, firehose and both pass the duplicate/restart/out-of-order matrix on local and remote PDS." - id: W4 title: "Composition and cleanup" parallel: true tracks: - name: app-roots items: [W4-APP-ROOTS] - name: cleanup items: [W4-REMOVE-LEGACY] after: [W4-APP-ROOTS] - name: migration items: [W4-MIGRATION] after: [W4-APP-ROOTS] - name: docs items: [W4-DOCS] after: [W4-APP-ROOTS] gate: "no hardcoded mode planner; existing localRepo installs upgrade; docs and manifests match." - id: W5 title: "Live verification" parallel: true tracks: - name: local-and-prod items: [W5-LOCAL, W5-PROD] - name: hosts items: [W5-HOSTS] after: [W5-LOCAL] - name: close items: [W5-CLOSE] after: [W5-LOCAL, W5-HOSTS, W5-PROD] gate: "every mode is proven live; prod gateway and the three bidder hosts stay Ready." items: - id: W0-CONTRACTS title: "Add the account, repo and comms API contracts" wave: W0 track: contracts parallel_with: [W0-MATRIX] depends_on: [] status: done evidence: [] decision: > Add MarketCommsMode {XRPC, Firehose, Both} and MarketComms {mode, relayUrls, firehoseUrls}. Add it to MarketBidderSpec, MarketRequesterSpec, GatewaySpec and SshGatewaySpec. Add RepoRef {workspace, kind, name} and AtprotoRepoSpec fields did, pdsRef, identityRef, authRef. Add AtprotoRecord.spec.repoRef and AtprotoRecordWatch.spec.repoRef; keep the legacy repo string as a compatibility alias. Extend EventStreamSource with relayUrls, jetstreamUrls and kind=jetstream; keep component refs. Extend ApplicationIdentity with PdsRef and SessionRef refs, never secret values. files: - api/v1alpha1/types_application.go - api/v1alpha1/types_component.go - api/v1alpha1/types_atprotorepo.go - api/v1alpha1/types_atprotorecord.go - api/v1alpha1/types_atprotorecordwatch.go - api/events/v1alpha1/types_eventstream.go - api/pds/v1alpha1/types_remotepds.go - deploy/workspacetype-application.yaml - deploy/sets.yaml done_when: > go generate ./api/... is clean, deepcopy and schemas include the new fields, and the old default of managed associated remains representable. - id: W0-MATRIX title: "Freeze the account and transport acceptance matrix" wave: W0 track: contracts parallel_with: [W0-CONTRACTS] depends_on: [] status: done evidence: [] decision: > Write the integration matrix before implementation: three account modes x local/remote PDS x bidder/requester; XRPC, firehose subscribeRepos, firehose JetStream and both; duplicate delivery; restart; out-of-order accept/event; association approval and revocation; existing-account no association record. Name the fixtures and the live hosts for each row. files: - test/integration/ - docs/plans/0021-account-and-comms-modes.yaml done_when: > Every item below names the tests and live proof it needs. A row with no test is blocked, not silently skipped. - id: W1-REPO title: "One repo/account resolver for local and remote PDS" wave: W1 track: repo parallel_with: [W1-EVENTSTREAM, W1-OAUTH] depends_on: [W0-CONTRACTS] status: done evidence: [] decision: > Add ResolvedRepo and RepoResolver over pdsclient.PDS. Resolve local Pds components, RemotePDS CRs and operator repo bindings. Read RemotePDSAuth fields and turn them into TokenSource/DPoPSource; a remote PDS without resolvable auth fails closed. Make AtprotoRepo a real controller owned by componentcore. Switch appctl and statusstate to the resolver. Add AccountSource/SessionSource wiring so PdsAccount and AtprotoSession can become Ready for remote accounts. files: - internal/atproto/pdsclient/ - internal/appctl/pdsclient.go - internal/appctl/atproto_repo.go - internal/remotepds/ - internal/statusstate/ - internal/stack/componentcore/controllers.go done_when: > Local and fake remote AtprotoRepo objects report Ready; a RemotePDS access-token ref resolves; a missing auth ref is Failed, not local. - id: W1-EVENTSTREAM title: "Make EventStream source- and match-aware" wave: W1 track: eventstream parallel_with: [W1-REPO, W1-OAUTH] depends_on: [W0-CONTRACTS] status: done evidence: [] decision: > Decode AtprotoRecordWatch.match and source. Create one EventStream per relay/JetStream/PDS source. Add external relay URLs and JetStream JSON support. A record-watch sink resolves the author's PDS and fetches the record by URI/CID; a relay source no longer requires a local PDS. Register a real handler registry for the handler sink. Persist a cursor per source. Do not store arbitrary event logs in status beyond the bounded watched-record set. files: - internal/eventstream/recordwatch.go - internal/eventstream/recordtypes.go - internal/eventstream/stream.go - internal/eventstream/relay.go - internal/eventstream/controller.go - api/events/v1alpha1/types_eventstream.go done_when: > A relay-sourced watch sees an operator repo record and verifies it from the author's PDS; a JetStream fixture produces the same normalized event; cursor resumes after restart. - id: W1-OAUTH title: "Extract the shared OAuth session and QR client" wave: W1 track: oauth parallel_with: [W1-REPO, W1-EVENTSTREAM] depends_on: [W0-CONTRACTS] status: done evidence: [] decision: > Move requester OAuthSession/DPoP refresh into internal/atproto/oauthclient. Add session store interfaces for OpenBao and tests. Add the QR session transfer poller for com.fedfork.atprotoOauthQR with service-auth audience/lxm, 5-minute TTL, nonce checks and burn-after-read semantics. Add the browser callback state/code exchange helper. No component owns tokens; all callers receive a TokenSource. files: - internal/atproto/oauthclient/ - internal/services/requester/oauth_session.go - internal/appctl/oauth_session.go done_when: > The requester still restores and refreshes its existing token store; the bidder can construct the same session client; QR poll and callback tests pass against fakes. - id: W2-RECORD title: "Implement the AtprotoRecord controller" wave: W2 track: records parallel_with: [W2-WATCH, W2-IDENTITY-REMOTE, W2-STATUS, W2-FIXTURES] depends_on: [W1-REPO] status: done evidence: [] decision: > Reconcile AtprotoRecord by repoRef. Resolve valueFrom with the existing reference rules, write through pdsclient.PDS, compare CID for InSync, repair drift, and delete according to finalizer/onMissingReference. Replace reconcileOperatorRecords with the same controller using an operator AtprotoRepo binding. Canonical association shape {challenge=operator,keyId=subject}; read legacy inverted bidder records only as a compatibility fallback. files: - internal/atproto/recordcontroller/ - internal/appctl/vouches.go - internal/appctl/association.go - deploy/apps/market-bidder/records.yaml - deploy/apps/gateway/records.yaml done_when: > An association declaration is Applied without manual writes; deleting the operator claim removes the local association; a changed reference rewrites the record; finalizer deletes it when the PDS is up. - id: W2-WATCH title: "Verify association claims and revoke them" wave: W2 track: records parallel_with: [W2-RECORD, W2-IDENTITY-REMOTE, W2-STATUS, W2-FIXTURES] depends_on: [W1-EVENTSTREAM] status: done evidence: [] decision: > Implement AtprotoRecordWatch match evaluation and verified-record status. For every candidate, fetch by URI/CID from the author PDS, verify authorship and match fields, and expose records/cursors/conditions. The MarketIdentity consumer writes approvedAssociations only for verified claims, and removes them on delete. No per-event status churn. files: - internal/eventstream/recordwatch.go - internal/identitycontroller/ - internal/appctl/association.go done_when: > claim create -> status verified -> association Approved; claim delete -> condition Revoked -> local association record removed; restart keeps the approved state by re-verification. - id: W2-IDENTITY-LOCAL title: "Managed associated and unassociated modes" wave: W2 track: local-identity parallel_with: [W2-IDENTITY-REMOTE, W2-STATUS, W2-FIXTURES] depends_on: [W2-RECORD, W2-WATCH, W1-REPO] status: done evidence: [] decision: > Drive localRepo from MarketIdentity spec. RequireApproval creates the canonical association record and waits for the verified claim; Skip sets Associated=True/AssociationSkipped. App readiness is gated on the identity result. Remove the unused/dead OAuthQr check from the bidder. Keep the app PDS only for localRepo. files: - internal/identitycontroller/ - internal/appctl/bidder.go - internal/appctl/requester.go - internal/appctl/pds.go - internal/stack/bidder/bidder.go done_when: > local associated and skip reach Ready; the skip mode creates no badgeBlueKeys record; a fresh install needs only the operator claim and approvedAssociations patch. - id: W2-IDENTITY-REMOTE title: "Existing-account modes: pdsAccount, OAuth browser, QR, delegated" wave: W2 track: remote-identity parallel_with: [W2-IDENTITY-LOCAL, W2-STATUS, W2-FIXTURES] depends_on: [W1-OAUTH, W1-REPO, W2-RECORD] status: done evidence: [] decision: > Implement the pdsAccount password/session flow and the OAuth flow. pdsAccount resolves the target RemotePDS and vault password, creates a session, and points AtprotoRepo at that PDS. OAuth browser exposes a callback Exposure and pendingAction visitUrl. OAuth QR publishes scanQr and polls the transfer endpoint. oauthDelegated waits for a sessionRef. All modes write market records as the account DID and create no association record. Existing-account is explicit opt-in. files: - internal/identitycontroller/ - internal/appctl/identity_planner.go - internal/appctl/bidder.go - internal/appctl/requester.go - internal/appctl/gateway.go - internal/atproto/oauthclient/ done_when: > bidder pdsAccount and requester OAuth use a remote PDS; session expiry publishes signInAgain; no token or password appears in status; existing account writes no badgeBlueKeys record. - id: W2-STATUS title: "Make remote account and session status truthful" wave: W2 track: status-fixtures parallel_with: [W2-RECORD, W2-WATCH, W2-IDENTITY-LOCAL, W2-IDENTITY-REMOTE, W2-FIXTURES] depends_on: [W1-REPO] status: done evidence: [] decision: > Wire AccountSource and SessionSource into statusstate. Resolve PdsAccount and AtprotoSession through the shared RepoResolver with a real token source. Keep only counts/booleans/metadata; never tokens. Remote AtprotoRepo status uses GetLatestCommit/GetRepo. files: - internal/statusstate/account.go - internal/statusstate/session.go - internal/statusstate/repo.go - internal/stack/componentcore/controllers.go done_when: > local and remote PdsAccount/AtprotoSession go Ready or a named failure; no "no account source" or no-token false failure remains. - id: W2-FIXTURES title: "Shared fakes for local PDS, RemotePDS, OAuth and relay" wave: W2 track: status-fixtures parallel_with: [W2-RECORD, W2-WATCH, W2-IDENTITY-LOCAL, W2-IDENTITY-REMOTE, W2-STATUS] depends_on: [W0-MATRIX] status: done evidence: [] decision: > Add test fixtures: local PDS with account/session/repo; RemotePDS with auth refs; OAuth token store and QR transfer server; relay subscribeRepos and JetStream frames; duplicate/restart injector. Reuse them across account and comms tests. files: - test/integration/ - internal/atproto/pdsclient/ (test helpers only) done_when: > The W0 matrix can be run without a live host; fixtures can be pointed at a real PDS without changing test logic. - id: W3-MARKET-EVENTS title: "Market EventStream inbox" wave: W3 track: market-events parallel_with: [W3-STATESYNC, W3-DISCOVERY] depends_on: [W1-EVENTSTREAM, W2-RECORD] status: done evidence: [] decision: > Register a market handler sink that receives verified record events, fetches the record from the author PDS, and creates a deterministic StateSync inbox object. Collections are rfp, bid, accept, receipt, market.event and compute events. No service-local subscription or in-memory seen set. The handler reports Ready/Lagging/Stalled. files: - internal/services/bidder/ - internal/services/requester/ - internal/eventstream/handlers.go done_when: > A firehose-only bidder sees a new RFP and a firehose-only requester sees a new bid without any submit XRPC call. - id: W3-STATESYNC title: "Durable outbox/inbox and dedupe" wave: W3 track: statesync parallel_with: [W3-MARKET-EVENTS, W3-DISCOVERY] depends_on: [W2-RECORD] status: done evidence: [] decision: > Add market XRPC outbox adapters and inbound inbox adapters. Use deterministic StateSync names and idempotency keys: uri|#|cid for record versions; acceptUri, receiptUri, terminationUri for one-shot side effects. XRPC and firehose create the same object; Create AlreadyExists is success. Add retry/Stalled/pending-event handling; no time-window dedupe. Replace the requester's passive mailbox drain with a real adapter or a shared inbox controller. files: - internal/statesync/ - internal/services/bidder/statesync.go - internal/services/requester/bids.go - internal/services/requester/inbox.go done_when: > duplicate bid/accept/receipt/event from both transports produces one domain object and one side effect; restart preserves dedupe. - id: W3-DISCOVERY title: "Relay crawl, offering and fail-closed discovery" wave: W3 track: discovery parallel_with: [W3-MARKET-EVENTS, W3-STATESYNC] depends_on: [W1-REPO, W2-STATUS] status: done evidence: [] decision: > Make PDS registration/offering refresh a first-class controller action. For Firehose/Both, require either a crawlable PDS (ingress/relay) or a direct EventStream source. If neither, set Failed, do not silently fall back to XRPC. Keep relay listReposByCollection for XRPC discovery and static bidder DIDs as the offline fallback. files: - internal/services/bidder/ - internal/services/requester/ - internal/appctl/bidder.go - internal/appctl/requester.go - deploy/apps/market-bidder/ - deploy/apps/gateway/ done_when: > local PDS bidder is visible in relay index in firehose mode; missing ingress yields a named condition; XRPC-only still discovers via relay or static DIDs. - id: W3-SERVICE-WIRING title: "Wire XRPC, firehose and both into bidder/requester" wave: W3 track: service-wiring parallel_with: [] depends_on: [W3-MARKET-EVENTS, W3-STATESYNC, W3-DISCOVERY] status: done evidence: [] decision: > Services read spec.comms from their component spec. XRPC mounts submit handlers and sends submit outbox records. Firehose does not call submit* for market records; its only market ingress is StateSync from the market handler. Both does both. PDS writes are unconditional. Removal of XRPC handlers is fail-closed when Firehose is selected. files: - internal/services/bidder/ - internal/services/requester/ - internal/appctl/components.go - api/v1alpha1/types_component.go done_when: > mode is visible in component status; switching mode needs no env flags; each mode passes the matrix. - id: W4-APP-ROOTS title: "One app-root composition for identity, repo and comms" wave: W4 track: app-roots parallel_with: [] depends_on: [W2-IDENTITY-LOCAL, W2-IDENTITY-REMOTE, W3-SERVICE-WIRING] status: done evidence: [] decision: > Replace BidderIdentityMode, gateway/SSH/network hardcoded modes and the per-service mode decisions with one planner. Each root renders MarketIdentity, AtprotoRepo, AtprotoRecord, EventStream and StateSync children. Local Pds is created only for localRepo. RemotePDS refs are explicit. Gateway and SshGateway gain identity/comms fields. files: - internal/appctl/bidder.go - internal/appctl/requester.go - internal/appctl/gateway.go - internal/appctl/sshgateway.go - internal/appctl/network.go - internal/appctl/identity_planner.go done_when: > A root object alone chooses the account and transport mode; no profile names a different planner; gateway and SSH can select local or remote without code changes. - id: W4-REMOVE-LEGACY title: "Remove hardcoded and duplicate paths" wave: W4 track: cleanup parallel_with: [W4-MIGRATION, W4-DOCS] depends_on: [W4-APP-ROOTS] status: done evidence: [] decision: > Delete BidderIdentityMode, dead CheckSignIn OAuth branch, direct reconcileOperatorRecords, appctl ensurePdsAccountInVault branching and the duplicate bidder/requester RecordStore. Keep only the shared resolver and local/remote bindings. Remove unused RemotePDS in-memory synthesis. files: - internal/appctl/ - internal/services/bidder/ - internal/services/requester/ done_when: > rg finds no hardcoded localRepo for bidder/gateway/SSH; build/vet and existing installs upgrade on localRepo. - id: W4-MIGRATION title: "Compatibility for existing installs and records" wave: W4 track: migration parallel_with: [W4-REMOVE-LEGACY, W4-DOCS] depends_on: [W2-RECORD, W4-APP-ROOTS] status: done evidence: [] decision: > Default omitted identity to managed associated; default comms to Both when sources exist, otherwise XRPC. Read legacy inverted association records but write canonical. Keep old AtprotoRecord repo string mapped to repoRef. Do not force a fresh install for localRepo state. files: - internal/appctl/ - internal/atproto/trust/ - api/v1alpha1/ done_when: > local/hat-9 installed bidders upgrade without a new DID; old association records still resolve; new records are canonical. - id: W4-DOCS title: "Update architecture and operator docs" wave: W4 track: docs parallel_with: [W4-REMOVE-LEGACY, W4-MIGRATION] depends_on: [W4-APP-ROOTS] status: done evidence: [] decision: > Update AGENTS.md, CONTRIBUTING.md, BIDDER_ARCH.md, GATEWAY_ARCH.md, docs/ARCHITECTURE.md and docs/oauth.md with the mode matrix, defaults, vault paths, QR origin, and fail-closed transport conditions. files: - AGENTS.md - CONTRIBUTING.md - BIDDER_ARCH.md - GATEWAY_ARCH.md - docs/ARCHITECTURE.md - docs/oauth.md done_when: > Docs match the implemented defaults and commands; no pendingAction or DenoPod instructions remain for the Go tree. - id: W5-LOCAL title: "Run the local account and transport matrix" wave: W5 track: local-and-prod parallel_with: [W5-PROD] depends_on: [W4-REMOVE-LEGACY, W4-MIGRATION] status: done evidence: [] decision: > Run local managed associated, managed unassociated, pdsAccount, OAuth browser and OAuth QR where a safe account exists; run XRPC, subscribeRepos, JetStream and both; inject duplicates, restart, and out-of-order events. Use disposable accounts for existing-account modes. files: - test/integration/ - test/soak/ done_when: > Every matrix row passes; no duplicate VM or bid; cursors resume; no secrets in kcp. - id: W5-HOSTS title: "Prove the bidder hosts" wave: W5 track: hosts parallel_with: [] depends_on: [W5-LOCAL] status: done evidence: [] decision: > Install/upgrade this host, hat-9 and hat-7. Keep this host and hat-9 in association mode; run hat-7 in oauthQr against the bobvmbuilder account so it authors as that real account and runs no local PDS. Verify DIDs, records, association conditions and component readiness. files: - BIDDER_ARCH.md done_when: > all three bidders Ready; this host and hat-9 Associated; hat-7 Ready in oauthQr with status.did equal to the bobvmbuilder account DID and no local Pds component; association records present where expected. - id: W5-PROD title: "Prove the prod gateway" wave: W5 track: local-and-prod parallel_with: [W5-LOCAL] depends_on: [W4-REMOVE-LEGACY] status: done evidence: [] decision: > Upgrade the prod gateway, refresh Caddy certificates, sign in aliceoa.bsky.social, create a cluster, fetch its kubeconfig, run kubectl against kcp.socialweb.computer, delete the cluster, and verify kcp.socialweb.computer health. Do not push. files: - GATEWAY_ARCH.md - scripts/deploy.sh done_when: > prod gateway Ready; Alice session and cluster API work; kubeconfig authenticates; test cluster removed. - id: W5-CLOSE title: "Close the plan with evidence" wave: W5 track: close parallel_with: [] depends_on: [W5-LOCAL, W5-HOSTS, W5-PROD] status: done evidence: [] decision: > Run gofmt, go build, go vet, deploy/check.sh --fast, make check-pins and the full live matrix. Record evidence in each item. No push. files: - docs/plans/0021-account-and-comms-modes.yaml done_when: > all items done or blocked with a named reason; no secrets in status; working tree clean except the plan evidence. test_matrix: accounts: - managed-associated (local PDS) - managed-unassociated (local PDS) - existing-pds-account (remote PDS) - existing-oauth-browser (remote account) - existing-oauth-qr (remote account) - existing-oauth-delegated (remote account) transports: - XRPC - Firehose subscribeRepos - Firehose JetStream - Both faults: - duplicate XRPC + firehose delivery - process restart between delivery and apply - out-of-order accept before bid/receipt - relay reconnect/cursor resume - missing ingress for Firehose - revoked association claim - expired OAuth session live: - this host: associated - hat-9: associated - hat-7: oauthQr, bobvmbuilder account - socialweb.computer: prod gateway, Alice sign-in and tenant kubeconfig acceptance_matrix: rule: > W0-MATRIX freezes these rows. Every row names a live host or a fixture that already exists or is still required. A row with neither is blocked, never silently skipped. proof_sources: | this host: the local bidder host, associated (W5-HOSTS) hat-9: associated bidder host (W5-HOSTS) hat-7: unassociated bidder host (W5-HOSTS) prod gateway: socialweb.computer gateway, Alice sign-in (W5-PROD) dev market network w3comms: disposable dev/CI market network test/integration: the integration suite under test/integration fixtures: local-pds: a Pds component with account, session and repo, on a loopback port, no ingress remote-pds: a RemotePDS with URL, DID and auth refs, including a fail-closed missing-auth case oauth-store: a token store with refresh, expiry and revoked-session cases qr-transfer: the com.fedfork.atprotoOauthQR transfer endpoint with nonce and burn-after-read relay-subscribeRepos: an external relay serving JSON and CBOR subscribeRepos frames jetstream: an external JetStream endpoint serving the same normalized events duplicate-injector: delivers one uri|#|cid twice over XRPC and the firehose restart-injector: stops the process between delivery and apply, then resumes the cursor rows: - account: managed-associated identity: localRepo + RequireApproval pds: local Pds roles: [bidder, requester] transports: [XRPC, Firehose subscribeRepos, JetStream, Both] fixtures: [local-pds, relay-subscribeRepos, jetstream, duplicate-injector, restart-injector] proof: [test/integration, this host, hat-9, dev market network w3comms] test: TestMarketAccountAndTransportMatrix/managed-associated - account: managed-unassociated identity: localRepo + Skip pds: local Pds roles: [bidder, requester] transports: [XRPC, Firehose subscribeRepos, JetStream, Both] fixtures: [local-pds, relay-subscribeRepos, jetstream, duplicate-injector] proof: [test/integration, this host, dev market network w3comms] test: TestMarketAccountAndTransportMatrix/managed-unassociated - account: existing-pds-account identity: pdsAccount pds: RemotePDS roles: [bidder, requester] transports: [XRPC, Firehose subscribeRepos, JetStream, Both] fixtures: [remote-pds, relay-subscribeRepos, jetstream] proof: [test/integration, this host, dev market network w3comms] test: TestMarketAccountAndTransportMatrix/existing-pds-account - account: existing-oauth-browser identity: oauthBrowser pds: RemotePDS roles: [bidder, requester] transports: [XRPC, Firehose subscribeRepos, JetStream, Both] fixtures: [remote-pds, oauth-store, relay-subscribeRepos, jetstream] proof: [test/integration, this host, prod gateway] test: TestMarketAccountAndTransportMatrix/existing-oauth-browser - account: existing-oauth-qr identity: oauthQr pds: RemotePDS roles: [bidder, requester] transports: [XRPC, Firehose subscribeRepos, JetStream, Both] fixtures: [remote-pds, qr-transfer, oauth-store, relay-subscribeRepos, jetstream] proof: [test/integration, this host, hat-7, prod gateway] test: TestMarketAccountAndTransportMatrix/existing-oauth-qr - account: existing-oauth-delegated identity: oauthDelegated pds: RemotePDS roles: [bidder, requester] transports: [XRPC, Firehose subscribeRepos, JetStream, Both] fixtures: [remote-pds, oauth-store, relay-subscribeRepos, jetstream] proof: [test/integration, this host] test: TestMarketAccountAndTransportMatrix/existing-oauth-delegated faults: - fault: duplicate delivery (XRPC + firehose) fixtures: [duplicate-injector] proof: [test/integration, this host, dev market network w3comms] test: TestMarketDuplicateDeliveryCreatesOneObject - fault: process restart between delivery and apply fixtures: [restart-injector] proof: [test/integration, this host, dev market network w3comms] test: TestMarketRestartResumesCursorsAndDedupe - fault: out-of-order accept before bid/receipt and out-of-order event fixtures: [duplicate-injector] proof: [test/integration, this host, dev market network w3comms] test: TestMarketOutOfOrderAcceptConverges - fault: missing source fails closed (Firehose without ingress or EventStream) fixtures: [local-pds] proof: [test/integration, this host, dev market network w3comms] test: TestMarketFirehoseFailsClosedWithoutIngress - fault: missing auth fails closed (RemotePDS without auth ref) fixtures: [remote-pds] proof: [test/integration, this host, prod gateway] test: TestMarketRemotePDSMissingAuthFailsClosed - fault: relay reconnect and cursor resume fixtures: [relay-subscribeRepos, jetstream] proof: [test/integration, this host] test: TestMarketRelayReconnectResumesCursor - fault: revoked association claim fixtures: [relay-subscribeRepos] proof: [test/integration, this host, hat-9] test: TestMarketAssociationRevocationRemovesApproval - fault: expired OAuth session fixtures: [oauth-store] proof: [test/integration, this host, prod gateway] test: TestMarketExpiredSessionReportsSignInAgain risks: | Existing-account mode writes market records into the real account repo. Keep it explicit opt-in and document the risk. OAuth QR depends on the external qr.fedfork.com/did-key-associator protocol. Keep qrOrigin configurable and isolate the poller. Firehose for a local PDS needs ingress/relay crawl. Do not silently call XRPC when the selected mode cannot be satisfied. Dedupe keys for updates need one rule: URI+CID for versions, URI for one-shot side effects. Write it down and test both. AtprotoRecordWatch status can grow; keep only bounded watched records and per-source cursors. RemotePDS auth refs and session refresh are security-sensitive; test fail-closed and never log tokens. Legacy inverted bidder association records must remain readable while new writes are canonical. decisions: account_default: "managed associated (localRepo + RequireApproval)" comms_default: "Both when relay/firehose sources are configured; XRPC otherwise" existing_account: "explicit opt-in; market records live in the real account repo" qr_origin: "configurable; reuse qr.fedfork.com initially" association_shape: "canonical challenge=operator,keyId=subject; legacy read fallback" dedupe: "StateSync deterministic objects; uri|#|cid for versions; URI for one-shot effects" abstraction: "AtprotoRepo binding plus MarketIdentity account; no separate AtprotoAccount CR"