1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
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=<cliDid>&n=<nonce>; browser posts the full DPoP
session to com.fedfork.atprotoOauthQR; CLI polls with a service-auth JWT
(aud did:web:<qr host>, 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"