Living document. Captures design decisions made so far.
A Stack is a structured, portable personal or organizational data store. It provides a unified API for reading and writing Records regardless of the underlying storage backend. Apps integrate a single library and don't need to know or care how data is stored.
A Stack is created via an async factory that reads identity and timezone from the adapter.
// First run — create a new database with initial config
const adapter = await LocalAdapter.initialize({
path: './my-stack.db',
entityId: 'abc123', // required — owner entity ID
timezone: 'America/New_York', // required — IANA timezone string
});
// Subsequent runs — open an existing database
const adapter = await LocalAdapter.open({ path: './my-stack.db' });
// Always the same — reads identity and timezone from the adapter
const stack = await Stack.create(adapter);
stack.ownerEntityId; // from adapter.ownerEntityId
stack.timezone; // from adapter.timezoneLocalAdapter.initialize() fails if the file already exists. LocalAdapter.open() fails if the file does not exist. This makes the distinction explicit and prevents silent config divergence.
Plugin and extension code that doesn't need to know the underlying backend should accept StackClient rather than the concrete Stack or ScopedStack. StackClient is the passable interface covering the full record API (create, get, query, update, delete, associate, dissociate, setPermissions, getVersions, getVersion, restoreVersion, getAttachment, putAttachment, deleteAttachment) plus a features getter. Both Stack and ScopedStack implement it.
Stack identity (ownerEntityId, timezone) is stored as a singleton _config@1 record in the records table. Adapters expose these values as typed readonly properties (adapter.ownerEntityId, adapter.timezone) rather than as a generic key/value store.
For the API adapter, identity values are sourced from the discovery endpoint (GET /.well-known/stack) when the adapter is opened and cached for the session as adapter properties.
An Entity represents the owner or author of a Stack — a person or organization. Entities are modeled as Records of the built-in system type _entity, rather than as a separate object type. This means Entities can have attachments (e.g. an avatar), relationships, and all other Record capabilities for free.
The Stack has a designated owner Entity, stored as a config value pointing to an _entity Record's ID.
Content fields:
type EntityContent = {
name: string; // Display name — human-friendly, not necessarily unique. May contain spaces and punctuation. e.g. "Jane Smith"
handle?: string; // Short unique identifier — URL-safe, no spaces. e.g. "janesmith". Like a username. Optional for private entities.
};An Entity record's entityId may point to itself (the owner Entity authored its own record).
Apps that write to a Stack are also modeled as Records, using the built-in system type _app. This allows querying all Records created by a specific app, and provides a foundation for future enforcement in the API adapter.
Content fields:
type AppContent = {
name: string; // Display name of the app e.g. "My Notes App"
version?: string; // Semver string e.g. "1.0.0". The app's unique machine-readable identity
// is captured by the _app record's appId (e.g. "com.example.myapp"),
// so no handle is needed.
};A Group is a set of Entities, modeled as a Record of the built-in system type _group. Groups serve two distinct purposes, distinguished by a single optional field:
- Permission group — lives in a personal stack, used purely to manage access to Records. No shared stack. Lets you grant permissions to a set of Entities without listing them individually on every Record.
- Collaborative group — a Group that additionally owns its own Stack, used as a shared workspace. The presence of
stackUrlin content is what makes a Group collaborative.
A permission group can be promoted to a collaborative group at any time by adding a stackUrl — no migration, no restructuring.
Content fields:
type GroupContent = {
name: string; // Display name — human-friendly, not necessarily unique. e.g. "Jane's Book Club"
handle?: string; // Short unique identifier — URL-safe, no spaces. e.g. "janes-book-club". Optional for private groups.
stackUrl?: string; // If present, this group owns a shared collaborative stack at this URL. Absent = permission-only group.
};Membership is expressed via associations on the _group Record, using the existing Association model:
{ kind: "relationship", label: "member", recordId: "<entity record id>" }
{ kind: "relationship", label: "admin", recordId: "<entity record id>" }This gives roles (member vs. admin) for free via association labels, and membership is queryable and versioned like any other Record data.
Implementation note for the API adapter: When enforcing group-based permissions, the server must resolve group membership by fetching the _group Record and walking its relationship associations. This requires the server to have read access to the stack where the _group Record lives.
A Grant authorises one or more Entities to perform specific actions on Records of a given Type. Grants are modeled as Records of the built-in system type _grant, making them queryable, versioned, and subject to the same lifecycle as any other Record.
Content fields:
type GrantContent = {
typeId: TypeId; // Which record type this grant covers
actions: GrantAction[]; // Which actions are permitted
};
type GrantAction =
| 'create' // Create new records of this type
| 'read-own' // Read records where record.entityId === requester
| 'read-any' // Read all records of this type
| 'update-own' // Update records where record.entityId === requester
| 'update-any' // Update all records of this type
| 'delete-own' // Delete records where record.entityId === requester
| 'delete-any'; // Delete all records of this typeStack.grant() is the owner-facing helper for creating grant records:
// Grant a specific entity permission to create comments and manage their own
await stack.grant('bob-entity-id', [
{ typeId: 'com.example/comment@1', actions: ['create', 'read-own', 'update-own', 'delete-own'] },
]);
// Default grant — applies to any authenticated entity (null entityId on the grant record)
await stack.grant(null, [{ typeId: 'com.example/comment@1', actions: ['create', 'read-own'] }]);Design decisions:
- No wildcard
typeId: there is no*or catch-all. Every grant is opt-in per type. Adding a new type never implicitly inherits existing grants — it starts default-deny. - Default grants (grant record has no
entityId): apply to any authenticated entity. Useful for "any logged-in user can comment" scenarios. Anonymous requesters (noentityId) are always denied, even under a default grant. - Actions are independent:
'create'does not imply'read-own', and so on. The combination['create', 'read-own', 'update-own', 'delete-own']is a common bundle for contributor access, but each action must be listed explicitly. -ownscope:-ownactions apply only to Records whererecord.entityIdequals the requester — Records the entity authored. Records with noentityId(owner-created) do not satisfy any-owncheck.
An Attachment record stores the metadata for an uploaded file. Attachment metadata is modeled as a Record of the built-in system type _attachment, separate from the binary content which is stored by the adapter.
Content fields:
type AttachmentContent = {
fileId: string; // SHA-256 hex hash of the file bytes — content-addressed ID
mimeType: string; // MIME type declared at upload e.g. "image/png"
size: number; // File size in bytes
filename?: string; // Original filename if provided at upload
};An _attachment@1 record is created each time stack.putAttachment() or scopedStack.putAttachment() is called — even if the same bytes were previously uploaded. Multiple _attachment@1 records may therefore exist for the same fileId, each with its own mimeType and filename. The binary is stored only once (content-addressed deduplication), but each upload gets its own metadata record.
When uploaded via Stack.putAttachment() (owner-level), the record carries no entityId. When uploaded via ScopedStack.putAttachment(), the entityId is set to the uploading entity.
A Type defines the schema for the content field of a Record. Types are identified by a namespaced, versioned string ID controlled by the app author — the app is the real coordination mechanism between stacks, so Type identity is scoped to the app that defined it.
type ScalarFieldKind =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'text' // Long-form string (e.g. markdown body)
| 'record-ref'; // Reference to another record by ID
type FieldDef =
| { kind: ScalarFieldKind; required?: boolean }
| { kind: 'array'; items: FieldDef; required?: boolean } // recursive
| { kind: 'object'; properties: TypeSchema; required?: boolean }; // recursive
type TypeSchema = {
[fieldName: string]: FieldDef;
};
type StackType = {
id: string; // Versioned identifier, e.g. "com.example.myapp/note@2"
baseId: string; // Derived from id by stripping version suffix, e.g. "com.example.myapp/note"
version: number; // Incrementing integer
name: string; // Human-readable label, e.g. "Note"
schema: TypeSchema;
schemaHash: string; // SHA-256 of canonical (minified, alpha-sorted) schema
migratesFrom?: string; // e.g. "com.example.myapp/note@1" — documents lineage
createdAt: Date;
};Array and object fields are schema-validated on write but opaque to the query engine in v1 — only top-level scalar fields support exact-match content filtering in queries.
Type identity: Two Types are the same if their id matches (including version). Two stacks running the same app will have the same Type IDs and can rely on that for interop.
Schema drift detection: If two Records share a typeId but their Type definitions have different schemaHash values, that is unambiguously a bug — intentional changes always produce a new version number.
Type compatibility: Structural/duck-typed — a Type is compatible with a required schema if it contains all required fields with matching kinds. Used by apps that want to work with Records regardless of exact Type, e.g. any Type with { text: string }:
function isCompatible(
candidateSchema: TypeSchema, // the Record's actual Type
requiredSchema: TypeSchema, // minimum fields the app needs
): boolean {
return Object.entries(requiredSchema).every(([key, def]) => {
if (!def.required) return true;
const field = candidateSchema[key];
return field !== undefined && field.kind === def.kind;
});
}Apps that care about semantics filter by exact typeId. Apps that want flexibility use isCompatible().
System types (reserved, library-defined): _config@1, _entity@1, _app@1, _group@1, _grant@1, _attachment@1. System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All six are pre-seeded when a Stack is created via Stack.create() — they are always available without any setup by the caller.
Apps register migration functions between adjacent Type versions at startup. The library composes them into a full migration graph, so an app that only knows about v3 doesn't need to know that v1 ever existed.
stack.registerMigration({
from: 'com.example.myapp/note@1',
to: 'com.example.myapp/note@2',
migrate: (content) => ({ ...content, title: '' }),
});
stack.registerMigration({
from: 'com.example.myapp/note@2',
to: 'com.example.myapp/note@3',
migrate: (content) => ({ ...content, pinned: false }),
});The migration registry is per-stack-instance — different stacks can be at different migration states without interfering. Registration is part of app startup, immediately after creating the Stack.
What the library does with registered migrations:
- Lazy migration on read —
stack.get()andstack.query()apply the migration chain in memory, returning Records as if they were the latest version. Nothing is written to disk. - Migration committed on update — when a record is next updated via
stack.update(), the library migrates existing content first, then applies the patch, then writes at the latest typeId. This is when lazy migration is persisted. - Path composition — migrations between adjacent versions are automatically chained (v1→v2→v3), so apps only ever register one step at a time.
- Warn on unmigratable reads — if a Record's Type version has no registered path to the latest, the library warns the app and returns the raw record.
- Batch migration —
stack.migrateAll("com.example.myapp/note")eagerly commits all pending migrations to disk in one deliberate pass. Version history is preserved before each write. Use before deployments or after major schema changes.
Not yet implemented: validation of migration function output against the target schema at registration time.
A Record is the fundamental unit of data in a Stack.
type StackRecord = {
// --- Core (always present) ---
id: string; // Crockford base-32, time-sortable, unique within a stack
typeId: string; // Versioned Type ID e.g. "com.example.myapp/note@2"
createdAt: Date;
updatedAt: Date;
content: Record<string, unknown>; // Validated against the Type's schema
version: number; // Increments on each write (for conflict detection)
// --- Optional native fields ---
parentId?: string; // ID of a parent Record (for hierarchy/folders)
entityId?: string; // Author Entity. Absent means owner-created — Records written directly by the stack owner carry no entityId.
appId?: string; // App that created this Record
deletedAt?: Date; // Present if soft-deleted
permissions?: Permission[]; // Access control (see Permissions)
associations?: Association[]; // Tags, attachments, relationships
};Design principle: Native fields are things the library needs to operate (routing, querying, syncing, hierarchy). Everything semantic and domain-specific goes in content.
Tags, attachments, and relationships are unified under a single Association model. All three associate a Record with a labeled payload — the label carries semantic meaning (e.g. "avatar", "parent", "reply-to").
type Association =
| { kind: 'tag'; label: string }
| { kind: 'attachment'; label: string; fileId: string; mimeType: string }
| { kind: 'relationship'; label: string; recordId: string };Examples:
- A
contacttype uses{ kind: "attachment", label: "avatar", fileId: "..." }as a profile picture. - A
tweettype uses{ kind: "relationship", label: "reply-to", recordId: "..." }to reference another tweet. - Any record can use
{ kind: "tag", label: "starred" }for user-defined labels.
Note: parentId is a separate native field (not an Association) because hierarchical containment is fundamental enough to warrant indexing at the library level. Associations are for metadata and cross-references.
Access control in a Stack has two complementary layers. ScopedStack (see below) enforces both.
All Records are private by default — readable only by the stack owner. The permissions field is absent or empty on private records; there is no explicit private permission value. Permissions represent grants of access, not restrictions. Enforcement is the responsibility of the API adapter. The JSON and SQLite adapters ignore the permissions field.
// Absence of permissions (empty or undefined) = private, owner only.
type Permission =
| { access: 'public' }
| { access: 'entity'; entityId: string; read: boolean; write: boolean }
| { access: 'group'; groupId: string; read: boolean; write: boolean };Group permissions reference a _group Record by ID. The group may be a simple permission group (living in the stack owner's personal stack) or a collaborative group with its own stack — the permission model is the same either way.
Permission resolution:
private— owner onlypublic— any requester can readentity— check the requester's entityId directlygroup— fetch the referenced_groupRecord, walk itsrelationshipassociations to verify the requester is a member or admin
Cross-stack group resolution (where the _group Record lives in a different stack than the Record being accessed) requires the server to have read access to that stack.
_grant records (see Grant) authorise Entities to perform actions across all Records of a given Type, without touching individual records. A read-any grant on comment@1, for example, makes all comments of that type readable by the grantee without setting permissions on each one.
ScopedStack checks both layers on every operation: if either the Record's own permissions or a matching _grant record permits the action, access is granted. The owner always has full access and bypasses both checks.
The core library ships a permission-enforcing wrapper so server implementations don't need to reimplement this resolution logic. stack.asEntity(entityId) — entityId is null for an anonymous/unauthenticated requester — returns a ScopedStack: the same surface as Stack, but every operation is checked against both permission layers.
ScopedStack.create() additionally checks _grant records for a 'create' action on the target type before allowing the Record to be written. Anonymous requesters are always denied. The owner always passes. The created Record's entityId is always set to the requester, so -own grants apply to it immediately.
Reading or writing a Record that exists but isn't accessible throws StackPermissionError; a missing Record throws StackNotFoundError, so callers can distinguish "not found" from "forbidden" (typically 404 vs 403 at the HTTP layer).
Plain Stack methods remain unscoped and perform no permission checks — correct for single-entity embedded use, where there's no requester distinct from the app itself. Use asEntity() when one Stack instance serves requests from multiple, possibly untrusted, entities, e.g. a server adapter.
ScopedStack.query()'s total is always null. The adapter's unfiltered count would otherwise leak the existence and cardinality of Records the requester can't read, even when the returned records array comes back empty. Computing an exact filtered count would require evaluating every match rather than just the returned page, so it's intentionally not attempted.
ScopedStack's group-membership check only resolves _group Records living in the same stack as the Record being accessed — it does not yet implement the cross-stack case described above. A server relying on cross-stack groups must still handle that case itself.
Version history is managed by the library as a side channel — apps do not manage it directly. On every update(), the library snapshots the previous state.
type RecordVersion = {
version: number;
content: object;
updatedAt: Date;
entityId?: string; // Who made this change
};API surface:
stack.getVersions(recordId)— retrieve version historystack.restoreVersion(recordId, version)— revert to a prior version
Storage per adapter:
- JSON: sibling file
{id}.versions.json - SQLite:
versionstable - API: server snapshots automatically on every
PATCH /records/:id; history is read via/records/:id/versions
The adapter contract is split into two focused interfaces that are composed into a single StackAdapter:
StackRecordAdapter — structured storage: capabilities, stack identity (ownerEntityId, timezone), all record/association/version/type methods, and optional lifecycle hooks (flush, close).
StackBlobAdapter — binary storage: putAttachment, getAttachment, deleteAttachment, and optional lifecycle hooks.
type StackAdapter = StackRecordAdapter & StackBlobAdapter;Packages follow a naming convention that makes the adapter type discoverable:
adapter-*— fullStackAdapter(convenience packages covering both halves)record-adapter-*—StackRecordAdapteronlyblob-adapter-*—StackBlobAdapteronly
| Package | Type | Use case |
|---|---|---|
adapter-local |
full | Local app storage — SQLite + disk blobs |
record-adapter-sqljs |
record | sql.js records, FTS, full query support |
blob-adapter-disk |
blob | Content-addressed blobs on disk |
adapter-api |
full | Hosted/shared stacks via HTTP |
adapter-json |
full | Portable JSON files (planned) |
adapter-local is the batteries-included package for the common local case. It wraps SQLiteRecordAdapter and DiskBlobAdapter and stores attachments in an attachments/ subdirectory next to the database file.
Use combineAdapters() from @haverstack/core when you want different backends for records and blobs — for example, SQLite records with S3 blob storage:
import { combineAdapters } from '@haverstack/core';
import { SQLiteRecordAdapter } from '@haverstack/record-adapter-sqljs';
import { S3BlobAdapter } from '@haverstack/blob-adapter-s3'; // hypothetical
const record = await SQLiteRecordAdapter.initialize({ path, entityId, timezone });
const blob = new S3BlobAdapter(bucketConfig);
const adapter = combineAdapters({ record, blob });
const stack = await Stack.create(adapter);All adapters support the full Record API. Performance guarantees differ; correctness does not.
Binary files are stored and retrieved through the library using content-addressed storage. A file's ID is the SHA-256 hash of its bytes, so uploading identical bytes twice returns the same fileId without writing a second binary copy. Each upload creates a new _attachment@1 record (see Attachment) regardless of deduplication, so metadata (mimeType, size, filename) is tracked per upload.
// Upload a file — returns a stable SHA-256 hex ID and creates an _attachment@1 record
const fileId = await stack.putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>
// Fetch the binary
const data: Uint8Array = await stack.getAttachment(fileId)
// Delete the binary and its _attachment@1 metadata record(s)
// Throws StackConflictError if any record still references the file
await stack.deleteAttachment(fileId)A fileId is referenced in an Association of kind "attachment". To read metadata for a given fileId, query _attachment@1 records:
const results = await stack.query({
filter: {
typeId: '_attachment@1',
content: { fileId },
},
limit: 1,
});
const meta = results.records[0]?.content as AttachmentContent | undefined;Stack vs ScopedStack attachment methods:
-
Stack.putAttachment(data, mimeType, filename?)— owner-level upload. Creates an_attachment@1record with noentityId. No grant check. -
ScopedStack.putAttachment(data, mimeType, filename?)— entity-scoped upload. Requires acreategrant on_attachment@1. The created record'sentityIdis set to the uploading entity. -
Stack.getAttachment(fileId)— no permission check; always succeeds if the bytes exist. -
ScopedStack.getAttachment(fileId)— accessible if the requester is the owner, can read any record that references the file, or uploaded the file themselves and it hasn't been associated with a record yet. ThrowsStackPermissionErrorotherwise. -
Stack.deleteAttachment(fileId)— deletes bytes and all_attachment@1metadata records for the file. ThrowsStackConflictErrorif any record still references the file. ThrowsStackNotFoundErrorif the file doesn't exist. -
ScopedStack.deleteAttachment(fileId)— owner only. ThrowsStackPermissionErrorfor non-owners. Delegates toStack.deleteAttachment().
Deduplication: Bytes are deduplicated — uploading the same content twice stores the binary only once. However, each call to putAttachment() creates a new _attachment@1 record with its own mimeType and filename. The same fileId may have multiple _attachment@1 records from separate uploads.
Queries are expressed as a Query object passed to stack.query(). All adapters support the full query shape; performance guarantees differ.
type Filter = {
// Native fields
typeId?: string | string[];
parentId?: string | null; // null = root records only
appId?: string | string[];
entityId?: string | string[];
createdAt?: DateRange;
updatedAt?: DateRange;
// Association filters
tags?: string[]; // records that have ALL of these tags
hasAttachment?: string; // records with an attachment of this label
relatedTo?: { recordId: string; label?: string };
attachmentFileId?: string; // records that reference a specific attachment file ID
// Content fields (exact match on top-level keys)
content?: { [key: string]: unknown };
// Full-text search (capability varies by adapter)
search?: string;
};
type DateRange = {
before?: Date;
after?: Date;
};type Query = {
filter?: Filter;
sort?: {
field: 'createdAt' | 'updatedAt' | 'version';
direction?: 'asc' | 'desc';
};
limit?: number;
cursor?: string; // Opaque cursor for page-based pagination
};Pagination is cursor-based rather than offset-based, so it works consistently across adapters and doesn't drift when records are inserted mid-page.
Adapters expose a capabilities object so apps can check what's supported before relying on a feature:
type AdapterCapabilities = {
fullTextSearch: boolean;
contentFieldQuery: boolean;
sortableFields: string[];
};AdapterCapabilities is the adapter-implementer-facing name. On the StackClient interface it is exposed as features: StackFeatures (StackFeatures is a type alias for AdapterCapabilities). App and plugin code should read stack.features rather than going through the adapter directly.
Per-adapter notes:
- JSON adapter — supports all filter fields via O(n) scan; may maintain
_index.jsonto speed up native field lookups;fullTextSearch: falsein v1 - SQLite adapter — indexes all native fields and association labels; supports content field queries and full-text search via FTS5
- API adapter — capabilities determined by the server; declared in a discovery endpoint
Records are never hard-deleted by default. Two levels of deletion are supported:
Soft delete — the default. A deleted Record is flagged with a deletedAt timestamp and excluded from normal queries, but remains recoverable. Version history is preserved. A soft-deleted Record is a tombstone — its current state is gone but its history is not.
Hard delete — permanent and explicit. Removes the Record and all its version history. Requires deliberate intent via a flag. The escape hatch for sensitive, secret, or harmful content.
stack.delete(recordId); // soft delete — reversible
stack.delete(recordId, { hard: true }); // hard delete — permanentQueries exclude soft-deleted Records by default. Opt in with:
stack.query({ filter: { includeDeleted: true } });Restore always creates a new version with the old content — it never rewrites history. The act of restoring is itself part of the version history.
stack.restoreVersion(recordId, version); // creates a new version, doesn't rewrite historyThe API adapter speaks REST over HTTP with JSON bodies and standard status codes. It is the only adapter where permissions are enforced and app identity can be validated.
A client hits this endpoint first to understand the server's identity and capabilities. The response supplies entityId and timezone, which the APIAdapter caches as ownerEntityId and timezone properties for the session.
GET /.well-known/stack
{
"version": "1.0",
"entityId": "abc123",
"timezone": "America/New_York",
"capabilities": {
"fullTextSearch": true,
"contentFieldQuery": true,
"sortableFields": ["createdAt", "updatedAt", "version"]
}
}Bearer token in the Authorization header. Token issuance is out of scope for the spec — that is the server's concern. The adapter sends the token if configured; the server returns 401 if missing or invalid.
Authorization: Bearer <token>
(As a non-normative example, SQLiteAdapter ships an optional hashed-token store — createToken / lookupToken / listTokens / revokeToken — for servers that want DB-backed bearer tokens without rolling their own storage. This is adapter-specific tooling, not part of the wire protocol; other adapters are free to manage tokens however they like, or not at all.)
Standard HTTP status codes are used throughout:
| Status | Meaning | When |
|---|---|---|
| 400 | Bad request | Structurally malformed request — missing a required field, unparseable query parameter, invalid JSON |
| 401 | Unauthorized | Missing or invalid bearer token |
| 403 | Forbidden | StackPermissionError — record exists but the requester lacks access |
| 404 | Not found | StackNotFoundError — record or version does not exist |
| 409 | Conflict | StackConflictError — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record) |
| 413 | Request entity too large | Attachment upload exceeds the server's size limit |
| 422 | Unprocessable entity | StackValidationError — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) |
The distinction between 400 and 422 matters for write endpoints (POST /records, PATCH /records/:id, POST /types): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema.
GET /records — query by native fields (see query params below)
POST /records/query — query including content field filters (JSON body)
POST /records — create
GET /records/:id — get one
PATCH /records/:id — update content only (partial merge, null = delete field)
DELETE /records/:id — soft delete
DELETE /records/:id?hard=true — hard delete
GET /records query params:
?typeId=
?parentId= (use "null" for root records)
?appId=
?entityId=
?createdBefore=
?createdAfter=
?updatedBefore=
?updatedAfter=
?tag= (repeatable: ?tag=starred&tag=important)
?hasAttachment=
?attachmentFileId=
?relatedTo=
?search=
?sort=createdAt|updatedAt|version
?direction=asc|desc
?limit=
?cursor=
?includeDeleted=
GET /records covers all native field queries and is usable from a browser or simple HTTP client without a JSON body. POST /records/query is a superset — it accepts the full Query object as a JSON body and additionally supports content field filtering. A server that declares contentFieldQuery: false in discovery does not support the POST query endpoint.
PATCH /records/:id accepts a partial content object. Omitted fields retain their current values. A field set to null is removed (RFC 7396 / JSON Merge Patch). Associations and permissions are managed via their own endpoints.
GET /records/:id/permissions — get current permissions
PUT /records/:id/permissions — replace all permissions (empty array = private)
Response envelope:
{
"records": [...],
"cursor": "opaque-string-or-null",
"total": 142
}The server snapshots a record's state automatically on every PATCH /records/:id — there is no client-initiated endpoint to write a version directly.
GET /records/:id/versions — list all versions (newest first)
GET /records/:id/versions/:version — get a specific version
POST /records/:id/restore/:version — restore a version (creates new version, no rewrite)
Associations are always in the context of a Record:
GET /records/:id/associations — all associations
GET /records/:id/associations?kind=tag
GET /records/:id/associations?kind=attachment
GET /records/:id/associations?kind=relationship
GET /records/:id/associations?label=avatar — filter by label across all kinds
POST /records/:id/associations — add an association
DELETE /records/:id/associations — remove an association (by body)
Response shape is consistent regardless of kind:
{
"associations": [
{ "kind": "tag", "label": "starred" },
{ "kind": "attachment", "label": "avatar", "fileId": "abc123", "mimeType": "image/png" },
{ "kind": "relationship", "label": "reply-to", "recordId": "xyz789" }
]
}GET /types — list all types known to this stack
GET /types/:id — get one type definition (id is URL-encoded)
POST /types — register or replace a type
POST /attachments — store raw file bytes, returns { fileId }
GET /attachments/:fileId — download a file
DELETE /attachments/:fileId — delete a file
Attachments are uploaded first to get a fileId, then referenced in an Association when creating or updating a Record. This keeps all Record endpoints JSON-only.
File IDs are SHA-256 hashes of the content. Uploading identical bytes twice returns the same fileId without writing a second copy.
Upload: Send the raw binary as the request body. Content-Type and Content-Disposition headers are ignored — POST /attachments stores bytes only and does not create an _attachment@1 record. To record metadata (MIME type, filename, size), create an _attachment@1 record via POST /records after upload.
POST /attachments
Authorization: Bearer <token>
<binary data>
Returns 413 Request Entity Too Large if the payload exceeds the server's configured limit (default 50 MB, controlled by MAX_ATTACHMENT_BYTES).
The SDK's Stack.putAttachment() and ScopedStack.putAttachment() perform both steps automatically. Direct HTTP callers must create the _attachment@1 record separately if metadata is needed.
Download: Two optional query parameters control the response metadata and, when both are supplied, allow the server to skip the _attachment@1 database lookup entirely:
| Parameter | Effect |
|---|---|
?contentType |
Sets Content-Type on the response. Dangerous types (HTML, SVG, JS, XML) are forced to application/octet-stream regardless. |
?filename |
Sets the filename in Content-Disposition. Also infers Content-Type from the file extension when ?contentType is omitted. |
GET /attachments/<fileId>?contentType=image/png&filename=photo.png
When neither parameter is provided the server queries the _attachment@1 record: the stored mimeType becomes Content-Type, and the filename is taken from the requester's own _attachment@1 record (if one exists). Falls back to Content-Type: application/octet-stream when no metadata record is found.
Delete: Owner only. Returns 409 Conflict if any record in the stack still references the file (i.e. has it in an attachment association or its content references the fileId). The bytes and all _attachment@1 metadata records for the file are removed atomically on success.
Attachment permissions are governed by the Record(s) that reference them, not the attachment itself. If any Record referencing a fileId is accessible to the requester, the attachment is accessible. A non-owner requester can also access a file if they own an _attachment@1 record for it, enabling access in the window between upload and record association.
GET /entity — get the stack owner's entity record
PATCH /entity — update it
A convenience alias for the owner entity rather than requiring clients to look it up by ID.
- Multi-stack patterns — apps managing multiple stacks (personal + group stacks) will likely repeat common fan-out and merge patterns; a
StackWorkspaceabstraction is a likely future addition once real usage patterns emerge