atjamalpha

Reading, writing, validating

How-to. Task recipes: read each record type, write each record type, validate a signup, and check eligibility. Code is lifted from the real read layer, web components, and test harness.

All examples assume:

import { Queries, Validate, Eligibility, NSIDS } from "@atjam/lexicons";

Configure the read layer once first (see Using the lexicons).

Reading

Fetch a round, jam, or any record

// By DID + rkey:
const round = await Queries.fetchRound(did, rkey); // PdsRecord<Round.Round>
const jam = await Queries.fetchJam(did, rkey); // PdsRecord<Jam.Jam>

// By AT URI:
const jam2 = await Queries.fetchJamByUri(jamUri);

// Any record type, low-level:
const rec = await Queries.getRecord({ did, collection, rkey });

getRecord returns a PdsRecord<T> = { uri, cid, value }. Parse an AT URI into its parts with Queries.parseAtUri(uri) { did, collection, rkey }.

List the rounds under a jam

const rounds = await Queries.fetchRoundsForJam(jamUri); // PdsRecord<Round.Round>[]

This asks Constellation which at.atjam.round records link to the jam (via .jam.uri), then fetches each round body. Unreadable rounds are dropped.

Find a round's signups, submissions, and invitations

// Signups: returns BacklinksResponse ({ links, cursor?, total? }).
const signups = await Queries.fetchSignupsForRound(roundUri);
for (const link of signups.links) {
  const signup = await Queries.getRecord({
    ...Queries.parseAtUri(link.uri),
  });
}

// Submissions and invitations: bodies are fetched for you.
const submissions = await Queries.fetchSubmissionsForRound(roundUri); // PdsRecord<Submission.Submission>[]
const invitations = await Queries.fetchInvitationsForRound(roundUri); // PdsRecord<Invitation.Invitation>[]

fetchSignupsForRound returns the raw backlinks (so you can decide whether to fetch each body); fetchSubmissionsForRound and fetchInvitationsForRound fetch and return the record bodies directly.

Build a feed across known organizers

const feed = await Queries.fetchHomeFeed([organizerDid1, organizerDid2]);
// PdsRecord<Round.Round>[], deduped, sorted by createdAt descending.

There is no global round index — you supply the DIDs. See the read-layer limitation.

Name the actors

const handles = await Queries.resolveHandles([did1, did2]); // HandleMap (Record<string,string>)
// DIDs that fail to resolve are simply absent — fall back to the DID.

const { pds, handle } = await Queries.resolveDid(did);

Writing

The lexicons package has no write helper. Write with your own ATProto client via com.atproto.repo.createRecord:

com.atproto.repo.createRecord
  repo:       <your DID>
  collection: <NSIDS.jam | round | signup | invitation | submission>
  record:     { $type, ...fields, createdAt }

Each record's required fields (verify against the JSON schemas):

Jam — NSIDS.jam

Required: name, createdAt.

{
  $type: NSIDS.jam,
  name: "Old Salt Song Club",
  // optional: description, kind, links
  createdAt: new Date().toISOString(),
}

Round — NSIDS.round

Required: jam, assignment, acceptedSubmissionTypes (≥1 NSID), milestones (≥1), createdAt.

{
  $type: NSIDS.round,
  jam: { uri: jamRef.uri, cid: jamRef.cid }, // strong-ref to the jam
  assignment: "Make anything. Submit a Bluesky post as proof.",
  acceptedSubmissionTypes: ["app.bsky.feed.post"],
  milestones: [{ label: "submission-deadline", date: deadlineIso }],
  // optional: name, subject, closingEvent, joinMode, networkGate
  createdAt: new Date().toISOString(),
}

joinMode defaults to "open" when omitted. For "hosted"/"network", see the two-sided flow below.

Signup — NSIDS.signup

Required: round, createdAt. For open rounds, that is all.

{
  $type: NSIDS.signup,
  round: { uri: round.uri, cid: round.cid },
  ...(invitation ? { invitation } : {}), // required by readers for hosted/network
  // optional: note
  createdAt: new Date().toISOString(),
}

This is exactly what web/app/components/signup-button.tsx builds.

Invitation — NSIDS.invitation

Required: round, invitee (a DID), createdAt.

{
  $type: NSIDS.invitation,
  round: { uri: round.uri, cid: round.cid },
  invitee, // the DID being invited
  // optional: note
  createdAt: new Date().toISOString(),
}

This is what web/app/components/invite-form.tsx builds.

Submission — NSIDS.submission

Required: round, createdAt, and a deliverable — one of two, a gradient rather than a coin-flip:

  • payload (preferred) — a strong-ref to a record on another app: content-addressed, verifiable, portable. Its $type SHOULD appear in the round's acceptedSubmissionTypes.
  • url (fallback) — a plain link, for work that isn't an ATProto record yet (an itch.io build, a hosted doc). Unverifiable and mutable, so prefer payload.

The Lexicon can't express "exactly one of" across fields, so both are optional in the schema and the "at least one" rule lives in code: read every deliverable through Submission.getDeliverable(submission), which returns the record (preferred), else the url, else null.

// Preferred — a record payload:
{
  $type: NSIDS.submission,
  round: { uri: round.uri, cid: round.cid },
  payload: { uri: payload.uri, cid: payload.cid }, // e.g. an app.bsky.feed.post
  // optional: note
  createdAt: new Date().toISOString(),
}

// Fallback — a plain link, when the work isn't an ATProto record:
{
  $type: NSIDS.submission,
  round: { uri: round.uri, cid: round.cid },
  url: "https://my-itch.io/the-build",
  createdAt: new Date().toISOString(),
}

web/app/components/submit-form.tsx resolves a pasted link to an AT URI, checks its collection is in the round's acceptedSubmissionTypes, then fetches the record to capture its CID before building the payload strong-ref.

Validating a signup

For hosted and network rounds, a signup is only confirmed participation if its invitation checks out. The lexicon does not enforce this — readers must. Use Validate.validateSignup:

const result = await Validate.validateSignup({
  signup: { uri: signupUri, value: signupRecord }, // value: Signup
  round: { uri: roundUri, value: roundRecord }, // value: Round
  // fetchers?: optional; defaults wrap Queries
});

if (result.valid) {
  // count it as confirmed participation
} else {
  console.log(result.reason);
}

validateSignup is read-only — it never writes. You pass in the signup and round records (fetch them yourself); it handles all downstream fetches (the invitation, network chain walks, submission checks). The tests/src/scenarios/hosted-validation.test.ts scenario calls it exactly this way after writing real records.

The validation contract

In one sentence: a signup counts only if someone allowed to invite actually invited that exact DID to that exact round — and you can't fake who invited you, because the inviter is simply whoever wrote the invitation record. Everything below is that idea, checked condition by condition.

The rules, by joinMode (derived via getJoinMode — unknown values resolve to "open"):

  • open → always { valid: true }. No invitation expected.
  • hosted / network → the signup must have an invitation strong-ref. Then the referenced invitation is fetched and must satisfy:
    • invitation.invitee equals the signup's creator DID (parsed from the signup's AT URI), and
    • invitation.round.uri equals the round's URI.
    • The inviter DID is parsed from the signup's invitation.uri; the organizer DID is parsed from the round's jam.uri.
  • hosted → the inviter must be the organizer.
  • network → the organizer is always a valid inviter (the chain root). Otherwise the inviter must have their own valid signup to the round — the validator walks the chain recursively, detecting cycles. When networkGate is "contributed" (derived via getNetworkGate; unknown → "signup"), the inviter must additionally have at least one submission to the round.

The default fetchers go through Queries; tests inject stubs via the fetchers option to drive the matrix deterministically (see lexicons/src/validate.test.ts).

Checking eligibility (before writing)

Validate answers "is this existing signup valid?". Eligibility answers the prospective questions a UI needs before writing anything. It is pure — no I/O. You pass in the already-known facts.

const ctx: Eligibility.InviterContext = {
  round, // Round
  organizerDid, // chain root, always a valid inviter
  validSignerDids: [...], // DIDs whose signup to this round is valid
  submitterDids: [...], // DIDs with ≥1 submission to this round
};

// May this DID issue invitations? (open rounds → always false)
Eligibility.canInvite(ctx, viewerDid);

// Lower-level: is this DID a valid inviter for the round?
Eligibility.isValidInviter(ctx, inviterDid);

// Which invitation, if any, would make the viewer's signup valid?
// (names the viewer as invitee AND comes from a valid inviter)
const usable = Eligibility.findUsableInvitation(ctx, viewerDid, invitations);

findUsableInvitation is generic over the invitation record shape, so it returns your own type back (carrying the cid you need for the strong-ref). The web app uses it to decide whether to enable the "I'm in" button and which invitation strong-ref to attach.

The two-sided invitation → signup flow

For hosted and network rounds, participation is bilateral: two records, written by two parties, on two PDSes.

  1. The inviter writes the invitation. An at.atjam.invitation on the inviter's PDS, naming the invitee's DID and strong-reffing the round. For hosted the inviter must be the organizer; for network it can be the organizer or any participant who meets networkGate.
  2. The invitee writes the signup. An at.atjam.signup on the invitee's PDS, strong-reffing the round and the invitation from step 1.

Both records together constitute confirmed participation. Each party owns their own side and can delete it to remove themselves — neither depends on the other to do so. Readers confirm the pairing with Validate.validateSignup.

Absence of an invitation is not rejection — it just means that DID is not (yet) in. The data does not encode "pending" vs "declined"; rejections, if any, happen out-of-band.