Status: Implemented (M4) — the free surface is live:
app/api/publik+app/api/synkroute handlers,lib/services/, Auth.js atauth.ts, migrations underdb/migrations/, and the client sync engine inlib/sync/. Basik/Klub monetization (M5) remains unbuilt and is gated behind the Core Product phases (vision.md § Roadmap). This document remains the normative contract. The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
Scope
M4 delivers the free service surface on arkaik-hosted infrastructure:
| In scope (M4) | Out of scope (M5+) |
|---|---|
| Publik — anonymous snapshot sharing with owner-key deletion | Payments, pricing page, Basik/Klub gating (Stripe) |
| Synk — accounts + one-way interval JSON backups with 7-day retention | Real-time sync, multi-device merge (Basik/Klub) |
| Tier enforcement points, config-driven, defaulting to Synk limits | Server-side ref integrations (Klub differentiator) |
| Inkognito redefined as full-stack self-hosting (see below) | Hosted asset buckets / pack upload path |
Three hard boundaries, inherited from vision.md and journal.md, that no M4 implementation may cross:
- The browser is the source of truth for every tier. The server stores backups and shares; it is never the system of record. No server-side mutation of bundles beyond validated storage.
- No re-projection. The server never derives a snapshot from a journal or vice versa. It stores what the client sent, verbatim (minus the Publik journal strip below).
- No event-sourcing promises. M4 sync is whole-bundle backup. The journal rides inside the bundle as opaque history; journal-based merge is a Basik/Klub design problem for M5+.
Boundary 1 no longer holds for hosted projects — decision record
Hosted projects (
graph_projects, migration 008) deliberately cross boundary 1. For a project in an account, the server is the system of record, and it does mutate bundles:applyMutationapplies ops under a row lock, and the GitHub App promotes acceptance statuses from pull-request events with no browser involved.This is recorded rather than quietly edited because boundary 1 is load-bearing everywhere else, and it still holds everywhere else. Synk and Publik are unchanged: they remain backup and share, storing what the client sent. Boundaries 2 and 3 are untouched even for hosted projects — the server derives events from the diff it just applied, which is emission, not re-projection, and there is still no merge.
Why the boundary was worth crossing. The browser cannot be the system of record for a graph that a coding agent in any repository, and a webhook GitHub calls, both write to. Keeping it there would have required two-way sync, conflict resolution and merge — the M5+ problem boundary 3 defers — to deliver a feature whose whole point is that there is one copy.
What still holds, and is what "validated storage" was protecting. Every server mutation passes the same
validateBundlethe CLI and MCP use, in the same transaction, and is refused whole on any error. Every change lands as an ordinary journal event with a real actor (github-appfor the App), so the history says what acted. The format stays portable and export is always available: a hosted project can be exported and re-imported as a local one at any time.The cost, stated plainly: hosted projects do not work offline. Local-first projects still do, and remain the default.
See hosted-projects.md for the how-to and bundle-format.md § References for the promotion rules.
Backend — Decision Record
Chosen: Vercel-native. Next.js route handlers under app/api/ for compute, Postgres (Neon via Vercel) for all storage. No Supabase.
- One store: snapshots are ≤ 5 MB JSON (the app's own import cap), which fits
jsonbrows comfortably — no blob store needed in M4. Assets travel inside the bundle as data URIs (the only form the app produces today, capped at 2 MB each); a hosted bucket becomes relevant only with M5 asset uploads. - Auth is Auth.js (NextAuth v5) with the Postgres adapter — see Synk below.
- Consequence, recorded honestly: the original "Inkognito = BYO Supabase provider" framing is superseded, because there is no Supabase implementation to point a sovereign user at. Inkognito is redefined below.
- Everything under
app/api/,db/, andlib/services/is AGPL-3.0 (services side of the license split, toolchain.md § Licensing).
Environment variables (all server-only, none NEXT_PUBLIC_): DATABASE_URL, AUTH_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET. The app MUST boot and serve every existing local-first surface when they are unset — services degrade to absent, never break the client-only app.
Migrations: plain SQL files in db/migrations/NNN_name.sql, applied by a small idempotent runner (npm run db:migrate) that records applied migrations in a _migrations table. Committed, ordered, append-only — the same files are the self-hosting setup scripts (Inkognito).
Publik
Anonymous, account-less snapshot sharing. Framing: GitHub Gist for product graphs.
Protocol
| Endpoint | Auth | Behavior |
|---|---|---|
POST /api/publik | None (rate-limited) | Body: a ProjectBundle JSON. Server validates, strips, stores. Returns 201 { id, url, owner_key } |
GET /api/publik/{id} | None | Returns the stored bundle JSON (content-type: application/json) |
DELETE /api/publik/{id} | Authorization: Bearer <owner_key> | Deletes iff sha256(owner_key) matches. 204 / 403 |
POST /api/publik/{id}/report | None (rate-limited) | Increments report_count; over threshold flags for review. 202 |
Server-side rules on POST:
- Validate with
@arkaik/schema:parseBundle+validateBundle. Errors →422with the structured findings; warnings pass. The server accepts conformance Levels 0–2, like every consumer. - Journal stripped by default, enforced server-side. The privacy default of journal.md ("Publik publishes without the journal") MUST NOT depend on client behavior alone: the server removes
journal[]unless the request explicitly opts in with?include_journal=true. History leaking through a forgotten CLI flag is not an acceptable failure mode. - Size cap 5 MB (mirrors the app's import cap) →
413. id: server-generated, URL-safe, ≥ 10 chars of ≥ 64-bit randomness (unguessable, non-sequential).owner_key: UUID v4, returned exactly once; the server stores only its SHA-256 hash. Lost key = no deletion path in M4 (account-link recovery is an open question, tracked in vision.md).- Rate limiting: per-IP creation throttle (order of 10/hour) enforced in Postgres — no extra infra dependency.
429withretry-after.
Storage
publik_snapshots (
id text primary key,
owner_key_hash text not null,
bundle jsonb not null, -- verbatim post-strip, schema-validated
schema_version int not null,
title text not null, -- denormalized from project.title for listings/preview
size_bytes int not null,
report_count int not null default 0,
created_at timestamptz not null default now()
)
Snapshots are immutable — there is no update endpoint; re-publishing creates a new id. Retention: no guarantee (per the published disclaimer); M4 keeps snapshots indefinitely but the schema and disclaimer reserve the right to expire.
Surfaces
/p/{id}page (dynamic route, hosted app only): server-fetches the snapshot and renders a preview — title, description, node/edge counts, format-level badge, created date — plus an Import into arkaik button and the no-retention disclaimer. The import button fetches the JSON client-side and funnels it through the existingimportProjectpath (lib/utils/export.ts), which already handles validation, timestamp repair, and ID-collision rewriting. Not in M4: rendering the full read-only graph server-side — that requires the provider-injection seam (seedocs/rfcs/arkaik-dev.mdblocker #2) and is an enhancement, not a launch requirement. "Anyone with the URL can import and edit a copy locally" is the promise; the preview page fulfills it.- In-app Publish action (project list / project shell): confirmation dialog (what will be public, journal excluded, no retention guarantee) → on success shows the URL and the owner key once, with copy buttons and a "save this key" warning.
- CLI
arkaik push(the Phase-4 command reserved in toolchain.md):arkaik push [path]= validate → pack--no-journal→POST /api/publik→ print URL + owner key.arkaik push --delete <id> --key <owner_key>=DELETE.--include-journalforwards the explicit opt-in. No update verb — immutability above.
Moderation
Minimal but real: the report endpoint + report_count threshold flag, an admin deletion path (direct SQL or a maintenance script — no admin UI in M4), and a published contact route for takedowns in the site footer/docs. This resolves the vision's "Publik moderation" open question at the process level; tooling can grow later.
Synk
Accounts plus one-way interval backups of local projects. The tier's own definition (vision.md § Infrastructure): "Backup service only" — deliberately not a database-of-record.
Auth
Auth.js (NextAuth v5), Postgres adapter, GitHub OAuth as the launch provider (the audience is developers; email magic-links need an email vendor and can follow). Sessions: Auth.js defaults (JWT). The adapter owns users / accounts / sessions tables; arkaik adds:
alter table users add column tier text not null default 'synk';
Backup protocol
| Endpoint | Auth | Behavior |
|---|---|---|
PUT /api/synk/projects/{projectId} | Session | Body: full bundle (journal embedded). Validates, enforces limits, stores a backup version. 201, or 200 { deduped: true } |
GET /api/synk/projects | Session | Lists the caller's backed-up projects (latest backup metadata each) |
GET /api/synk/projects/{projectId}/backups | Session | Lists retained backup versions (id, created_at, size, content hash) |
GET /api/synk/backups/{backupId} | Session | Returns the bundle JSON for restore |
DELETE /api/synk/projects/{projectId} | Session | Removes the project and all its backups from the server |
Rules:
- Authorization is by ownership: every row carries
user_id; every query filters on the session's user. (App-layer authorization replaces the RLS concept from the superseded Supabase framing — same guarantee, enforced in one place.) - Content-hash dedupe: the client serializes with
serializeBundle()(canonical form — deterministic bytes) and sendssha256alongside; if it equals the latest stored hash, the server records nothing. Canonical serialization is what makes "did anything change?" a byte comparison. - Journal included. Backups are the user's private data; unlike Publik there is no strip. A restored backup round-trips history intact.
- Retention: backups older than 7 days are pruned on write (no cron dependency), except the newest backup per project, which is never pruned regardless of age.
- Limits (see Tier Enforcement): Synk = 1 project, ~250 entities (nodes + edges). Violations →
403with a structured{ limit, actual, tier }body the client can render.
Client sync engine
- Provider-injection seam (prerequisite): introduce
getProvider()and a mutation-notification channel on the provider (a lightweightsubscribe(cb)that fires after each successful mutation transaction). This is the same seamdocs/rfcs/arkaik-dev.mdcalls for — built once, serving both. SyncManager(lib/sync/): on mutation notification, debounce ~60 s (the "interval backup (~1 min)" promise), thenexportProject→ canonical serialize → hash →PUT. Also: a manual "Back up now" action, and visible per-project status (backed up · pending · error · limit-exceeded).- One-way, up. Restore is an explicit user action (pick a version → import as local project, existing collision handling applies). The engine MUST NOT write server state into the local store unprompted.
- Lokal → Synk conversion (the vision's "primary conversion funnel"): after first sign-in, existing local projects are offered for backup with one click each — the data never moves, it gains a backup. No migration of storage, no account-gating of local features.
Hosted Graph Projects
The Klub tier's database-of-record for a project's graph — where Synk stores backups of a local project, this holds the authoritative snapshot and journal, and agents write to it over HTTP (db/migrations/008_graph_projects.sql; lib/services/graph/store.ts is the only module that touches those tables).
Machine auth
One seam answers "who is calling?" for every route: getCaller() in lib/services/auth.ts.
- Two kinds of caller. An interactively signed-in human (Auth.js session) or a machine holding a bearer token (
Authorization: Bearer …, minted in project settings and stored hashed inapi_tokens). - Scopes.
graph:readandgraph:writeare the agent plane;synkis the backup API. Three append-only scopes exist for credentials that live in a public repository's CI secrets, and none is a default:quality:appendopens…/quality/eventsfor a batch of trips,release:appendopens…/live(issue #424), andscreenshot:appendopens…/screenshotsfor a batch of CI screenshot URLs. A token carries the scopes it was minted with; a session caller carries all of them — scopes exist to limit machines, not people. A caller without the scope gets403 { error: "insufficient_scope", required }. The three append doors name their append scope inrequired— the narrowest one that opens them — and…/quality/eventsnamesgraph:writeinstead once it has parsed a batch that holds more than trips. - Owner scoping, not user scoping. Every statement filters on the caller's owner ids (
lib/services/owners.ts), so shared ownership works and a project belonging to another owner is404, never403— the API cannot be used to probe for project ids.
Write path
| Endpoint | Scope | Behavior |
|---|---|---|
POST /api/graph/projects/{id}/mutations | graph:write | The graph write path: a batch of typed ops applied atomically, journalled, version bumped |
PUT /api/graph/projects/{id}/bundle | graph:write | Wholesale restore — snapshot and journal replaced together |
PATCH /api/graph/projects/{id} | graph:write | Project-level fields only (title, description, version, metadata) — deliberately cannot touch nodes or edges |
DELETE /api/graph/projects/{id} | graph:write | Archive (archived_at); leaves the listing, stays readable |
POST /api/graph/projects/{id}/quality/events | graph:write (quality:append suffices for a batch of trips only) | Journal-only quality events, five whitelisted types: the finding decisions quality.finding.resolved/accepted, quality.signal.tripped, and a hosted scoped re-audit's quality.assessment.scored and scoped quality.audit.completed (issue #473) — no snapshot change, no version bump. All-or-nothing; refusals name each entry's index and reason (lib/services/graph/quality-events.ts) |
POST /api/graph/projects/{id}/live | graph:write or release:append | The deployment door (issue #424): { entries: [{ node_id, platform, detail? }] } marks each acceptance live on that platform and nothing else — platform required, acceptances only, never the base status. An ordinary mutation (applyMutation: lock, validators, version bump) landing as node.status_changed with platform, the entry's detail and actor arkaik-ci (arkaik-app for a session). All-or-nothing refusals (unknown_node, not_acceptance, archived (base status or the platform's own entry), platform_not_applicable) are 422; already_live is a 200 under skipped, so a re-run writes nothing (lib/services/graph/live.ts). No If-Match: the plan is computed from a read outside the lock, so a version conflict re-reads and re-plans up to three times before answering 409 — a retry can only converge (already_live) |
POST /api/graph/projects/{id}/screenshots | graph:write or screenshot:append | The screenshot door (spec 2026-11-01): { entries: [{ node_id, platform, url, commit, order, detail? }] } records a CI screenshot URL for each slot and nothing else — views and acceptances only, https only, source forced to ci. One record_screenshot op per entry through applyMutation, landing as screenshot.recorded with actor arkaik-ci. Refusals (unknown_node, not_shot_bearing, platform_not_applicable, invalid_url) are an all-or-nothing 422; stale_order (below the slot's ci_order watermark) and unchanged are 200 skips that write nothing, so a re-run or an out-of-order run is harmless (lib/services/graph/screenshots.ts). Same no-If-Match, re-plan-on-conflict posture as …/live |
Rules:
If-Matchis strong, and it is the snapshotversion(a bigint, returned as a string in the project GET's JSON body). A stale version writes nothing:409on…/mutations,412onPUT …/bundle(a known, deliberate inconsistency, documented at the mutations route).PUT …/bundlerequires the header —428when it is absent.- A weak validator in
If-Matchis refused, never applied.PUT …/bundleanswers400 if_match_unsupported(classifyIfMatchinlib/services/graph/restore.tstreatsW/…,*and comma lists as unsupported shapes); on…/mutationsaW/"…"cannot equal a decimal version and lands as a409. This is what makes the weak read validators below safe to hand out. - The version bumps on every snapshot write and never on a journal-only append. An appended
deliverable.shippedor quality event grows the journal while the snapshot — and the version every writer is racing on — stands still.
Read contract
| Endpoint | Body | Read validator |
|---|---|---|
GET /api/graph/projects/{id} | The bundle with quality decisions and hosted scores folded in, plus version | W/"<version>.<folded quality event count>" |
GET /api/graph/projects/{id}/nodes | { nodes } | W/"<version>" |
GET /api/graph/projects/{id}/edges | { edges } | W/"<version>" |
GET /api/graph/projects/{id}/journal | { journal }, server order | W/"<version>.<event count>" |
GET /api/graph/projects/{id}/journal/stats | { stats: { total, releases: [{ version, eventCount }] } } — the event total and each release's changelog size | W/"<version>.<event count>" |
GET /api/graph/projects/{id}/journal/page?before=&limit=&families= | { page: { events, next } }, newest first in (ts, id) order | W/"<version>.<event count>.<page tag>" — the page tag is a hash of the canonical before, limit and families |
GET /api/graph/projects/{id}/export | { bundle } with the journal embedded | W/"<version>.<event count>" |
GET /api/graph/projects/{id}/pollen?after=&limit= | { pollen }, one page of the projected journal | W/"<version>.<event count>.<page tag>" — the page tag is a hash of after and limit |
- Every read answers with
ETag,Cache-Control: private, no-cacheandVary: Authorization— on the200and on the304alike.privatebecause every body is owner-scoped,no-cachebecause a client must revalidate rather than reuse blind,Varybecause a bearer token selects the owner. If-None-Matchearns a bodiless304. The comparison is weak (RFC 9110 § 8.8.3.2): a case-insensitiveW/on either side is ignored,*matches any representation that exists, and a comma list matches on any member. A matching conditional read costs the auth queries plus one validator statement — no snapshot is loaded, nothing but headers is written.- The validators are weak on purpose. Marking them weak is what makes a client that echoes a read ETag into
If-Matchfail loudly (see the Write path) instead of silently writing against a version it never read. - Each route validates on exactly what its body depends on.
/nodesand/edgesmove only when the snapshot does./journaland/exportmove on every appended event. The bundle GET counts the quality events it folds only — the two finding decisions andquality.assessment.scored(QUALITY_FOLD_TYPES), because a merged PR'sdeliverable.shippedmust not turn the next map revalidation into a multi-megabyte200. - Counts, not
max(seq). Journal appends are not transactional, so two concurrent appends can commit out ofseqorder and a max taken between them would never learn about the earlier row. A count moves on every commit whatever the order, and the only deleter (a bundle restore) always bumps the version. It also keeps the platform-widebigserialout of a tenant-visible header. - An archived project still reads. Only the writes refuse it. A conditional read answers
304/200for exactly the projects an unconditional read answers200for — otherwise an archived project would start404ing the moment a client revalidated. ?types=projects the journal read.GET …/journal?types=a,b— repeatable and comma-separated (?types=a&types=bis the same request), tokens trimmed, empties dropped, deduped and sorted into a canonical list. No projection, an empty one, or an unknown type are all valid: the first two mean the whole journal, the third answers an empty list with200, so a client reading a forward-compatible event type never breaks against an older server. More than 32 distinct types is400 { error: "invalid_types" }— fail closed, because a silently truncated list would answer with a subset the caller cannot detect. The projection belongs to/journalalone:/nodes,/edges,/journal/stats,/journal/pageand/exportignore the parameter entirely, since/exportbuilds an interchange bundle and a bundle missing most of its history would restore as data loss./journal/statscounts with the browser's code. The Overview's aggregate iscomputeReleaseEventCountsfrom@arkaik/schema— the same projection the page ran over the whole journal — not a SQL window, because a release's window follows(ts, id)order, a re-tagged version resolves to its latest marker, and a platform-scoped release counts only events touching that platform's nodes. Each row leaves Postgres reduced toRELEASE_COUNT_EVENT_FIELDS, and node platforms are read from the snapshot only when some release is platform-scoped. It shares/journal's validator: an append moves the total, and node platforms move only with the version./journal/pagepages in time order, not arrival order. It is the History page's read and the SQL twin ofpageJournalin@arkaik/schema: newest first by(ts, id)—orderEvents' order reversed — keyset-paged on migration 012'sgraph_events_project_time_idx, whose expressionsgetJournalPagemust spell exactly. Notseq: an append minted with an earlier timestamp (a merged PR's deliverable) arrives late but happened early, and History shows it where it happened. Event ids are unique per project, so the cursor is[ts, id]as JSON;nextis what to send back asbefore,nullon the last page.familiesfilters by the History chips' type prefixes (JOURNAL_FAMILIES); a type no family claims appears only unfiltered. Every parameter fails closed with a400(invalid_cursor,invalid_limitoutside 1–500,invalid_families) — a closed vocabulary of six is a typo, not a forward-compatible type, and a clamped limit would hand back a page the caller did not size for. Default limit 100.- A projection carries the whole journal's validator. One aggregate per project rather than one per projection: an append of a type a projection does not carry still invalidates it. A superfluous
200is cheap; a missed one would leave a page rendering history that no longer exists. - A
304is never an existence oracle. The validator statement is owner-scoped exactly like the body load, so another owner gets404whatever it sends,*included.
Pollen Feed
GET /api/graph/projects/{projectId}/pollen?after=<id>&limit=<n> — the
project's journal projected to pollen envelopes (the Ariko federation
contract; the normative document lives in the ariko repo as docs/POLLEN.md,
its reference validator vendored at lib/pollen/contract.ts with the
conformance fixtures at tests/fixtures/pollen/).
- Opt-in: served only when
project.metadata.pollen.plantis set (project settings → Federation); otherwise the samenot_founda missing project produces, so the feed cannot be used to probe for ids either. - Auth:
graph:read, owner-scoped — identical to…/journal. - Order & cursor: journal server order.
afteris the last pollen id the consumer processed; an unknownafteranswers 410 Gone (drop the cursor, rebuild from the start).limitdefaults to 100, capped at 200. An empty array means caught up. - Mapping (v1):
deliverable.shipped→shipped(re-appends emit acorrectsref to the first occurrence's envelope),release.tagged→release.tagged,decision.status_changedintoapproved→decided. Everything else is not exported. An event the contract cannot express is skipped and logged, never a 500. - Rebuilds: a bundle restore replaces the journal wholesale; surviving event ids keep their envelope ids (ULIDs are preserved), vanished cursors get the 410. This is the contract's coordinated-rewrite case.
- Conditional: every answer carries the read headers above with the
validator
W/"<version>.<event count>.<page tag>"— the journal's validator plus a hash of this page'safterandlimit, so a consumer that stores one ETag beside one cursor can never earn a 304 for one page with the tag of another. A matchingIf-None-Matchanswers a bodiless 304 from the auth queries, one owner-scoped statement (validators and plant slug together, so a feed-less project still 404s and*is no existence oracle) and one index lookup for the cursor; neither the bundle nor the journal body is read. The cursor check runs before the conditional answer: an unknownafteris 410 whatever the header says,*included, because a gone cursor is not a representation to be unmodified from. A 200 loads only what the projection reads — node{ id, title }pairs and the events of the three projected types — never the whole bundle or the whole journal.
The webhook's Lab-Note half feeds this: a merged PR of a linked repository
whose body carries a ## Lab Note section lands one deliverable.shipped
event (full note under lab_note, actor github-app) per linked project,
idempotent by content — see journal.md § Event Vocabulary and
lib/services/github/lab-note.ts.
Tier Enforcement Points
M4 builds the sockets Basik/Klub will plug into, and nothing else:
// lib/services/limits.ts — server-side source of truth
export const TIER_LIMITS = {
synk: { projects: 1, entities: 250, retention_days: 7 },
basik: { projects: 3, entities: 1000, retention_days: 30 },
klub: { projects: Infinity, entities: Infinity, retention_days: Infinity },
} as const;
Enforcement lives in exactly two places: the PUT backup handler (projects + entities) and the prune step (retention). users.tier selects the row; M4 has no path that sets it to anything but synk. M5's billing work flips a column — it does not touch enforcement.
Inkognito — Redefinition
The original framing ("local-first backed by the user's own Supabase project") assumed a hosted Supabase implementation to mirror. With the Vercel-native decision there is none, and maintaining a second, Supabase-specific backend solely for the sovereign path would fork every services feature forever.
Redefinition: Inkognito is full-stack self-hosting. The app and services are AGPL — the sovereign path is to deploy them:
- Deploy the repo (Vercel, or any Node host) + any Postgres (Neon, RDS, or Supabase's Postgres — the SQL is plain Postgres, so a Supabase project still works as the database).
db/migrations/*.sqlare the release-aligned setup/migration scripts the vision promised, keyed to releases;schema_versionhandles bundle-format migrations independently.- Sovereignty comes from owning the deployment, not from a bespoke provider: same code, same features (minus arkaik-operated AI), zero arkaik infra involvement.
This supersedes issues #49 (SupabaseProvider) as originally framed. What remains true from that framing: the DataProvider seam stays backend-agnostic, and nothing in M4 precludes a future third-party provider implementation if demand exists.
Security & Privacy
- Owner keys and session secrets are never logged; owner keys stored only as SHA-256 hashes.
- All SQL through parameterized queries; all inbound bundles through
@arkaik/schemavalidation before touching storage. - Publik strips journals server-side by default (above) — private history requires explicit opt-in to leak.
- Account deletion removes the user row and cascades to all backups (
on delete cascade). - The client never receives another user's rows: every Synk query is user-scoped; Publik reads are by unguessable id only (no listing endpoint).
CI Additions
The workflow gains a services job: Postgres service container, npm run db:migrate against it (migration integrity check), and API integration tests (route handlers invoked against the migrated schema — Publik create/fetch/delete/strip, Synk auth-required/limits/dedupe/retention). The existing generated-artifact drift gate is unaffected; there are no generated SQL artifacts in M4.
Open Questions
- Publik owner-key recovery: account-link fallback once Synk accounts exist (a signed-in publisher could bind snapshots to their account)?
- Publik abuse posture beyond rate limiting: do we need proof-of-work or captcha if scripted publishing appears?
- Synk backup encryption at rest beyond Postgres defaults — is client-side encryption worth the key-management UX cost for a backup tier?
-
arkaik push --to synk(CLI backups into an account) needs a device-token auth flow — M5 with Basik/Klub, or earlier?