# 0005 — Lexicon design & the publish model

- **Status:** Accepted
- **Date:** 2026-06-08
- **Scope:** SP2 (publish). Edit semantics (the "puppy problem") are SP5.

Shapes below are verified against the live `site.standard.*` lexicons
(tangled.org/standard.site/lexicons, fetched 2026-06-08), not just the brief.

## Records written on publish

Two (or three) records on the writer's PDS, per brief §1:

1. **`site.standard.publication`** (rkey: TID) — created once per writer if missing.
   `url` (required) = the writer's SkyPress homepage `https://skypress.blog/@<handle>`;
   `name` (required) = a default derived from the handle. Reused on later publishes by
   listing the writer's existing publication records and matching by `url` — NOT a `self`
   singleton, and NOT just any standard.site publication the writer may already have from
   other tools.
2. **`site.standard.document`** (rkey: TID) — the article.
   - `site` = the publication's `at://` URI (links document↔publication, which Bluesky
     requires for the highest-fidelity card — brief §3).
   - `path` = `/<rkey>` (the document's own record key). Canonical URL = publication
     `url` + `path` = `https://skypress.blog/@<handle>/<rkey>`.
   - `title`, `publishedAt` (required); `description`; `textContent` (de-facto required —
     Bluesky computes reading-time/search from it, ignoring `content`); `bskyPostRef`
     (strongRef to the post, below); `content` = our Gutenberg object.
3. **`app.bsky.feed.post`** (rkey: TID) — the social signal, with an
   `app.bsky.embed.external` link card pointing at the canonical article URL.

### URL structure
`https://skypress.blog/@<handle>/<rkey>`. The `@` marks the handle segment, and the
document is addressed by its **record key**, not a title slug:
- a slug isn't stable across title edits; the rkey never changes (URL stability for the
  edit flow, SP5);
- the renderer (SP4) can `getRecord` the document directly by rkey — no listing +
  path-matching.

### Publish order (avoids a circular dependency)
The document's rkey is generated client-side up front (`TID.nextStr()`), so the article
URL is known before any record is written. So:
`ensure publication → create post (embed → article URL) → create document at the
pre-chosen rkey (content + textContent + bskyPostRef)`. One document write, no follow-up
update.

> **Superseded by Decision 0013.** To embed the standard.site link card, the post must
> carry the document's strongRef, so the order is now `create document (no bskyPostRef) →
> create post (facet + associatedRefs) → putRecord document (add bskyPostRef)`. Two
> document writes; see 0013 for why the resulting stale ref is harmless.

## The SkyPress content lexicon — `blog.skypress.content.gutenberg`

Goes inside the document's open `content` union (brief §3). The block tree is canonical
(the array from `onSaveBlocks`), not rendered HTML.

```
blog.skypress.content.gutenberg (object)
  version: integer   — serialization version (currently 1)
  blocks:  array of unknown — the Gutenberg block tree: [{ name, attributes, innerBlocks }]
```

- `blocks` items are typed `unknown` (arbitrary objects): Gutenberg block attributes are
  open-ended per block type and can't be strictly schematised. Each node matches the
  `BlockNode` shape `render.ts` consumes, so reconstruction is a direct read.
- Lexicon authoring discipline (brief §3): optional fields, open unions, additions-only.
  `version` lets us evolve the serialization without breaking old records; a breaking
  change ships as `blog.skypress.content.gutenberg` **v2** (a new $type), never a silent
  mutation.
- Graceful degradation: any reader that doesn't understand this `$type` falls back to the
  document's `textContent` — which is why we always write good `textContent`.

NSID rationale: the app lives at `skypress.blog`, so the reverse-DNS namespace is
`blog.skypress.*` (brief §3). Only the content format is SkyPress-owned; publication +
document metadata reuse `site.standard.*` for interop.

## Scope

Writing these records needs write access. Per brief §2, use **`atproto
transition:generic`** for now ("keep transition:generic until read-side scopes
stabilize"). This is the existing `OAUTH_SCOPE`; SP2 wires it into the dev loopback
client_id (which previously defaulted to read-only `atproto`), so the user re-auths once
to grant writes. The granular `repo:site.standard.*` / `repo:app.bsky.feed.post` scopes
(brief §2) are a later tightening.

## Edit semantics (the "puppy problem") — deferred to SP5, but constrained now

SP2 only **creates**. The document uses a TID rkey (the lexicon mandates `key: 'tid'`),
so a stable URL across edits will mean updating the same record (rkey) in SP5. SP2 does
not implement re-publish/update; that decision (mutate vs. version vs. new record) is
SP5's, and the lexicon's `updatedAt` field is reserved for it.

## Don't surprise the user (brief §10, non-negotiable)

Publishing creates a **public Bluesky post**. The publish UI must state this
unmistakably and require an explicit confirm before writing. Implemented in the publish
panel.
