···736736}
737737```
738738739739+### Empty Union (No Refs)
740740+741741+**Pattern:** Use `never | unknown` for unions with no model references
742742+743743+In rare cases, you need an empty union that accepts any type with a `$type` discriminator but has no known refs. Use the `never | unknown` pattern:
744744+745745+**TypeSpec:**
746746+```typespec
747747+model SubjectView {
748748+ @required type: string;
749749+ @required subject: string;
750750+ profile?: never | unknown; // Empty union
751751+}
752752+```
753753+754754+**JSON:**
755755+```json
756756+{
757757+ "profile": {
758758+ "type": "union",
759759+ "refs": []
760760+ }
761761+}
762762+```
763763+764764+**Why `never | unknown`?**
765765+- `never` creates a union type in TypeSpec (single `unknown` is just intrinsic, not a union)
766766+- `never` contributes no refs (it's an empty type)
767767+- `unknown` marks the union as open/extensible
768768+- Result: empty refs array with open union semantics
769769+770770+**When to use:** Fields that may contain any discriminated type in the future but have no current known types.
771771+739772### Syntax Summary
740773741774| Pattern | Syntax | JSON `closed` field | Use Case |
742775|---------|--------|---------------------|----------|
743776| **Open union (inline)** | `(A \| B \| unknown)` | omitted (false) | Default - allows future variants |
744777| **Open union (named)** | `union { A, B, unknown }` | omitted (false) | Named def, reusable |
778778+| **Empty union** | `never \| unknown` | omitted (false) | No current refs, open to future types |
745779| **Closed union (named)** | `@closed union { A, B }` | `true` | Fixed set, named def |
746780| **Closed union (inline)** | `Closed<A \| B>` | `true` | Fixed set, inline usage |
747781| **Single reference** | `SomeType` | N/A (not a union) | Exactly one type, no variants |
···412412 return this.createStringEnumDef(unionType, variants.stringLiterals, prop);
413413 }
414414415415- // Case 2: Model reference union
416416- if (variants.unionRefs.length > 0) {
415415+ // Case 2: Model reference union (including empty union with unknown)
416416+ if (variants.unionRefs.length > 0 || variants.hasUnknown) {
417417 return this.createUnionRefDef(unionType, variants, prop);
418418 }
419419420420- // Case 3: Empty or invalid union
421421- if (variants.stringLiterals.length === 0 && !variants.hasUnknown) {
420420+ // Case 3: Empty union without unknown
421421+ if (variants.stringLiterals.length === 0) {
422422 this.program.reportDiagnostic({
423423 code: "union-empty",
424424 severity: "error",
···3232 labels?: com.atproto.label.defs.Label[];
33333434 @doc("The set of report reason 'codes' which are in-scope for this service to review and action. These usually align to policy categories. If not defined (distinct from empty array), all reason types are allowed.")
3535- reasonTypes?: com.atproto.moderation.defs.reasonType[];
3535+ reasonTypes?: com.atproto.moderation.defs.ReasonType[];
36363737 @doc("The set of subject types (account, record, etc) this service accepts reports on.")
3838- subjectTypes?: com.atproto.moderation.defs.subjectType[];
3838+ subjectTypes?: com.atproto.moderation.defs.SubjectType[];
39394040 @doc("Set of record types (collection NSIDs) which can be reported to this service. If not defined (distinct from empty array), default is any record type.")
4141 subjectCollections?: nsid[];
···1111 @required createdAt: datetime;
12121313 @doc("The set of report reason 'codes' which are in-scope for this service to review and action. These usually align to policy categories. If not defined (distinct from empty array), all reason types are allowed.")
1414- reasonTypes?: com.atproto.moderation.defs.reasonType[];
1414+ reasonTypes?: com.atproto.moderation.defs.ReasonType[];
15151616 @doc("The set of subject types (account, record, etc) this service accepts reports on.")
1717- subjectTypes?: com.atproto.moderation.defs.subjectType[];
1717+ subjectTypes?: com.atproto.moderation.defs.SubjectType[];
18181919 @doc("Set of record types (collection NSIDs) which can be reported to this service. If not defined (distinct from empty array), default is any record type.")
2020 subjectCollections?: nsid[];
···11+import "@tlex/emitter";
22+33+namespace app.bsky.unspecced.searchActorsSkeleton {
44+ model BadQueryString {}
55+66+ @doc("Backend Actors (profile) search, returns only skeleton.")
77+ @query
88+ @errors(BadQueryString)
99+ op main(
1010+ @doc("Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. For typeahead search, only simple term match is supported, not full syntax.")
1111+ q: string,
1212+1313+ @doc("DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.")
1414+ viewer?: did,
1515+1616+ @doc("If true, acts as fast/simple 'typeahead' query.")
1717+ typeahead?: boolean,
1818+1919+ @minValue(1)
2020+ @maxValue(100)
2121+ limit?: integer = 25,
2222+2323+ @doc("Optional pagination mechanism; may not necessarily allow scrolling through entire result set.")
2424+ cursor?: string
2525+ ): {
2626+ cursor?: string;
2727+2828+ @doc("Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.")
2929+ hitsTotal?: integer;
3030+3131+ @required actors: app.bsky.unspecced.defs.SkeletonSearchActor[];
3232+ };
3333+}
···11+import "@tlex/emitter";
22+33+@maxLength(640)
44+@maxGraphemes(64)
55+scalar SkeletonSearchTagString extends string;
66+77+namespace app.bsky.unspecced.searchPostsSkeleton {
88+ model BadQueryString {}
99+1010+ @doc("Backend Posts search, returns only skeleton")
1111+ @query
1212+ @errors(BadQueryString)
1313+ op main(
1414+ @doc("Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.")
1515+ q: string,
1616+1717+ @doc("Specifies the ranking order of results.")
1818+ sort?: "top" | "latest" | string = "latest",
1919+2020+ @doc("Filter results for posts after the indicated datetime (inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYYY-MM-DD).")
2121+ since?: string,
2222+2323+ @doc("Filter results for posts before the indicated datetime (not inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYY-MM-DD).")
2424+ until?: string,
2525+2626+ @doc("Filter to posts which mention the given account. Handles are resolved to DID before query-time. Only matches rich-text facet mentions.")
2727+ mentions?: atIdentifier,
2828+2929+ @doc("Filter to posts by the given account. Handles are resolved to DID before query-time.")
3030+ author?: atIdentifier,
3131+3232+ @doc("Filter to posts in the given language. Expected to be based on post language field, though server may override language detection.")
3333+ lang?: language,
3434+3535+ @doc("Filter to posts with URLs (facet links or embeds) linking to the given domain (hostname). Server may apply hostname normalization.")
3636+ domain?: string,
3737+3838+ @doc("Filter to posts with links (facet links or embeds) pointing to this URL. Server may apply URL normalization or fuzzy matching.")
3939+ url?: uri,
4040+4141+ @doc("Filter to posts with the given tag (hashtag), based on rich-text facet or tag field. Do not include the hash (#) prefix. Multiple tags can be specified, with 'AND' matching.")
4242+ tag?: SkeletonSearchTagString[],
4343+4444+ @doc("DID of the account making the request (not included for public/unauthenticated queries). Used for 'from:me' queries.")
4545+ viewer?: did,
4646+4747+ @minValue(1)
4848+ @maxValue(100)
4949+ limit?: integer = 25,
5050+5151+ @doc("Optional pagination mechanism; may not necessarily allow scrolling through entire result set.")
5252+ cursor?: string
5353+ ): {
5454+ cursor?: string;
5555+5656+ @doc("Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.")
5757+ hitsTotal?: integer;
5858+5959+ @required posts: app.bsky.unspecced.defs.SkeletonSearchPost[];
6060+ };
6161+}
···11+import "@tlex/emitter";
22+33+namespace app.bsky.unspecced.searchStarterPacksSkeleton {
44+ model BadQueryString {}
55+66+ @doc("Backend Starter Pack search, returns only skeleton.")
77+ @query
88+ @errors(BadQueryString)
99+ op main(
1010+ @doc("Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.")
1111+ q: string,
1212+1313+ @doc("DID of the account making the request (not included for public/unauthenticated queries).")
1414+ viewer?: did,
1515+1616+ @minValue(1)
1717+ @maxValue(100)
1818+ limit?: integer = 25,
1919+2020+ @doc("Optional pagination mechanism; may not necessarily allow scrolling through entire result set.")
2121+ cursor?: string
2222+ ): {
2323+ cursor?: string;
2424+2525+ @doc("Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.")
2626+ hitsTotal?: integer;
2727+2828+ @required starterPacks: app.bsky.unspecced.defs.SkeletonSearchStarterPack[];
2929+ };
3030+}
···11+import "@tlex/emitter";
22+33+namespace app.bsky.video.uploadVideo {
44+ @doc("Upload a video to be processed then stored on the PDS.")
55+ @procedure
66+ op main(
77+ @encoding("video/mp4")
88+ input: void
99+ ): {
1010+ @required jobStatus: app.bsky.video.defs.JobStatus;
1111+ };
1212+}
···11+import "@tlex/emitter";
22+33+namespace com.atproto.lexicon.schema {
44+ @doc("Representation of Lexicon schemas themselves, when published as atproto records. Note that the schema language is not defined in Lexicon; this meta schema currently only includes a single version field ('lexicon'). See the atproto specifications for description of the other expected top-level fields ('id', 'defs', etc).")
55+ @record("nsid")
66+ model Main {
77+ @doc("Indicates the 'version' of the Lexicon language. Must be '1' for the current atproto/Lexicon schema system.")
88+ @required
99+ lexicon: integer;
1010+ }
1111+}
···11+import "@tlex/emitter";
22+33+namespace com.atproto.temp.checkHandleAvailability {
44+ @doc("An invalid email was provided.")
55+ model InvalidEmail {}
66+77+ @doc("Indicates the provided handle is available.")
88+ model ResultAvailable {}
99+1010+ @doc("Indicates the provided handle is unavailable and gives suggestions of available handles.")
1111+ model ResultUnavailable {
1212+ @doc("List of suggested handles based on the provided inputs.")
1313+ @required
1414+ suggestions: Suggestion[];
1515+ }
1616+1717+ model Suggestion {
1818+ @required handle: handle;
1919+2020+ @doc("Method used to build this suggestion. Should be considered opaque to clients. Can be used for metrics.")
2121+ @required
2222+ method: string;
2323+ }
2424+2525+ @doc("Checks whether the provided handle is available. If the handle is not available, available suggestions will be returned. Optional inputs will be used to generate suggestions.")
2626+ @query
2727+ @errors(InvalidEmail)
2828+ op main(
2929+ @doc("Tentative handle. Will be checked for availability or used to build handle suggestions.")
3030+ handle: handle,
3131+3232+ @doc("User-provided email. Might be used to build handle suggestions.")
3333+ email?: string,
3434+3535+ @doc("User-provided birth date. Might be used to build handle suggestions.")
3636+ birthDate?: datetime
3737+ ): {
3838+ @doc("Echo of the input handle.")
3939+ @required
4040+ handle: handle;
4141+4242+ @required result: ResultAvailable | ResultUnavailable;
4343+ };
4444+}
···11+import "@tlex/emitter";
22+33+namespace tools.ozone.communication.createTemplate {
44+ model DuplicateTemplateName {}
55+66+ @doc("Administrative action to create a new, re-usable communication (email for now) template.")
77+ @procedure
88+ @errors(DuplicateTemplateName)
99+ op main(
1010+ input: {
1111+ @doc("Subject of the message, used in emails.")
1212+ @required
1313+ subject: string;
1414+1515+ @doc("Content of the template, markdown supported, can contain variable placeholders.")
1616+ @required
1717+ contentMarkdown: string;
1818+1919+ @doc("Name of the template.")
2020+ @required
2121+ name: string;
2222+2323+ @doc("Message language.")
2424+ lang?: language;
2525+2626+ @doc("DID of the user who is creating the template.")
2727+ createdBy?: did;
2828+ }
2929+ ): tools.ozone.communication.defs.TemplateView;
3030+}
···11+import "@tlex/emitter";
22+33+namespace tools.ozone.communication.listTemplates {
44+ @doc("Get list of all communication templates.")
55+ @query
66+ op main(): {
77+ @required communicationTemplates: tools.ozone.communication.defs.TemplateView[];
88+ };
99+}
···11+import "@tlex/emitter";
22+33+namespace tools.ozone.communication.updateTemplate {
44+ model DuplicateTemplateName {}
55+66+ @doc("Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.")
77+ @procedure
88+ @errors(DuplicateTemplateName)
99+ op main(
1010+ input: {
1111+ @doc("ID of the template to be updated.")
1212+ @required
1313+ id: string;
1414+1515+ @doc("Name of the template.")
1616+ name?: string;
1717+1818+ @doc("Message language.")
1919+ lang?: language;
2020+2121+ @doc("Content of the template, markdown supported, can contain variable placeholders.")
2222+ contentMarkdown?: string;
2323+2424+ @doc("Subject of the message, used in emails.")
2525+ subject?: string;
2626+2727+ @doc("DID of the user who is updating the template.")
2828+ updatedBy?: did;
2929+3030+ disabled?: boolean;
3131+ }
3232+ ): tools.ozone.communication.defs.TemplateView;
3333+}
···11+import "@tlex/emitter";
22+33+namespace tools.ozone.moderation.defs {
44+ model ModEventView {
55+ @required id: integer;
66+ @required event: ModEventTakedown | ModEventReverseTakedown | ModEventComment | ModEventReport | ModEventLabel | ModEventAcknowledge | ModEventEscalate | ModEventMute | ModEventUnmute | ModEventMuteReporter | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert | ModEventTag | AccountEvent | IdentityEvent | RecordEvent | ModEventPriorityScore | AgeAssuranceEvent | AgeAssuranceOverrideEvent | RevokeAccountCredentialsEvent;
77+ @required subject: com.atproto.admin.defs.RepoRef | com.atproto.repo.strongRef.Main | chat.bsky.convo.defs.MessageRef;
88+ @required subjectBlobCids: string[];
99+ @required createdBy: did;
1010+ @required createdAt: datetime;
1111+ creatorHandle?: string;
1212+ subjectHandle?: string;
1313+ modTool?: ModTool;
1414+ }
1515+1616+ model ModEventViewDetail {
1717+ @required id: integer;
1818+ @required event: ModEventTakedown | ModEventReverseTakedown | ModEventComment | ModEventReport | ModEventLabel | ModEventAcknowledge | ModEventEscalate | ModEventMute | ModEventUnmute | ModEventMuteReporter | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert | ModEventTag | AccountEvent | IdentityEvent | RecordEvent | ModEventPriorityScore | AgeAssuranceEvent | AgeAssuranceOverrideEvent | RevokeAccountCredentialsEvent;
1919+ @required subject: RepoView | RepoViewNotFound | RecordView | RecordViewNotFound;
2020+ @required subjectBlobs: BlobView[];
2121+ @required createdBy: did;
2222+ @required createdAt: datetime;
2323+ modTool?: ModTool;
2424+ }
2525+2626+ model SubjectStatusView {
2727+ @required id: integer;
2828+ @required subject: com.atproto.admin.defs.RepoRef | com.atproto.repo.strongRef.Main | chat.bsky.convo.defs.MessageRef;
2929+ hosting?: AccountHosting | RecordHosting;
3030+ subjectBlobCids?: cid[];
3131+ subjectRepoHandle?: string;
3232+3333+ @doc("Timestamp referencing the first moderation status impacting event was emitted on the subject")
3434+ @required
3535+ createdAt: datetime;
3636+3737+ @doc("Timestamp referencing when the last update was made to the moderation status of the subject")
3838+ @required
3939+ updatedAt: datetime;
4040+4141+ @required reviewState: SubjectReviewState;
4242+4343+ @doc("Sticky comment on the subject.")
4444+ comment?: string;
4545+4646+ @doc("Numeric value representing the level of priority. Higher score means higher priority.")
4747+ @minValue(0)
4848+ @maxValue(100)
4949+ priorityScore?: integer;
5050+5151+ muteUntil?: datetime;
5252+ muteReportingUntil?: datetime;
5353+ lastReviewedBy?: did;
5454+ lastReviewedAt?: datetime;
5555+ lastReportedAt?: datetime;
5656+5757+ @doc("Timestamp referencing when the author of the subject appealed a moderation action")
5858+ lastAppealedAt?: datetime;
5959+6060+ takendown?: boolean;
6161+6262+ @doc("True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.")
6363+ appealed?: boolean;
6464+6565+ suspendUntil?: datetime;
6666+ tags?: string[];
6767+6868+ @doc("Statistics related to the account subject")
6969+ accountStats?: AccountStats;
7070+7171+ @doc("Statistics related to the record subjects authored by the subject's account")
7272+ recordsStats?: RecordsStats;
7373+7474+ @doc("Current age assurance state of the subject.")
7575+ ageAssuranceState?: "pending" | "assured" | "unknown" | "reset" | "blocked" | string;
7676+7777+ @doc("Whether or not the last successful update to age assurance was made by the user or admin.")
7878+ ageAssuranceUpdatedBy?: "admin" | "user" | string;
7979+ }
8080+8181+ @doc("Detailed view of a subject. For record subjects, the author's repo and profile will be returned.")
8282+ model SubjectView {
8383+ @required type: com.atproto.moderation.defs.SubjectType;
8484+ @required subject: string;
8585+ status?: SubjectStatusView;
8686+ repo?: RepoViewDetail;
8787+ profile?: (never | unknown);
8888+ record?: RecordViewDetail;
8989+ }
9090+9191+ @doc("Statistics about a particular account subject")
9292+ model AccountStats {
9393+ @doc("Total number of reports on the account")
9494+ reportCount?: integer;
9595+9696+ @doc("Total number of appeals against a moderation action on the account")
9797+ appealCount?: integer;
9898+9999+ @doc("Number of times the account was suspended")
100100+ suspendCount?: integer;
101101+102102+ @doc("Number of times the account was escalated")
103103+ escalateCount?: integer;
104104+105105+ @doc("Number of times the account was taken down")
106106+ takedownCount?: integer;
107107+ }
108108+109109+ @doc("Statistics about a set of record subject items")
110110+ model RecordsStats {
111111+ @doc("Cumulative sum of the number of reports on the items in the set")
112112+ totalReports?: integer;
113113+114114+ @doc("Number of items that were reported at least once")
115115+ reportedCount?: integer;
116116+117117+ @doc("Number of items that were escalated at least once")
118118+ escalatedCount?: integer;
119119+120120+ @doc("Number of items that were appealed at least once")
121121+ appealedCount?: integer;
122122+123123+ @doc("Total number of item in the set")
124124+ subjectCount?: integer;
125125+126126+ @doc("Number of item currently in \"reviewOpen\" or \"reviewEscalated\" state")
127127+ pendingCount?: integer;
128128+129129+ @doc("Number of item currently in \"reviewNone\" or \"reviewClosed\" state")
130130+ processedCount?: integer;
131131+132132+ @doc("Number of item currently taken down")
133133+ takendownCount?: integer;
134134+ }
135135+136136+ union SubjectReviewState {
137137+ string,
138138+139139+ ReviewOpen: "#reviewOpen",
140140+ ReviewEscalated: "#reviewEscalated",
141141+ ReviewClosed: "#reviewClosed",
142142+ ReviewNone: "#reviewNone",
143143+ }
144144+145145+ @doc("Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator")
146146+ @token
147147+ model ReviewOpen {}
148148+149149+ @doc("Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator")
150150+ @token
151151+ model ReviewEscalated {}
152152+153153+ @doc("Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator")
154154+ @token
155155+ model ReviewClosed {}
156156+157157+ @doc("Moderator review status of a subject: Unnecessary. Indicates that the subject does not need a review at the moment but there is probably some moderation related metadata available for it")
158158+ @token
159159+ model ReviewNone {}
160160+161161+ @doc("Take down a subject permanently or temporarily")
162162+ model ModEventTakedown {
163163+ comment?: string;
164164+165165+ @doc("Indicates how long the takedown should be in effect before automatically expiring.")
166166+ durationInHours?: integer;
167167+168168+ @doc("If true, all other reports on content authored by this account will be resolved (acknowledged).")
169169+ acknowledgeAccountSubjects?: boolean;
170170+171171+ @maxItems(5)
172172+ @doc("Names/Keywords of the policies that drove the decision.")
173173+ policies?: string[];
174174+ }
175175+176176+ @doc("Revert take down action on a subject")
177177+ model ModEventReverseTakedown {
178178+ @doc("Describe reasoning behind the reversal.")
179179+ comment?: string;
180180+ }
181181+182182+ @doc("Resolve appeal on a subject")
183183+ model ModEventResolveAppeal {
184184+ @doc("Describe resolution.")
185185+ comment?: string;
186186+ }
187187+188188+ @doc("Add a comment to a subject. An empty comment will clear any previously set sticky comment.")
189189+ model ModEventComment {
190190+ comment?: string;
191191+192192+ @doc("Make the comment persistent on the subject")
193193+ sticky?: boolean;
194194+ }
195195+196196+ @doc("Report a subject")
197197+ model ModEventReport {
198198+ comment?: string;
199199+200200+ @doc("Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject.")
201201+ isReporterMuted?: boolean;
202202+203203+ @required reportType: com.atproto.moderation.defs.ReasonType;
204204+ }
205205+206206+ @doc("Apply/Negate labels on a subject")
207207+ model ModEventLabel {
208208+ comment?: string;
209209+ @required createLabelVals: string[];
210210+ @required negateLabelVals: string[];
211211+212212+ @doc("Indicates how long the label will remain on the subject. Only applies on labels that are being added.")
213213+ durationInHours?: integer;
214214+ }
215215+216216+ @doc("Set priority score of the subject. Higher score means higher priority.")
217217+ model ModEventPriorityScore {
218218+ comment?: string;
219219+220220+ @minValue(0)
221221+ @maxValue(100)
222222+ @required
223223+ score: integer;
224224+ }
225225+226226+ @doc("Age assurance info coming directly from users. Only works on DID subjects.")
227227+ model AgeAssuranceEvent {
228228+ @doc("The date and time of this write operation.")
229229+ @required
230230+ createdAt: datetime;
231231+232232+ @doc("The status of the age assurance process.")
233233+ @required
234234+ status: "unknown" | "pending" | "assured" | string;
235235+236236+ @doc("The unique identifier for this instance of the age assurance flow, in UUID format.")
237237+ @required
238238+ attemptId: string;
239239+240240+ @doc("The IP address used when initiating the AA flow.")
241241+ initIp?: string;
242242+243243+ @doc("The user agent used when initiating the AA flow.")
244244+ initUa?: string;
245245+246246+ @doc("The IP address used when completing the AA flow.")
247247+ completeIp?: string;
248248+249249+ @doc("The user agent used when completing the AA flow.")
250250+ completeUa?: string;
251251+ }
252252+253253+ @doc("Age assurance status override by moderators. Only works on DID subjects.")
254254+ model AgeAssuranceOverrideEvent {
255255+ @doc("Comment describing the reason for the override.")
256256+ @required
257257+ comment: string;
258258+259259+ @doc("The status to be set for the user decided by a moderator, overriding whatever value the user had previously. Use reset to default to original state.")
260260+ @required
261261+ status: "assured" | "reset" | "blocked" | string;
262262+ }
263263+264264+ @doc("Account credentials revocation by moderators. Only works on DID subjects.")
265265+ model RevokeAccountCredentialsEvent {
266266+ @doc("Comment describing the reason for the revocation.")
267267+ @required
268268+ comment: string;
269269+ }
270270+271271+ model ModEventAcknowledge {
272272+ comment?: string;
273273+274274+ @doc("If true, all other reports on content authored by this account will be resolved (acknowledged).")
275275+ acknowledgeAccountSubjects?: boolean;
276276+ }
277277+278278+ model ModEventEscalate {
279279+ comment?: string;
280280+ }
281281+282282+ @doc("Mute incoming reports on a subject")
283283+ model ModEventMute {
284284+ comment?: string;
285285+286286+ @doc("Indicates how long the subject should remain muted.")
287287+ @required
288288+ durationInHours: integer;
289289+ }
290290+291291+ @doc("Unmute action on a subject")
292292+ model ModEventUnmute {
293293+ @doc("Describe reasoning behind the reversal.")
294294+ comment?: string;
295295+ }
296296+297297+ @doc("Mute incoming reports from an account")
298298+ model ModEventMuteReporter {
299299+ comment?: string;
300300+301301+ @doc("Indicates how long the account should remain muted. Falsy value here means a permanent mute.")
302302+ durationInHours?: integer;
303303+ }
304304+305305+ @doc("Unmute incoming reports from an account")
306306+ model ModEventUnmuteReporter {
307307+ @doc("Describe reasoning behind the reversal.")
308308+ comment?: string;
309309+ }
310310+311311+ @doc("Keep a log of outgoing email to a user")
312312+ model ModEventEmail {
313313+ @doc("The subject line of the email sent to the user.")
314314+ @required
315315+ subjectLine: string;
316316+317317+ @doc("The content of the email sent to the user.")
318318+ content?: string;
319319+320320+ @doc("Additional comment about the outgoing comm.")
321321+ comment?: string;
322322+ }
323323+324324+ @doc("Divert a record's blobs to a 3rd party service for further scanning/tagging")
325325+ model ModEventDivert {
326326+ comment?: string;
327327+ }
328328+329329+ @doc("Add/Remove a tag on a subject")
330330+ model ModEventTag {
331331+ @doc("Tags to be added to the subject. If already exists, won't be duplicated.")
332332+ @required
333333+ add: string[];
334334+335335+ @doc("Tags to be removed to the subject. Ignores a tag If it doesn't exist, won't be duplicated.")
336336+ @required
337337+ remove: string[];
338338+339339+ @doc("Additional comment about added/removed tags.")
340340+ comment?: string;
341341+ }
342342+343343+ @doc("Logs account status related events on a repo subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.")
344344+ model AccountEvent {
345345+ comment?: string;
346346+ @required timestamp: datetime;
347347+348348+ @doc("Indicates that the account has a repository which can be fetched from the host that emitted this event.")
349349+ @required
350350+ active: boolean;
351351+352352+ status?: "unknown" | "deactivated" | "deleted" | "takendown" | "suspended" | "tombstoned" | string;
353353+ }
354354+355355+ @doc("Logs identity related events on a repo subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.")
356356+ model IdentityEvent {
357357+ comment?: string;
358358+ handle?: handle;
359359+ pdsHost?: uri;
360360+ tombstone?: boolean;
361361+ @required timestamp: datetime;
362362+ }
363363+364364+ @doc("Logs lifecycle event on a record subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.")
365365+ model RecordEvent {
366366+ comment?: string;
367367+ @required timestamp: datetime;
368368+369369+ @required
370370+ `op`: "create" | "update" | "delete" | string;
371371+372372+ cid?: cid;
373373+ }
374374+375375+ model RepoView {
376376+ @required did: did;
377377+ @required handle: handle;
378378+ email?: string;
379379+ @required relatedRecords: unknown[];
380380+ @required indexedAt: datetime;
381381+ @required moderation: Moderation;
382382+ invitedBy?: com.atproto.server.defs.InviteCode;
383383+ invitesDisabled?: boolean;
384384+ inviteNote?: string;
385385+ deactivatedAt?: datetime;
386386+ threatSignatures?: com.atproto.admin.defs.ThreatSignature[];
387387+ }
388388+389389+ model RepoViewDetail {
390390+ @required did: did;
391391+ @required handle: handle;
392392+ email?: string;
393393+ @required relatedRecords: unknown[];
394394+ @required indexedAt: datetime;
395395+ @required moderation: ModerationDetail;
396396+ labels?: com.atproto.label.defs.Label[];
397397+ invitedBy?: com.atproto.server.defs.InviteCode;
398398+ invites?: com.atproto.server.defs.InviteCode[];
399399+ invitesDisabled?: boolean;
400400+ inviteNote?: string;
401401+ emailConfirmedAt?: datetime;
402402+ deactivatedAt?: datetime;
403403+ threatSignatures?: com.atproto.admin.defs.ThreatSignature[];
404404+ }
405405+406406+ model RepoViewNotFound {
407407+ @required did: did;
408408+ }
409409+410410+ model RecordView {
411411+ @required uri: atUri;
412412+ @required cid: cid;
413413+ @required value: unknown;
414414+ @required blobCids: cid[];
415415+ @required indexedAt: datetime;
416416+ @required moderation: Moderation;
417417+ @required repo: RepoView;
418418+ }
419419+420420+ model RecordViewDetail {
421421+ @required uri: atUri;
422422+ @required cid: cid;
423423+ @required value: unknown;
424424+ @required blobs: BlobView[];
425425+ labels?: com.atproto.label.defs.Label[];
426426+ @required indexedAt: datetime;
427427+ @required moderation: ModerationDetail;
428428+ @required repo: RepoView;
429429+ }
430430+431431+ model RecordViewNotFound {
432432+ @required uri: atUri;
433433+ }
434434+435435+ model Moderation {
436436+ subjectStatus?: SubjectStatusView;
437437+ }
438438+439439+ model ModerationDetail {
440440+ subjectStatus?: SubjectStatusView;
441441+ }
442442+443443+ model BlobView {
444444+ @required cid: cid;
445445+ @required mimeType: string;
446446+ @required size: integer;
447447+ @required createdAt: datetime;
448448+ details?: ImageDetails | VideoDetails;
449449+ moderation?: Moderation;
450450+ }
451451+452452+ model ImageDetails {
453453+ @required width: integer;
454454+ @required height: integer;
455455+ }
456456+457457+ model VideoDetails {
458458+ @required width: integer;
459459+ @required height: integer;
460460+ @required length: integer;
461461+ }
462462+463463+ model AccountHosting {
464464+ @required
465465+ status: "takendown" | "suspended" | "deleted" | "deactivated" | "unknown" | string;
466466+467467+ updatedAt?: datetime;
468468+ createdAt?: datetime;
469469+ deletedAt?: datetime;
470470+ deactivatedAt?: datetime;
471471+ reactivatedAt?: datetime;
472472+ }
473473+474474+ model RecordHosting {
475475+ @required
476476+ status: "deleted" | "unknown" | string;
477477+478478+ updatedAt?: datetime;
479479+ createdAt?: datetime;
480480+ deletedAt?: datetime;
481481+ }
482482+483483+ model ReporterStats {
484484+ @required did: did;
485485+486486+ @doc("The total number of reports made by the user on accounts.")
487487+ @required
488488+ accountReportCount: integer;
489489+490490+ @doc("The total number of reports made by the user on records.")
491491+ @required
492492+ recordReportCount: integer;
493493+494494+ @doc("The total number of accounts reported by the user.")
495495+ @required
496496+ reportedAccountCount: integer;
497497+498498+ @doc("The total number of records reported by the user.")
499499+ @required
500500+ reportedRecordCount: integer;
501501+502502+ @doc("The total number of accounts taken down as a result of the user's reports.")
503503+ @required
504504+ takendownAccountCount: integer;
505505+506506+ @doc("The total number of records taken down as a result of the user's reports.")
507507+ @required
508508+ takendownRecordCount: integer;
509509+510510+ @doc("The total number of accounts labeled as a result of the user's reports.")
511511+ @required
512512+ labeledAccountCount: integer;
513513+514514+ @doc("The total number of records labeled as a result of the user's reports.")
515515+ @required
516516+ labeledRecordCount: integer;
517517+ }
518518+519519+ @doc("Moderation tool information for tracing the source of the action")
520520+ model ModTool {
521521+ @doc("Name/identifier of the source (e.g., 'automod', 'ozone/workspace')")
522522+ @required
523523+ name: string;
524524+525525+ @doc("Additional arbitrary metadata about the source")
526526+ meta?: unknown;
527527+ }
528528+529529+ @doc("Moderation event timeline event for a PLC create operation")
530530+ @token
531531+ model TimelineEventPlcCreate {}
532532+533533+ @doc("Moderation event timeline event for generic PLC operation")
534534+ @token
535535+ model TimelineEventPlcOperation {}
536536+537537+ @doc("Moderation event timeline event for a PLC tombstone operation")
538538+ @token
539539+ model TimelineEventPlcTombstone {}
540540+}
···11+import "@tlex/emitter";
22+33+namespace tools.ozone.verification.defs {
44+ @doc("Verification data for the associated subject.")
55+ model VerificationView {
66+ @doc("The user who issued this verification.")
77+ @required
88+ issuer: did;
99+1010+ @doc("The AT-URI of the verification record.")
1111+ @required
1212+ uri: atUri;
1313+1414+ @doc("The subject of the verification.")
1515+ @required
1616+ subject: did;
1717+1818+ @doc("Handle of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current handle matches the one at the time of verifying.")
1919+ @required
2020+ handle: handle;
2121+2222+ @doc("Display name of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current displayName matches the one at the time of verifying.")
2323+ @required
2424+ displayName: string;
2525+2626+ @doc("Timestamp when the verification was created.")
2727+ @required
2828+ createdAt: datetime;
2929+3030+ @doc("Describes the reason for revocation, also indicating that the verification is no longer valid.")
3131+ revokeReason?: string;
3232+3333+ @doc("Timestamp when the verification was revoked.")
3434+ revokedAt?: datetime;
3535+3636+ @doc("The user who revoked this verification.")
3737+ revokedBy?: did;
3838+3939+ subjectProfile?: (never | unknown);
4040+ issuerProfile?: (never | unknown);
4141+ subjectRepo?: tools.ozone.moderation.defs.RepoViewDetail | tools.ozone.moderation.defs.RepoViewNotFound;
4242+ issuerRepo?: tools.ozone.moderation.defs.RepoViewDetail | tools.ozone.moderation.defs.RepoViewNotFound;
4343+ }
4444+}
+36
packages/emitter/test/spec/README.md
···11+# Lexicon Spec Test Fixtures
22+33+This directory contains test fixtures organized by ATProto Lexicon specification features, not by real-world examples.
44+55+## Organization
66+77+Each subdirectory focuses on a specific feature from the [Lexicon spec](https://atproto.com/specs/lexicon):
88+99+- **union/** - Union type features (open, closed, variants)
1010+- **string-constraints/** - String knownValues, enum, formats
1111+- **validation/** - Constraints (min/max length/value, graphemes)
1212+- **primary-types/** - Record, query, procedure, subscription
1313+- **field-types/** - Primitive types and their features
1414+- **refs/** - Reference patterns (local, cross-namespace)
1515+- **nullable/** - Nullable field handling
1616+- **unknown/** - Unknown type usage
1717+- **tokens/** - Token definitions and usage
1818+- **binary/** - Blob and bytes types
1919+- **evolution/** - Schema evolution patterns
2020+2121+## Purpose
2222+2323+Unlike `test/scenarios/atproto/` which tests real ATProto lexicons, these fixtures:
2424+2525+1. Test each spec feature in isolation
2626+2. Demonstrate minimal, focused examples
2727+3. Serve as documentation for TypeSpec → Lexicon mappings
2828+4. Catch edge cases and corner cases in the spec
2929+3030+## Example Structure
3131+3232+Each feature directory contains:
3333+- `input/*.tsp` - TypeSpec definitions for that feature
3434+- `output/*.json` - Expected Lexicon JSON output
3535+3636+Tests should be atomic and focus on one feature at a time.