···9797 // Setup HTTP routes with chi router
9898 r := chi.NewRouter()
9999100100+ // Add RealIP middleware to extract real client IP from proxy headers
101101+ r.Use(middleware.RealIP)
102102+100103 // Add logging middleware to log all HTTP requests
101104 r.Use(middleware.Logger)
102105
+873
docs/BLUESKY_MANIFEST_POSTS.md
···11+# Bluesky Manifest Posts
22+33+## Overview
44+55+This document describes the feature for posting to Bluesky when OCI manifests are uploaded to ATCR holds. When a user pushes an image to the registry, the hold's embedded PDS will:
66+77+1. Create `io.atcr.hold.layer` records for structured metadata tracking
88+2. Post to Bluesky announcing the push (similar to the "what's new" feed on the AppView web UI)
99+1010+## Architecture
1111+1212+### High-Level Flow
1313+1414+```
1515+User pushes image
1616+ โ
1717+AppView receives manifest PUT request
1818+ โ
1919+AppView stores manifest in user's PDS
2020+ โ
2121+AppView notifies hold via XRPC
2222+ โ
2323+Hold creates layer records in embedded PDS
2424+ โ
2525+Hold creates Bluesky post
2626+ โ
2727+Post appears in Bluesky feed
2828+```
2929+3030+### Component Interactions
3131+3232+**AppView** (`pkg/appview/storage/manifest_store.go`):
3333+- After successfully uploading manifest to user's PDS
3434+- Extracts manifest metadata (repository, tag, user info, layers)
3535+- Calls hold's `io.atcr.hold.notifyManifest` XRPC endpoint
3636+- Uses service token from user's PDS for authentication
3737+- Gracefully handles notification failures (doesn't fail manifest upload)
3838+3939+**Hold** (`pkg/hold/oci/xrpc.go`):
4040+- Receives manifest notification via new XRPC endpoint
4141+- Validates service token and extracts user DID
4242+- Creates layer records for each blob reference in manifest
4343+- Creates Bluesky post announcing the push
4444+- Returns success/failure status
4545+4646+**Hold's Embedded PDS** (`pkg/hold/pds/`):
4747+- Stores layer records in `io.atcr.hold.layer` collection
4848+- Stores Bluesky posts in `app.bsky.feed.post` collection
4949+- Both are ATProto records with auto-generated TID rkeys
5050+- Queryable via standard ATProto sync endpoints
5151+5252+## Implementation Details
5353+5454+### 1. Layer Record Schema
5555+5656+**File**: `pkg/atproto/lexicon.go`
5757+5858+**Collection**: `io.atcr.hold.layer`
5959+6060+**Purpose**: Structured metadata about container layers stored in the hold
6161+6262+**Schema**:
6363+```go
6464+type LayerRecord struct {
6565+ // Type identifier (always "io.atcr.hold.layer")
6666+ Type string `json:"$type" cborgen:"$type"`
6767+6868+ // Digest of the layer (e.g., "sha256:abc123...")
6969+ Digest string `json:"digest" cborgen:"digest"`
7070+7171+ // Size in bytes
7272+ Size int64 `json:"size" cborgen:"size"`
7373+7474+ // MediaType of the layer
7575+ MediaType string `json:"mediaType" cborgen:"mediaType"`
7676+7777+ // Repository this layer belongs to (e.g., "alice/myapp")
7878+ Repository string `json:"repository" cborgen:"repository"`
7979+8080+ // User DID who uploaded this layer
8181+ UserDID string `json:"userDid" cborgen:"userDid"`
8282+8383+ // User handle (for display purposes)
8484+ UserHandle string `json:"userHandle,omitempty" cborgen:"userHandle,omitempty"`
8585+8686+ // Timestamp
8787+ CreatedAt time.Time `json:"createdAt" cborgen:"createdAt"`
8888+}
8989+```
9090+9191+**Constructor**:
9292+```go
9393+func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *LayerRecord {
9494+ return &LayerRecord{
9595+ Type: LayerCollection,
9696+ Digest: digest,
9797+ Size: size,
9898+ MediaType: mediaType,
9999+ Repository: repository,
100100+ UserDID: userDID,
101101+ UserHandle: userHandle,
102102+ CreatedAt: time.Now(),
103103+ }
104104+}
105105+```
106106+107107+**Why CBOR tags**: The hold's embedded PDS uses CBOR encoding for efficient storage in the SQLite-backed carstore. All records stored in the hold must have `cborgen:` tags.
108108+109109+### 2. XRPC Manifest Notification Endpoint
110110+111111+**File**: `pkg/hold/oci/xrpc.go`
112112+113113+**Endpoint**: `POST /xrpc/io.atcr.hold.notifyManifest`
114114+115115+**Authentication**: Service token from user's PDS (same pattern as blob upload endpoints)
116116+117117+**Request Schema**:
118118+```go
119119+type NotifyManifestRequest struct {
120120+ // Repository name (e.g., "alice/myapp")
121121+ Repository string `json:"repository"`
122122+123123+ // Tag (e.g., "latest", "v1.0.0")
124124+ Tag string `json:"tag"`
125125+126126+ // User DID (e.g., "did:plc:abc123")
127127+ UserDID string `json:"userDid"`
128128+129129+ // User handle (e.g., "alice.bsky.social")
130130+ UserHandle string `json:"userHandle"`
131131+132132+ // Manifest content (parsed from uploaded manifest)
133133+ Manifest struct {
134134+ MediaType string `json:"mediaType"`
135135+ Config struct {
136136+ Digest string `json:"digest"`
137137+ Size int64 `json:"size"`
138138+ } `json:"config"`
139139+ Layers []struct {
140140+ Digest string `json:"digest"`
141141+ Size int64 `json:"size"`
142142+ MediaType string `json:"mediaType"`
143143+ } `json:"layers"`
144144+ } `json:"manifest"`
145145+}
146146+```
147147+148148+**Response Schema**:
149149+```go
150150+type NotifyManifestResponse struct {
151151+ Success bool `json:"success"`
152152+ LayersCreated int `json:"layersCreated"`
153153+ PostCreated bool `json:"postCreated"`
154154+ PostURI string `json:"postUri,omitempty"` // ATProto URI if post created
155155+ Error string `json:"error,omitempty"`
156156+}
157157+```
158158+159159+**Handler Implementation**:
160160+```go
161161+func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Request) {
162162+ ctx := r.Context()
163163+164164+ // 1. Validate service token (reuse existing auth middleware pattern)
165165+ userDID, err := h.validateServiceToken(ctx, r)
166166+ if err != nil {
167167+ writeXRPCError(w, "InvalidToken", err.Error())
168168+ return
169169+ }
170170+171171+ // 2. Parse request
172172+ var req NotifyManifestRequest
173173+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
174174+ writeXRPCError(w, "InvalidRequest", err.Error())
175175+ return
176176+ }
177177+178178+ // 3. Verify user DID matches token
179179+ if req.UserDID != userDID {
180180+ writeXRPCError(w, "Unauthorized", "user DID mismatch")
181181+ return
182182+ }
183183+184184+ // 4. Create layer records for each blob
185185+ layersCreated := 0
186186+ for _, layer := range req.Manifest.Layers {
187187+ record := atproto.NewLayerRecord(
188188+ layer.Digest,
189189+ layer.Size,
190190+ layer.MediaType,
191191+ req.Repository,
192192+ req.UserDID,
193193+ req.UserHandle,
194194+ )
195195+196196+ _, _, err := h.pds.CreateLayerRecord(ctx, record)
197197+ if err != nil {
198198+ log.Printf("Failed to create layer record: %v", err)
199199+ // Continue creating other records
200200+ } else {
201201+ layersCreated++
202202+ }
203203+ }
204204+205205+ // 5. Create Bluesky post
206206+ postURI, err := h.pds.CreateManifestPost(ctx, req.Repository, req.Tag, req.UserHandle)
207207+208208+ // 6. Return response
209209+ resp := NotifyManifestResponse{
210210+ Success: layersCreated > 0 || err == nil,
211211+ LayersCreated: layersCreated,
212212+ PostCreated: err == nil,
213213+ PostURI: postURI,
214214+ }
215215+216216+ if err != nil && layersCreated == 0 {
217217+ resp.Error = err.Error()
218218+ }
219219+220220+ w.Header().Set("Content-Type", "application/json")
221221+ json.NewEncoder(w).Encode(resp)
222222+}
223223+```
224224+225225+### 3. Hold PDS Layer Record Methods
226226+227227+**File**: `pkg/hold/pds/layer.go` (new file)
228228+229229+**Methods**:
230230+231231+```go
232232+// CreateLayerRecord creates a new layer record in the hold's PDS
233233+func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) {
234234+ // Validate record
235235+ if record.Type != atproto.LayerCollection {
236236+ return "", "", fmt.Errorf("invalid record type: %s", record.Type)
237237+ }
238238+239239+ if record.Digest == "" {
240240+ return "", "", fmt.Errorf("digest is required")
241241+ }
242242+243243+ // Create record with auto-generated TID rkey
244244+ rkey, recordCID, err := p.repomgr.CreateRecord(
245245+ ctx,
246246+ p.uid,
247247+ atproto.LayerCollection,
248248+ record,
249249+ )
250250+251251+ if err != nil {
252252+ return "", "", fmt.Errorf("failed to create layer record: %w", err)
253253+ }
254254+255255+ log.Printf("Created layer record at %s/%s (digest: %s, size: %d)",
256256+ atproto.LayerCollection, rkey, record.Digest, record.Size)
257257+258258+ return rkey, recordCID.String(), nil
259259+}
260260+261261+// ListLayerRecords lists layer records with optional filtering
262262+func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) {
263263+ // Implementation using repomgr.GetRecord for pagination
264264+ // This would query the carstore and unmarshal layer records
265265+ // Return records + next cursor for pagination
266266+}
267267+268268+// GetLayerRecord retrieves a specific layer record by rkey
269269+func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) {
270270+ // Implementation using repomgr.GetRecord
271271+}
272272+```
273273+274274+### 4. Bluesky Post Creation
275275+276276+**File**: `pkg/hold/pds/manifest_post.go` (new file)
277277+278278+**Pattern**: Reuse existing `status.go` pattern
279279+280280+```go
281281+// CreateManifestPost creates a Bluesky post announcing a manifest upload
282282+func (p *HoldPDS) CreateManifestPost(ctx context.Context, repository, tag, userHandle string) (string, error) {
283283+ now := time.Now()
284284+285285+ // Format post text (similar to "what's new" feed)
286286+ text := formatManifestPostText(repository, tag, userHandle)
287287+288288+ // Create post struct
289289+ post := &bsky.FeedPost{
290290+ LexiconTypeID: "app.bsky.feed.post",
291291+ Text: text,
292292+ CreatedAt: now.Format(time.RFC3339),
293293+ // Optional: Add embed with link to AppView
294294+ // Embed: &bsky.FeedPost_Embed{...}
295295+ }
296296+297297+ // Create record with auto-generated TID
298298+ rkey, recordCID, err := p.repomgr.CreateRecord(
299299+ ctx,
300300+ p.uid,
301301+ "app.bsky.feed.post",
302302+ post,
303303+ )
304304+305305+ if err != nil {
306306+ return "", fmt.Errorf("failed to create manifest post: %w", err)
307307+ }
308308+309309+ // Build ATProto URI for the post
310310+ postURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", p.did, rkey)
311311+312312+ log.Printf("Created manifest post: %s (cid: %s)", postURI, recordCID)
313313+314314+ return postURI, nil
315315+}
316316+317317+// formatManifestPostText generates the post text
318318+func formatManifestPostText(repository, tag, userHandle string) string {
319319+ // Example formats:
320320+ // "@alice.bsky.social pushed alice/myapp:latest to ATCR"
321321+ // "New image pushed: alice/myapp:v1.0.0 by @alice.bsky.social"
322322+ // "๐ฆ alice/myapp:latest pushed by @alice.bsky.social"
323323+324324+ return fmt.Sprintf("๐ฆ %s:%s pushed by @%s", repository, tag, userHandle)
325325+}
326326+```
327327+328328+**Advanced Post Options**:
329329+330330+```go
331331+// Example with embedded link to AppView
332332+post := &bsky.FeedPost{
333333+ LexiconTypeID: "app.bsky.feed.post",
334334+ Text: text,
335335+ CreatedAt: now.Format(time.RFC3339),
336336+ Embed: &bsky.FeedPost_Embed{
337337+ FeedPost_External: &bsky.EmbedExternal{
338338+ External: &bsky.EmbedExternal_External{
339339+ Uri: fmt.Sprintf("https://atcr.io/%s", repository),
340340+ Title: fmt.Sprintf("%s:%s", repository, tag),
341341+ Description: "View on ATCR",
342342+ },
343343+ },
344344+ },
345345+}
346346+347347+// Example with facets (mentions)
348348+// This would require parsing the text and creating facet structs
349349+// for @mentions to be clickable in Bluesky
350350+```
351351+352352+### 5. AppView Integration
353353+354354+**File**: `pkg/appview/storage/manifest_store.go`
355355+356356+**Integration Point**: After `client.PutRecord()` succeeds (around line 130-140)
357357+358358+```go
359359+// Existing code:
360360+recordURI, recordCID, err := ms.client.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord)
361361+if err != nil {
362362+ return "", fmt.Errorf("failed to store manifest in PDS: %w", err)
363363+}
364364+365365+// NEW: Notify hold about manifest upload
366366+if err := ms.notifyHoldAboutManifest(ctx, desc, manifestRecord, tag); err != nil {
367367+ // Log error but don't fail the manifest upload
368368+ log.Printf("Failed to notify hold about manifest: %v", err)
369369+}
370370+371371+return desc.Digest.String(), nil
372372+```
373373+374374+**Implementation**:
375375+376376+```go
377377+// notifyHoldAboutManifest sends manifest metadata to the hold
378378+func (ms *ManifestStore) notifyHoldAboutManifest(
379379+ ctx context.Context,
380380+ desc distribution.Descriptor,
381381+ manifestRecord *atproto.ManifestRecord,
382382+ tag string,
383383+) error {
384384+ // 1. Get registry context
385385+ regCtx, err := storage.GetRegistryContext(ctx)
386386+ if err != nil {
387387+ return fmt.Errorf("failed to get registry context: %w", err)
388388+ }
389389+390390+ // 2. Resolve hold DID to endpoint
391391+ holdEndpoint, err := ms.resolver.ResolveDIDToHTTPEndpoint(ctx, manifestRecord.HoldDID)
392392+ if err != nil {
393393+ return fmt.Errorf("failed to resolve hold DID: %w", err)
394394+ }
395395+396396+ // 3. Get service token from user's PDS
397397+ serviceToken, err := regCtx.Refresher.GetServiceToken(ctx, regCtx.DID, manifestRecord.HoldDID)
398398+ if err != nil {
399399+ return fmt.Errorf("failed to get service token: %w", err)
400400+ }
401401+402402+ // 4. Parse manifest to extract layer info
403403+ var parsedManifest struct {
404404+ MediaType string `json:"mediaType"`
405405+ Config distribution.Descriptor `json:"config"`
406406+ Layers []distribution.Descriptor `json:"layers"`
407407+ }
408408+409409+ if err := json.Unmarshal(manifestRecord.ManifestBlob.Data, &parsedManifest); err != nil {
410410+ return fmt.Errorf("failed to parse manifest: %w", err)
411411+ }
412412+413413+ // 5. Build notification request
414414+ notifyReq := map[string]interface{}{
415415+ "repository": ms.repository,
416416+ "tag": tag,
417417+ "userDid": regCtx.DID,
418418+ "userHandle": regCtx.Handle, // Need to add this to RegistryContext
419419+ "manifest": map[string]interface{}{
420420+ "mediaType": parsedManifest.MediaType,
421421+ "config": map[string]interface{}{
422422+ "digest": parsedManifest.Config.Digest.String(),
423423+ "size": parsedManifest.Config.Size,
424424+ },
425425+ "layers": func() []map[string]interface{} {
426426+ layers := make([]map[string]interface{}, len(parsedManifest.Layers))
427427+ for i, layer := range parsedManifest.Layers {
428428+ layers[i] = map[string]interface{}{
429429+ "digest": layer.Digest.String(),
430430+ "size": layer.Size,
431431+ "mediaType": layer.MediaType,
432432+ }
433433+ }
434434+ return layers
435435+ }(),
436436+ },
437437+ }
438438+439439+ // 6. Call hold's XRPC endpoint
440440+ reqBody, _ := json.Marshal(notifyReq)
441441+ req, err := http.NewRequestWithContext(
442442+ ctx,
443443+ "POST",
444444+ holdEndpoint+"/xrpc/io.atcr.hold.notifyManifest",
445445+ bytes.NewReader(reqBody),
446446+ )
447447+ if err != nil {
448448+ return err
449449+ }
450450+451451+ req.Header.Set("Content-Type", "application/json")
452452+ req.Header.Set("Authorization", "Bearer "+serviceToken)
453453+454454+ resp, err := http.DefaultClient.Do(req)
455455+ if err != nil {
456456+ return err
457457+ }
458458+ defer resp.Body.Close()
459459+460460+ if resp.StatusCode != http.StatusOK {
461461+ body, _ := io.ReadAll(resp.Body)
462462+ return fmt.Errorf("hold notification failed: %s (status: %d)", body, resp.StatusCode)
463463+ }
464464+465465+ // 7. Parse response (optional logging)
466466+ var notifyResp map[string]interface{}
467467+ if err := json.NewDecoder(resp.Body).Decode(¬ifyResp); err == nil {
468468+ log.Printf("Hold notification successful: %+v", notifyResp)
469469+ }
470470+471471+ return nil
472472+}
473473+```
474474+475475+### 6. Record Type Registration
476476+477477+**File**: `pkg/hold/pds/server.go`
478478+479479+**In `init()` function** (around line 30):
480480+481481+```go
482482+func init() {
483483+ // Existing registrations
484484+ lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{})
485485+ lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{})
486486+ lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
487487+488488+ // NEW: Register layer record type
489489+ lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
490490+}
491491+```
492492+493493+**Why needed**: ATProto's CBOR unmarshaling requires type registration to automatically deserialize records when reading from the carstore.
494494+495495+## Testing Strategy
496496+497497+### Unit Tests
498498+499499+**Test Layer Record Creation** (`pkg/hold/pds/layer_test.go`):
500500+```go
501501+func TestCreateLayerRecord(t *testing.T) {
502502+ pds := setupTestPDS(t)
503503+ ctx := context.Background()
504504+505505+ record := atproto.NewLayerRecord(
506506+ "sha256:abc123",
507507+ 1024,
508508+ "application/vnd.docker.image.rootfs.diff.tar.gzip",
509509+ "alice/myapp",
510510+ "did:plc:alice123",
511511+ "alice.bsky.social",
512512+ )
513513+514514+ rkey, cid, err := pds.CreateLayerRecord(ctx, record)
515515+ assert.NoError(t, err)
516516+ assert.NotEmpty(t, rkey)
517517+ assert.NotEmpty(t, cid)
518518+519519+ // Verify record was stored
520520+ retrieved, err := pds.GetLayerRecord(ctx, rkey)
521521+ assert.NoError(t, err)
522522+ assert.Equal(t, record.Digest, retrieved.Digest)
523523+}
524524+```
525525+526526+**Test Manifest Post Creation** (`pkg/hold/pds/manifest_post_test.go`):
527527+```go
528528+func TestCreateManifestPost(t *testing.T) {
529529+ pds := setupTestPDS(t)
530530+ ctx := context.Background()
531531+532532+ postURI, err := pds.CreateManifestPost(ctx, "alice/myapp", "latest", "alice.bsky.social")
533533+ assert.NoError(t, err)
534534+ assert.Contains(t, postURI, "app.bsky.feed.post")
535535+536536+ // Parse URI and verify post exists
537537+ // at://did:web:hold01.atcr.io/app.bsky.feed.post/{rkey}
538538+}
539539+```
540540+541541+**Test XRPC Endpoint** (`pkg/hold/oci/xrpc_test.go`):
542542+```go
543543+func TestHandleNotifyManifest(t *testing.T) {
544544+ handler := setupTestHandler(t)
545545+546546+ req := NotifyManifestRequest{
547547+ Repository: "alice/myapp",
548548+ Tag: "latest",
549549+ UserDID: "did:plc:alice123",
550550+ UserHandle: "alice.bsky.social",
551551+ Manifest: /* ... */,
552552+ }
553553+554554+ // Make HTTP request with service token
555555+ resp := makeRequest(t, handler, req, validServiceToken)
556556+557557+ assert.Equal(t, http.StatusOK, resp.StatusCode)
558558+559559+ var result NotifyManifestResponse
560560+ json.NewDecoder(resp.Body).Decode(&result)
561561+562562+ assert.True(t, result.Success)
563563+ assert.Equal(t, 3, result.LayersCreated) // if manifest has 3 layers
564564+ assert.True(t, result.PostCreated)
565565+}
566566+```
567567+568568+### Integration Tests
569569+570570+**End-to-End Test**:
571571+1. Push a test image to ATCR
572572+2. Verify manifest is stored in user's PDS
573573+3. Verify layer records are created in hold's PDS
574574+4. Verify Bluesky post is created in hold's PDS
575575+5. Query ATProto endpoints to retrieve records
576576+577577+## Error Handling
578578+579579+### AppView Side
580580+581581+**Notification failures should NOT break manifest uploads**:
582582+- If hold is unreachable: Log error, continue
583583+- If service token fails: Log error, continue
584584+- If hold returns error: Log error, continue
585585+586586+**Rationale**: Bluesky posts are a "nice to have" feature, not critical infrastructure. Image pushes must succeed even if social features fail.
587587+588588+### Hold Side
589589+590590+**Partial failures are acceptable**:
591591+- If some layer records fail: Create what we can, return partial success
592592+- If Bluesky post fails but layers succeed: Return success with `postCreated: false`
593593+- If all operations fail: Return error response
594594+595595+**Logging**:
596596+- Log all errors for debugging
597597+- Include user DID, repository, and error details
598598+- Use structured logging for easy querying
599599+600600+## Configuration
601601+602602+### Environment Variables
603603+604604+**Hold Service** (`.env.hold.example`):
605605+```bash
606606+# Enable/disable Bluesky posting
607607+HOLD_BLUESKY_POSTS_ENABLED=true
608608+609609+# Enable/disable layer record creation
610610+HOLD_LAYER_RECORDS_ENABLED=true
611611+```
612612+613613+**AppView** (`.env.appview.example`):
614614+```bash
615615+# Enable/disable manifest notifications to holds
616616+ATCR_NOTIFY_HOLDS_ENABLED=true
617617+```
618618+619619+### Feature Flags
620620+621621+Consider making this feature opt-in initially:
622622+- Add flag to captain record: `enableSocialPosts bool`
623623+- Check flag before creating posts
624624+- Allow hold owners to disable social features
625625+626626+## Performance Considerations
627627+628628+### Database Impact
629629+630630+**Layer records**: Each manifest upload creates N records (where N = number of layers)
631631+- Typical image: 5-10 layers
632632+- Large image: 50+ layers
633633+- Storage: ~500 bytes per record (CBOR compressed)
634634+635635+**Bluesky posts**: One post per manifest
636636+- Storage: ~200 bytes per post
637637+- Indexed by creation time for feed queries
638638+639639+**Carstore growth**: Estimate ~5KB per manifest upload (records + post)
640640+641641+### Network Impact
642642+643643+**AppView โ Hold notification**:
644644+- One HTTP POST per manifest upload
645645+- Payload size: ~2-10KB (depends on layer count)
646646+- Should complete in <100ms on local network
647647+648648+**Service token requests**:
649649+- Tokens cached for 50 seconds
650650+- Minimal overhead if pushing multiple manifests quickly
651651+652652+### Optimization Opportunities
653653+654654+1. **Batch layer record creation**: Use `BatchWrite` for multiple records
655655+2. **Async processing**: Queue notifications and process in background
656656+3. **Rate limiting**: Limit posts per user/hold to prevent spam
657657+4. **Deduplication**: Skip layer records for already-seen digests
658658+659659+## Future Enhancements
660660+661661+### Phase 2: Enhanced Posts
662662+663663+**Rich embeds**:
664664+- Link preview to AppView repository page
665665+- Thumbnail image from first layer
666666+- Metadata badges (image size, layer count, tags)
667667+668668+**Mentions**:
669669+- Parse user handle and create Bluesky facets for @mentions
670670+- Enable clickable mentions in posts
671671+672672+**Tags/hashtags**:
673673+- Add `#container`, `#docker`, repository tags
674674+- Improve discoverability in Bluesky
675675+676676+### Phase 3: Feed Customization
677677+678678+**Hold-specific feeds**:
679679+- Query layer records by repository
680680+- Filter by user DID
681681+- Time-based queries
682682+683683+**ATProto feed generator**:
684684+- Implement `app.bsky.feed.getFeedSkeleton` XRPC endpoint
685685+- Publish hold's feed to Bluesky
686686+- Users can subscribe to hold activity feeds
687687+688688+### Phase 4: Analytics
689689+690690+**Track metrics**:
691691+- Posts per day/week/month
692692+- Most active users
693693+- Most popular repositories
694694+- Storage growth over time
695695+696696+**Dashboards**:
697697+- Visualize activity on AppView UI
698698+- Show trending images
699699+- Leaderboards for most pushed repositories
700700+701701+## Security Considerations
702702+703703+### Authentication
704704+705705+**Service tokens**:
706706+- Validate tokens against user's PDS
707707+- Verify DID matches in token claims
708708+- Check token expiration (60s from PDS)
709709+710710+**Authorization**:
711711+- Only authenticated users can trigger posts
712712+- Posts created under hold's DID (not user's DID)
713713+- User information is metadata in post text
714714+715715+### Privacy
716716+717717+**User handles**:
718718+- Posts include user handle (`@alice.bsky.social`)
719719+- Consider opt-out mechanism for privacy-conscious users
720720+721721+**Repository names**:
722722+- Public information (already visible in AppView)
723723+- Consider private repository flags in future
724724+725725+### Rate Limiting
726726+727727+**Prevent spam**:
728728+- Limit posts per user per hour
729729+- Detect rapid-fire pushes (CI/CD)
730730+- Consider aggregating multiple pushes into single post
731731+732732+**Resource protection**:
733733+- Limit layer record creation to prevent storage exhaustion
734734+- Cap manifest notification payload size
735735+- Timeout long-running operations
736736+737737+## Monitoring and Observability
738738+739739+### Metrics to Track
740740+741741+**AppView**:
742742+- `atcr_hold_notifications_total` - Counter of notifications sent
743743+- `atcr_hold_notifications_errors` - Counter of failures
744744+- `atcr_hold_notification_duration_ms` - Histogram of latency
745745+746746+**Hold**:
747747+- `hold_layer_records_created_total` - Counter of layer records
748748+- `hold_bluesky_posts_created_total` - Counter of posts
749749+- `hold_manifest_notifications_received_total` - Counter of incoming notifications
750750+- `hold_notification_errors_total` - Counter of errors by type
751751+752752+### Logging
753753+754754+**Structured logs**:
755755+```json
756756+{
757757+ "level": "info",
758758+ "msg": "manifest notification received",
759759+ "repository": "alice/myapp",
760760+ "tag": "latest",
761761+ "userDid": "did:plc:alice123",
762762+ "layerCount": 5,
763763+ "layersCreated": 5,
764764+ "postCreated": true,
765765+ "duration_ms": 45
766766+}
767767+```
768768+769769+### Alerts
770770+771771+**Critical issues**:
772772+- High error rate (>10% failures)
773773+- Service token failures (auth issues)
774774+- PDS carstore errors (database problems)
775775+776776+**Warning issues**:
777777+- Slow notifications (>1s latency)
778778+- Partial failures (some layers not created)
779779+- Missing user handle in context
780780+781781+## Migration Strategy
782782+783783+### Rollout Plan
784784+785785+**Phase 1: Development**
786786+- Implement core functionality
787787+- Add comprehensive tests
788788+- Deploy to staging environment
789789+790790+**Phase 2: Beta**
791791+- Enable for test holds only
792792+- Gather feedback from early users
793793+- Monitor performance and errors
794794+795795+**Phase 3: Opt-in**
796796+- Add configuration flags
797797+- Allow hold owners to enable feature
798798+- Document setup process
799799+800800+**Phase 4: Default On**
801801+- Enable by default for new holds
802802+- Migrate existing holds (opt-out available)
803803+- Announce feature publicly
804804+805805+### Backward Compatibility
806806+807807+**No breaking changes**:
808808+- New XRPC endpoint (doesn't affect existing endpoints)
809809+- New record types (isolated collections)
810810+- Optional feature (can be disabled)
811811+812812+**Existing holds**:
813813+- Work without changes
814814+- Can opt-in by updating hold service
815815+- No data migration required
816816+817817+## Example Post Formats
818818+819819+### Simple Format
820820+```
821821+๐ฆ alice/myapp:latest pushed by @alice.bsky.social
822822+```
823823+824824+### Detailed Format
825825+```
826826+๐ฆ New container image pushed!
827827+828828+alice/myapp:v1.2.3
829829+Pushed by @alice.bsky.social
830830+5 layers, 125 MB total
831831+832832+View: https://atcr.io/alice/myapp
833833+```
834834+835835+### With Emoji/Styling
836836+```
837837+๐ alice/myapp:latest
838838+839839+โ 5 layers
840840+๐ฆ 125.4 MB
841841+๐ค @alice.bsky.social
842842+๐ atcr.io/alice/myapp
843843+```
844844+845845+### With Tags
846846+```
847847+๐ฆ alice/myapp:latest pushed by @alice.bsky.social
848848+849849+#container #docker #atcr
850850+```
851851+852852+## References
853853+854854+### Related Code
855855+856856+- Existing Bluesky post implementation: `pkg/hold/pds/status.go`
857857+- XRPC endpoint pattern: `pkg/hold/oci/xrpc.go`
858858+- Record type definitions: `pkg/atproto/lexicon.go`
859859+- Manifest storage: `pkg/appview/storage/manifest_store.go`
860860+- Service token handling: `pkg/auth/oauth/refresher.go`
861861+862862+### External Documentation
863863+864864+- ATProto Record Schema: https://atproto.com/specs/record-key
865865+- Bluesky Post Lexicon: https://atproto.com/lexicons/app-bsky-feed#appbskyfeedpost
866866+- CBOR Encoding: https://cbor.io/
867867+- Bluesky Facets (mentions/links): https://atproto.com/specs/richtext
868868+869869+### Tools
870870+871871+- CBOR code generation: `github.com/whyrusleeping/cbor-gen`
872872+- ATProto libraries: `github.com/bluesky-social/indigo`
873873+- Testing: Standard Go testing + `testify/assert`