···1111 status: Option<&ActorStatus>,
1212 sync_state: &ActorSyncState,
1313 handle: Option<&str>,
1414+ account_created_at: Option<&DateTime<Utc>>,
1415 time: DateTime<Utc>,
1516) -> Result<u64> {
1617 // Allow allowlist states (synced, dirty, processing) to flow freely
1718 // Allow upgrading from partial to allowlist states
1819 // Never downgrade from allowlist states to partial
2020+ //
2121+ // Account created_at is updated if provided and current value is NULL
2222+ // This allows enrichment during handle resolution without overwriting existing values
19232020- match (status, handle) {
2121- (Some(status), Some(handle)) => {
2222- // Both status and handle provided
2424+ match (status, handle, account_created_at) {
2525+ (Some(status), Some(handle), Some(created_at)) => {
2626+ // All three provided
2727+ conn.execute(
2828+ "INSERT INTO actors (did, status, handle, sync_state, account_created_at, last_indexed) VALUES ($1, $2, $3, $4, $5, $6)
2929+ ON CONFLICT (did) DO UPDATE SET
3030+ status=EXCLUDED.status,
3131+ handle=EXCLUDED.handle,
3232+ sync_state=CASE
3333+ WHEN actors.sync_state IN ('synced', 'dirty', 'processing') AND EXCLUDED.sync_state = 'partial'
3434+ THEN actors.sync_state
3535+ ELSE EXCLUDED.sync_state
3636+ END,
3737+ account_created_at=COALESCE(actors.account_created_at, EXCLUDED.account_created_at),
3838+ last_indexed=EXCLUDED.last_indexed",
3939+ &[&did, &status, &handle, &sync_state, &created_at, &time],
4040+ )
4141+ .await
4242+ }
4343+ (Some(status), Some(handle), None) => {
4444+ // Status and handle, no created_at
2345 conn.execute(
2446 "INSERT INTO actors (did, status, handle, sync_state, last_indexed) VALUES ($1, $2, $3, $4, $5)
2547 ON CONFLICT (did) DO UPDATE SET
···3557 )
3658 .await
3759 }
3838- (Some(status), None) => {
6060+ (Some(status), None, Some(created_at)) => {
6161+ // Status and created_at, no handle
6262+ conn.execute(
6363+ "INSERT INTO actors (did, status, sync_state, account_created_at, last_indexed) VALUES ($1, $2, $3, $4, $5)
6464+ ON CONFLICT (did) DO UPDATE SET
6565+ status=EXCLUDED.status,
6666+ sync_state=CASE
6767+ WHEN actors.sync_state IN ('synced', 'dirty', 'processing') AND EXCLUDED.sync_state = 'partial'
6868+ THEN actors.sync_state
6969+ ELSE EXCLUDED.sync_state
7070+ END,
7171+ account_created_at=COALESCE(actors.account_created_at, EXCLUDED.account_created_at),
7272+ last_indexed=EXCLUDED.last_indexed",
7373+ &[&did, &status, &sync_state, &created_at, &time],
7474+ )
7575+ .await
7676+ }
7777+ (Some(status), None, None) => {
3978 // Only status provided
4079 conn.execute(
4180 "INSERT INTO actors (did, status, sync_state, last_indexed) VALUES ($1, $2, $3, $4)
···5190 )
5291 .await
5392 }
5454- (None, Some(handle)) => {
9393+ (None, Some(handle), Some(created_at)) => {
9494+ // Handle and created_at, no status
9595+ conn.execute(
9696+ "INSERT INTO actors (did, handle, sync_state, account_created_at, last_indexed) VALUES ($1, $2, $3, $4, $5)
9797+ ON CONFLICT (did) DO UPDATE SET
9898+ handle=EXCLUDED.handle,
9999+ sync_state=CASE
100100+ WHEN actors.sync_state IN ('synced', 'dirty', 'processing') AND EXCLUDED.sync_state = 'partial'
101101+ THEN actors.sync_state
102102+ ELSE EXCLUDED.sync_state
103103+ END,
104104+ account_created_at=COALESCE(actors.account_created_at, EXCLUDED.account_created_at),
105105+ last_indexed=EXCLUDED.last_indexed",
106106+ &[&did, &handle, &sync_state, &created_at, &time],
107107+ )
108108+ .await
109109+ }
110110+ (None, Some(handle), None) => {
55111 // Only handle provided
56112 conn.execute(
57113 "INSERT INTO actors (did, handle, sync_state, last_indexed) VALUES ($1, $2, $3, $4)
···67123 )
68124 .await
69125 }
7070- (None, None) => {
126126+ (None, None, Some(created_at)) => {
127127+ // Only created_at provided
128128+ conn.execute(
129129+ "INSERT INTO actors (did, sync_state, account_created_at, last_indexed) VALUES ($1, $2, $3, $4)
130130+ ON CONFLICT (did) DO UPDATE SET
131131+ sync_state=CASE
132132+ WHEN actors.sync_state IN ('synced', 'dirty', 'processing') AND EXCLUDED.sync_state = 'partial'
133133+ THEN actors.sync_state
134134+ ELSE EXCLUDED.sync_state
135135+ END,
136136+ account_created_at=COALESCE(actors.account_created_at, EXCLUDED.account_created_at),
137137+ last_indexed=EXCLUDED.last_indexed",
138138+ &[&did, &sync_state, &created_at, &time],
139139+ )
140140+ .await
141141+ }
142142+ (None, None, None) => {
71143 // Neither provided - just ensure actor exists with sync_state
72144 conn.execute(
73145 "INSERT INTO actors (did, sync_state, last_indexed) VALUES ($1, $2, $3)
+1
consumer/src/db/allowlist.rs
···172172 Some(&ActorStatus::Active),
173173 &ActorSyncState::Dirty,
174174 None, // no handle
175175+ None, // no account_created_at (enriched during handle resolution)
175176 now,
176177 )
177178 .await
+46-7
consumer/src/workers/handle_resolver.rs
···118118 let cache_key = format!("did_handle:{}", did);
119119 let cached_handle: Option<String> = self.redis.get(&cache_key).await?;
120120121121- let handle = if let Some(cached) = cached_handle {
121121+ let (handle, account_created_at) = if let Some(cached) = cached_handle {
122122 counter!("handle_resolver.cache_hit").increment(1);
123123- Some(cached)
123123+ // Cache hit - we have the handle but not the account creation time
124124+ // Fetch account creation time separately (not cached, but only fetched once per actor typically)
125125+ let created_at = match self.resolver.get_plc_creation_time(did).await {
126126+ Ok(Some(timestamp)) => {
127127+ counter!("handle_resolver.plc_creation_fetch_success").increment(1);
128128+ Some(timestamp)
129129+ }
130130+ Ok(None) => {
131131+ debug!("No PLC creation time found for {}", did);
132132+ counter!("handle_resolver.plc_creation_not_found").increment(1);
133133+ None
134134+ }
135135+ Err(e) => {
136136+ debug!("Failed to fetch PLC creation time for {}: {}", did, e);
137137+ counter!("handle_resolver.plc_creation_error").increment(1);
138138+ None
139139+ }
140140+ };
141141+ (Some(cached), created_at)
124142 } else {
125143 // 2. Cache miss - resolve from PLC directory
126144 counter!("handle_resolver.cache_miss").increment(1);
127145128128- match self.resolver.resolve_did(did).await {
146146+ let (handle, created_at) = match self.resolver.resolve_did(did).await {
129147 Ok(Some(doc)) => {
130148 // Extract handle from DID document's alsoKnownAs field
131149 let handle = doc
···148166 counter!("handle_resolver.no_handle").increment(1);
149167 }
150168151151- handle
169169+ // Fetch account creation time from PLC audit log
170170+ let created_at = match self.resolver.get_plc_creation_time(did).await {
171171+ Ok(Some(timestamp)) => {
172172+ counter!("handle_resolver.plc_creation_fetch_success").increment(1);
173173+ Some(timestamp)
174174+ }
175175+ Ok(None) => {
176176+ debug!("No PLC creation time found for {}", did);
177177+ counter!("handle_resolver.plc_creation_not_found").increment(1);
178178+ None
179179+ }
180180+ Err(e) => {
181181+ debug!("Failed to fetch PLC creation time for {}: {}", did, e);
182182+ counter!("handle_resolver.plc_creation_error").increment(1);
183183+ None
184184+ }
185185+ };
186186+187187+ (handle, created_at)
152188 }
153189 Ok(None) => {
154190 debug!("DID document not found for {}", did);
155191 counter!("handle_resolver.did_not_found").increment(1);
156156- None
192192+ (None, None)
157193 }
158194 Err(e) => {
159195 debug!("Failed to resolve DID for {}: {}", did, e);
160196 counter!("handle_resolver.did_resolution_error").increment(1);
161161- None
197197+ (None, None)
162198 }
163163- }
199199+ };
200200+201201+ (handle, created_at)
164202 };
165203166204 // 4. Send handle update operation to batch writer (bounded channel with backpressure)
···172210 status: None,
173211 sync_state: parakeet_db::types::ActorSyncState::Partial,
174212 handle,
213213+ account_created_at,
175214 timestamp: chrono::Utc::now(),
176215 }],
177216 cache_invalidations: vec![],
+2
consumer/src/workers/jetstream/processing.rs
···202202 status: None,
203203 sync_state,
204204 handle, // Pass the Option<String> directly
205205+ account_created_at: None, // Jetstream doesn't provide creation time, enriched during handle resolution
205206 timestamp,
206207 }];
207208···244245 status: Some(status),
245246 sync_state,
246247 handle: None,
248248+ account_created_at: None, // Jetstream doesn't provide creation time, enriched during handle resolution
247249 timestamp,
248250 }];
249251
+1
did-resolver/Cargo.toml
···44edition = "2021"
5566[dependencies]
77+chrono = { version = "0.4", features = ["serde"] }
78hickory-resolver = "0.24.2"
89reqwest = { version = "0.12.12", features = ["json", "native-tls"] }
910serde = { version = "1.0.217", features = ["derive"] }
+34
did-resolver/src/lib.rs
···7979 Ok(Some(did_doc))
8080 }
81818282+ /// Get account creation timestamp from PLC audit log
8383+ /// Returns the timestamp of the first operation (where prev is null)
8484+ /// Only works for did:plc DIDs - returns None for other DID methods
8585+ pub async fn get_plc_creation_time(
8686+ &self,
8787+ did: &str,
8888+ ) -> Result<Option<chrono::DateTime<chrono::Utc>>, Error> {
8989+ // Only fetch for did:plc
9090+ if !did.starts_with("did:plc:") {
9191+ return Ok(None);
9292+ }
9393+9494+ let res = self
9595+ .client
9696+ .get(format!("{}/{did}/log/audit", self.plc))
9797+ .send()
9898+ .await?;
9999+100100+ let status = res.status();
101101+102102+ if status.is_server_error() {
103103+ return Err(Error::ServerError);
104104+ }
105105+106106+ if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
107107+ return Ok(None);
108108+ }
109109+110110+ let audit_log: Vec<types::PlcAuditLogEntry> = res.json().await?;
111111+112112+ // First entry in the audit log is the account creation
113113+ Ok(audit_log.first().map(|entry| entry.created_at))
114114+ }
115115+82116 async fn resolve_did_web(&self, id: &str) -> Result<Option<types::DidDocument>, Error> {
83117 let res = match self
84118 .client
+8
did-resolver/src/types.rs
···2233#[derive(Debug, Deserialize, Serialize)]
44#[serde(rename_all = "camelCase")]
55+pub struct PlcAuditLogEntry {
66+ pub did: String,
77+ pub created_at: chrono::DateTime<chrono::Utc>,
88+ // Other fields exist but we only need createdAt
99+}
1010+1111+#[derive(Debug, Deserialize, Serialize)]
1212+#[serde(rename_all = "camelCase")]
513pub struct DidDocument {
614 #[serde(default, rename = "@context")]
715 pub context: Vec<String>,
···11+-- Remove account_created_at column from actors table
22+DROP INDEX IF EXISTS idx_actors_account_created_at;
33+ALTER TABLE actors DROP COLUMN account_created_at;
···11+-- Add account_created_at column to actors table
22+-- This represents when the account was created (from PLC directory first operation)
33+-- Nullable because we may not know it for all actors initially (especially stubs)
44+-- Will be populated during handle resolution
55+ALTER TABLE actors ADD COLUMN account_created_at TIMESTAMP WITH TIME ZONE;
66+77+-- Create index for potential queries by account age
88+CREATE INDEX idx_actors_account_created_at ON actors(account_created_at) WHERE account_created_at IS NOT NULL;
···115115}
116116117117pub(super) fn build_basic(
118118- (did, handle, profile, chat_decl, is_labeler, status, notif_decl): ProfileLoaderRet,
118118+ (did, handle, account_created_at, profile, chat_decl, is_labeler, status, notif_decl): ProfileLoaderRet,
119119 stats: Option<ProfileStats>,
120120 labels: Vec<models::Label>,
121121 verifications: Option<Vec<crate::loaders::EnrichedVerification>>,
···141141 })
142142 .unwrap_or_else(|| (None, None, None));
143143144144- // Use profile.created_at if available, fall back to Utc::now()
145145- let created_at = profile.as_ref().map(|p| p.created_at).unwrap_or_else(Utc::now);
144144+ // Use actor.account_created_at if available (from PLC directory), fall back to Utc::now()
145145+ // This is the actual account creation time, not when the profile record was created
146146+ let created_at = account_created_at.unwrap_or_else(Utc::now);
146147147148 ProfileViewBasic {
148149 did,
···160161}
161162162163pub(super) fn build_profile(
163163- (did, handle, profile, chat_decl, is_labeler, status, notif_decl): ProfileLoaderRet,
164164+ (did, handle, account_created_at, profile, chat_decl, is_labeler, status, notif_decl): ProfileLoaderRet,
164165 stats: Option<ProfileStats>,
165166 labels: Vec<models::Label>,
166167 verifications: Option<Vec<crate::loaders::EnrichedVerification>>,
···187188 })
188189 .unwrap_or_else(|| (None, None, None, None));
189190190190- // Use profile.created_at for both createdAt and indexedAt (if profile exists)
191191- // Fall back to Utc::now() if no profile record exists yet
192192- let created_at = profile.as_ref().map(|p| p.created_at).unwrap_or_else(Utc::now);
191191+ // Use actor.account_created_at for both createdAt and indexedAt
192192+ // Fall back to Utc::now() if account creation time not available
193193+ let created_at = account_created_at.unwrap_or_else(Utc::now);
193194194195 ProfileView {
195196 did,
···209210}
210211211212pub(super) fn build_detailed(
212212- (did, handle, profile, chat_decl, is_labeler, status, notif_decl): ProfileLoaderRet,
213213+ (did, handle, account_created_at, profile, chat_decl, is_labeler, status, notif_decl): ProfileLoaderRet,
213214 stats: Option<ProfileStats>,
214215 labels: Vec<models::Label>,
215216 verifications: Option<Vec<crate::loaders::EnrichedVerification>>,
···241242 })
242243 .unwrap_or_else(|| (None, None, None, None, None, None));
243244244244- // Use profile.created_at for both createdAt and indexedAt (if profile exists)
245245- // Fall back to Utc::now() if no profile record exists yet
246246- let created_at = profile.as_ref().map(|p| p.created_at).unwrap_or_else(Utc::now);
245245+ // Use actor.account_created_at for both createdAt and indexedAt
246246+ // Fall back to Utc::now() if account creation time not available
247247+ let created_at = account_created_at.unwrap_or_else(Utc::now);
247248248249 ProfileViewDetailed {
249250 did,