Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/post-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@haverstack/commons': minor
---

Add `org.haverstack/post@1` — the broadcast utterance, completing the fourth cell
(speech / unbounded audience) of the commons' text-type contract 2×2.

`POST` exports `{ text: required, format, url }`, no date field by design — uttering
is creating, and a mirrored `publishedAt` would equal `createdAt` on every record. See
`docs/commons/post.md` for the full rationale and conventions.
22 changes: 12 additions & 10 deletions docs/commons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ org.haverstack/article@1
org.haverstack/place@1
org.haverstack/page@1
org.haverstack/photo@1
org.haverstack/post@1
org.haverstack/message@1 (proposed — see below)
org.haverstack/event@1 (proposed)
org.haverstack/poll@1 (proposed)
Expand Down Expand Up @@ -216,18 +217,19 @@ rewritten by and for them.
Three clusters. The **personal-data cluster** covers the shapes nearly every personal
app re-invents first. The **publishing cluster** covers the personal-web shapes (its
note/article boundary is the IndieWeb's post-type-discovery rule: a note is an entry
without a name, an article is an entry with one). The **group cluster** covers the
without a name, an article is an entry with one) plus `post`, the broadcast-speech
counterpart to `article`'s published-artifact contract. The **group cluster** covers the
small-group workspace — the Basecamp shape: message board, shared calendar, decisions,
shared drive — built on collaborative group stacks (`_group` with `stackUrl`), where
`entityId`-as-author and per-type grants do the heavy lifting. Pairs across the set
make the interop story demonstrable: notes ↔ flashcards, bookmarks ↔ read-later,
articles and pages ↔ any site generator, polls ↔ calendar (a scheduling poll's winning
slot becomes an event).

Three types carry free-form text and are distinguished by **social contract, not
shape** — notes are kept, messages are sent, articles are published.
[Choosing a text type](./text-types.md) is the decision guide, with worked examples
(comments on a blog post are messages; private marginalia are notes).
Four types carry free-form text and are distinguished by **social contract, not
shape** — notes are kept, messages are sent, articles are published, posts are
broadcast. [Choosing a text type](./text-types.md) is the decision guide, with worked
examples (comments on a blog post are messages; private marginalia are notes).

| Type | File | Status | Read-compat core |
| --------------------------- | ------------------------------ | -------- | ------------------------- |
Expand All @@ -239,6 +241,7 @@ shape** — notes are kept, messages are sent, articles are published.
| `org.haverstack/place@1` | [`place.md`](./place.md) | Draft | `{ latitude, longitude }` |
| `org.haverstack/page@1` | [`page.md`](./page.md) | Draft | `{ slug, text }` |
| `org.haverstack/photo@1` | [`photo.md`](./photo.md) | Draft | `{ image }` |
| `org.haverstack/post@1` | [`post.md`](./post.md) | Draft | `{ text }` |
| `org.haverstack/message@1` | [`message.md`](./message.md) | Proposed | `{ text }` |
| `org.haverstack/event@1` | [`event.md`](./event.md) | Proposed | `{ title, startsAt }` |
| `org.haverstack/poll@1` | [`poll.md`](./poll.md) | Proposed | `{ question, options }` |
Expand All @@ -254,11 +257,10 @@ concrete intended writer exists — the group cluster graduates when a group-too
or demo is real, building on the grant/group primitives (`_group`, type-level grants)
documented in the identity and access-control specs.

Deliberately absent from the initial set: `post` (the broadcast contract — `message` is
the group-scoped shape, not the social one), recurrence rules (see `event`: occurrences are materialized in @1),
`file`/`document` (a first-class `file` type is expected to follow `photo`'s pattern;
until a real writer needs it, a record plus attachment covers it), and `checkin`
(subsumed by the `location` cross-type convention plus any record).
Deliberately absent from the initial set: recurrence rules (see `event`: occurrences are
materialized in @1), `file`/`document` (a first-class `file` type is expected to follow
`photo`'s pattern; until a real writer needs it, a record plus attachment covers it), and
`checkin` (subsumed by the `location` cross-type convention plus any record).

---

Expand Down
4 changes: 3 additions & 1 deletion docs/commons/message.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ discussion.

## Deliberately excluded

- Social-post semantics — the broadcast contract's territory (see above).
- Social-post semantics — the broadcast contract's territory; see [`post`](./post.md).
- Reactions — a future micro-proposal (likely tag associations by non-authors — which
needs finer-grained association permissions than today's `update-own`/`update-any`
grant actions provide).
Expand All @@ -96,3 +96,5 @@ discussion.
- **Proposed** — initial definition: `text` (required), `subject`, `format`.
- **Proposed, amended** — threads may be anchored by any record (comments on articles,
photos, polls); text-type contract guide cross-referenced.
- **Proposed, amended** — [`post@1`](./post.md) landed, discharging the broadcast-shape
cross-reference this file previously carried as a forward pointer only.
117 changes: 117 additions & 0 deletions docs/commons/post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# `org.haverstack/post@1` — Post

> **Status:** Draft.

A broadcast utterance: a short, untitled piece of speech published under a public
identity, addressed to whoever listens rather than to a bounded audience. Completes the
fourth cell of the artifact/speech × bounded/unbounded 2×2 in
[Choosing a text type](./text-types.md#the-fourth-contract-posts-are-broadcast) —
artifact/unbounded is `article`/`page`, speech/bounded is `message`, speech/unbounded is
`post`.

**Not the IndieWeb's "note."** Their note is our `post`; see
[On the names](./text-types.md#on-the-names) for why the commons keeps its own names
despite the collision with IndieWeb post-type discovery and ActivityStreams 2.0's `Note`.

Posts are **broadcast** — speech constituted by the act of publishing, under a
self-certifying identity, to an audience the stack has no boundary around. For the
boundary with `note` (kept), `message` (sent to a bounded audience), and `article`
(published as a named work), see
[Choosing a text type](./text-types.md).

## Schema

```ts
await stack.defineType('org.haverstack/post@1', 'Post', {
text: { kind: 'text', required: true },
format: { kind: 'string' },
url: { kind: 'string' },
});
```

## Field semantics

| Field | Kind | Required | Meaning |
| -------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text` | yes | The utterance. What the post _is_. |
| `format` | `string` | no | Same vocabulary and defaults as `note`: `"markdown"` (default when absent), `"plain"`; unknown values render as plain. |
| `url` | `string` | no | Canonical location. POSSE's premise is that the canonical copy lives on your own site; the publishing app stamps it at first publish, same convention as `article.url`. Syndicated copies never overwrite it. |

The author is `entityId` — set natively by `Stack.create()`/`ScopedStack.create()`. No
`author` string: unlike `article`, a post is never a captured work carrying someone
else's byline.

### No date field, deliberately

The commons applies a consistent rule: a content date earns its place by naming an
event `createdAt` doesn't (`photo.takenAt` is capture vs. import, `article.publishedAt`
is publication vs. drafting). For a broadcast utterance, **uttering is creating** — an
unsaid post is working material, not a post, which is the same reasoning that leaves
`message@1` with no date field. A `publishedAt` here would equal `createdAt` on
essentially every record ever written, which design rule 5 forbids by name
(_"timestamps are `createdAt`/`updatedAt` — never mirrored into content"_).

This makes native `createdAt` the only ordering key a post will ever have. Importing an
existing archive of dated posts therefore depends on
[#203](https://github.com/haverstack/core/issues/203) (settable `createdAt` for
owner-side imports on unscoped `Stack`) — a dependency on _importing into_ the type, not
on the type's definition, which is fully expressible today.

**Scheduled posts**, the strongest objection to no date field: the position taken here
is that scheduling is the scheduler's state, not the utterance's — a queued post is
working material inside a tool until it goes out, and its moment is when it went out.
If that proves wrong in practice, an optional date field lands in place with no version
bump; starting with a mirror field and later removing it would be the expensive
direction.

## Conventions

- **Threading**: `parentId` for in-stack anchoring, as `message` does. Cross-stack
replies — a reply living in the replier's stack, referencing a record in someone
else's — are a relationship's `target` union
(`{ scope: 'record', recordId, stackUrl }` / `{ scope: 'external', ns, id }`), not
`parentId`, and need no change here; see
[Choosing a text type § The fourth contract](./text-types.md#the-fourth-contract-posts-are-broadcast).
- **Embedded media**: attachment associations labeled `embed`, per the cross-type
convention.
- **Geotagging**: the cross-type `location` relationship to a [`place`](./place.md)
record — the Foursquare-style public check-in is `post` + `location`.
- **Tags**: tag associations, never a content field.
- **Syndication**: a bridge publishes a copy elsewhere and stamps a `syndicated-to`
relationship with an external target on the canonical record; see
[Cross-type conventions](./README.md#cross-type-conventions). The canonical copy is
this record; syndicated copies never own `url`.
- **Authorship**: `entityId`. Deletion inside the stack is recoverable as usual; once a
post has been syndicated, deletion of the broadcast copy is a request to the network,
not a guarantee — a caveat of syndication, not of this type.

## Prior art

IndieWeb post-type discovery (their "note"), ActivityStreams 2.0 `Note`,
`app.bsky.feed.post`, Mastodon status, Twitter.

## Read-compat core

```ts
{ text: { kind: 'text', required: true } }
```

Shared with `note`, `message`, and `article` by design — generic text consumers reach
all four via `isCompatible()`; the `typeId` is the contract signal for consumers that
honor it.

## Deliberately excluded

- `createdAt`/`updatedAt`/a `publishedAt` mirror — see "No date field, deliberately"
above.
- `tags: string[]` — tag associations exist.
- `author` string — a post is never a captured work with someone else's byline;
`entityId` is the author.
- `to`/`cc` addressing, reply-count, like-count and similar network-derived fields —
bridge/adapter territory, not properties of the utterance itself.
- `lexiconId` mapping, content addressing, tombstone semantics — bridge machinery
(`adapter-atproto` and friends), not a content schema concern.

## Changelog

- **Draft** — initial definition: `text` (required), `format`, `url`.
20 changes: 11 additions & 9 deletions docs/commons/text-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ that being read as a finished work is its purpose.
| Sharing a link into the group ("read this!") | `message` (+ rel.) | The commentary is speech; the shared bookmark/article stays an artifact, linked. |
| Check-in, private location diary | `note` (+ `location`) | A journal entry with coordinates — addressed to no one. |
| "I'm at the café — come join me" (group) | `message` (+ `location`) | Speech; the `location` association is the invariant across every check-in contract. |
| Foursquare-style public check-in | _not yet in the commons_ | Broadcast speech — `post` + `location` once the fourth contract lands. |
| Social media post | _not yet in the commons_ | A fourth contract — public broadcast — see below. |
| Foursquare-style public check-in | `post` (+ `location`) | Broadcast speech, geotagged — the fourth contract, below. |
| Social media post | `post` | A fourth contract — public broadcast — see below. |

## Comments are messages; marginalia are notes

Expand All @@ -95,12 +95,12 @@ contracts encode two things, and speech-ness is only one. The other is **audienc
shape**: a `message`'s audience is defined by a boundary (presence in the stack _is_
the addressing — the reason `message` has no `to`/`cc`), while a post's audience is
whoever listens: unbounded and unenumerable. That completes a 2×2, and the fourth cell
is deliberately outside this guide:
is [`post`](./post.md):

| | Bounded audience | Unbounded audience |
| ------------ | ----------------------------- | ---------------------------------- |
| **Artifact** | `note` (kept — self or group) | `article` / `page` (published) |
| **Speech** | `message` (sent) | **`post` (broadcast)** — see below |
| | Bounded audience | Unbounded audience |
| ------------ | ----------------------------- | ------------------------------- |
| **Artifact** | `note` (kept — self or group) | `article` / `page` (published) |
| **Speech** | `message` (sent) | [`post`](./post.md) (broadcast) |

(Naming trap from prior art: the IndieWeb's "note" post-type is a public short
utterance — _their_ note is our `post`, not our `note`.)
Expand Down Expand Up @@ -136,8 +136,10 @@ rests on: relationship targets that name a record in someone else's stack or an
identifier in another protocol, and a `relatedTo` filter that queries them. A
_bridge_ carries the rest — its typeId → `$type` translation table, and the content
addressing and tombstone machinery that describes a copy rather than the record it was
made from. Those are separate tracks from the content type: a `post` with no replies
and no bridge is fully expressible with what core provides.
made from. Those are separate tracks from the content type: [`post`](./post.md) is
defined and fully expressible with what core provides today, replies and bridge
machinery included; only the bridge itself (`adapter-atproto`, the `lexiconId` mapping,
`externalIds`) remains future work.

The shape this enables is the IndieWeb's POSSE pattern — publish on your own site,
syndicate elsewhere — with real primitives underneath: the canonical copy lives in your
Expand Down
4 changes: 2 additions & 2 deletions packages/commons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ await defineCommonsTypes(stack, [NOTE, TASK]);
const note = await stack.create(NOTE.id, { text: 'Hello, Haverstack!' });
```

Each export (`NOTE`, `BOOKMARK`, `TASK`, `CONTACT`, `ARTICLE`, `PLACE`, `PAGE`, `PHOTO`)
is a `{ id, name, schema }` triple mirroring its type's `stack.defineType(...)` block in
Each export (`NOTE`, `BOOKMARK`, `TASK`, `CONTACT`, `ARTICLE`, `PLACE`, `PAGE`, `PHOTO`,
`POST`) is a `{ id, name, schema }` triple mirroring its type's `stack.defineType(...)` block in
[`docs/commons/`](https://github.com/haverstack/core/tree/main/docs/commons) exactly.
`defineCommonsTypes()` is a thin loop over `stack.defineType()` — calling it again with
types already registered is the ordinary idempotent no-op path (see `defineType`'s own
Expand Down
10 changes: 10 additions & 0 deletions packages/commons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ export const PHOTO: CommonsType = {
},
};

export const POST: CommonsType = {
id: 'org.haverstack/post@1',
name: 'Post',
schema: {
text: { kind: 'text', required: true },
format: { kind: 'string' },
url: { kind: 'string' },
},
};

/**
* Registers each given commons type on `stack` via `defineType()`, exactly
* as written here. Sequential, matching `Stack`'s own system-type seeding —
Expand Down
4 changes: 3 additions & 1 deletion packages/commons/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import {
PLACE,
PAGE,
PHOTO,
POST,
defineCommonsTypes,
} from '../src/index.js';

const ALL = [NOTE, BOOKMARK, TASK, CONTACT, ARTICLE, PLACE, PAGE, PHOTO];
const ALL = [NOTE, BOOKMARK, TASK, CONTACT, ARTICLE, PLACE, PAGE, PHOTO, POST];

let adapter: MemoryAdapter;
let stack: Stack;
Expand Down Expand Up @@ -75,6 +76,7 @@ describe('read-compat cores', () => {
text: { kind: 'text', required: true },
},
[PHOTO.id]: { image: { kind: 'file-ref', required: true } },
[POST.id]: { text: { kind: 'text', required: true } },
};

test('every schema satisfies its documented read-compat core', () => {
Expand Down
Loading