Appearance
Legal — terms, versions, and the acceptance ledger
backend/src/workspace/legal/ is the contract layer: which documents exist, who they are for, what is published, and (historically) who accepted it. It sits in the spine because it is identity-adjacent — the public /legal pages and the operator dashboard resolve through the same code.
There is no in-app acceptance gate (removed 2026-08-18). Agreement is by continuing: the "By continuing, you agree to…" line under /register (ToS + Privacy) and /join (ToS or Platform Access Terms by invite kind) is the whole legal surface — no dialog, no checkbox. The LegalGate dialog, GET /api/legal/pending, POST /api/legal/accept, and the pendingFor / acceptPending / requiredDocumentIds / pendingFromState resolvers were deleted with it; the trigger was an invited guest who could not get past the dialog, and Leon's call was to remove every popup rather than debug one.
The commercial model it encodes
The agency is the only customer (simplified 2026-08-12; the earlier agency/direct split and its five-document catalog are gone). Luniq contracts with the account, and the whole catalog is three documents:
| Who | Agrees to | Why |
|---|---|---|
Anyone in an account (account_members row) | Terms of Service | ONE contract: the agency terms (clients, reselling, white label) are body sections and data processing is Annex 1 inside the same document — one version trail |
A workspace guest (workspace_members row, no account) | Platform Access Terms | An agency's client is not our customer; serving them the ToS would assert a relationship that does not exist |
| Everyone | Privacy Policy | Public, informational |
The ToS's "Your clients" section is where the agency warrants it had authority to connect a client's site and to give the people it invites access.
accounts.type still exists but no longer forks the documents — any account type resolves to the customer set. It is not a feature flag either — experience (workspace/experience.js) is the entitlement knob (the per-workspace module switches were removed 2026-08-14).
Three files, three jobs
| File | Owns |
|---|---|
documents.js | The catalog (LEGAL_DOCUMENTS, with audience) + readSource(id) |
versions.js | Publishing, currentVersions(), catalogWithStatus() |
acceptances.js | READ side of the append-only ledger: listAcceptancesForAccount() + acceptanceCounts() — nothing writes it any more |
index.js | Barrel only |
Code owns the rules, data owns the state. What documents exist and who they are for are code. The TEXT is authored in the internal dashboard since 2026-08-12 (Leon's call — versions are drafted where they are published): the repo markdown in workspace/legal/documents/*.md seeded v1.0 and remains the fallback for a publish that carries no body, but the published snapshot in the row is the sole authority on what anyone agreed to, and the version trail is the history. Adding a document is still a reviewed commit; publishing one is an operator action.
Adding a document = one entry in LEGAL_DOCUMENTS + one markdown file. The API, the /legal pages, and both dashboards are driven off that list.
audience says who a document is for: customer (every account), authorized-user (belongs to no account — a workspace guest), public (informational). The frontend notice line mirrors it by hand (LegalNotice audience="customer" | "guest").
Publishing snapshots the text
publishVersion() takes the authored body (the dashboard's markdown editor) — or reads the repo markdown when no body is given — strips the HTML authoring comments (repo metadata, not contract text — the first publish shipped the draft note into the body, which is why the strip lives in the publish path, not in the files' discipline), refuses an implausibly short body (a fat-fingered empty editor must not become the version everyone is asked to accept), and copies the result into the row. That copy is the evidence — "which words did they agree to" must never depend on git archaeology, and a later deploy must not be able to change what someone already accepted. Rows are never updated; (document_id, version) is unique, so re-publishing a label fails rather than rewriting history.
effective_at may be in the future — "these terms change on the 1st" is a normal publish, and the version simply is not current until then. The current version is the newest EFFECTIVE one (effective_at desc, created_at desc as tie-break), which has a same-day trap: a date-only "effective from" is midnight UTC, so re-publishing later the same day with today's date would rank BELOW the morning's version and silently keep the old text current. The dashboard therefore sends no effectiveAt when the picked date is today (→ now()); only future (scheduled) and past (deliberately retroactive) dates are sent.
material is recorded on the row — true = rights or obligations changed, false = a clarification. It is an explicit operator decision at publish time and part of the record; with the gate gone it interrupts nobody. A new version simply becomes the one in force at /legal; changes to the legal documents are communicated by email (Leon, 2026-08-18), never by an in-app interruption.
backend/scripts/test-legal-resolver.mjs (part of npm test) now asserts catalog integrity only: every document has a known audience, is reachable by id, has a source with content, and the two documents the notice line links (tos, access-terms) exist.
Where agreement happens
frontend/src/components/LegalNotice.tsx, under the forms where a relationship starts./register carries the customer variant (Terms of Service + Privacy Policy); /join follows the invite kind (agency member → ToS, workspace guest → Platform Access Terms). Signing up or accepting the invitation IS the agreement — nothing to tick, no dialog afterwards. /login carries no notice: the person already agreed at sign-up. Every link opens /legal in a new tab so the half-filled form survives.
Tables
| Table | Shape |
|---|---|
legal_document_versions | document_id, version, body (the snapshot), summary, material, effective_at, published_by; unique (document_id, version) |
legal_acceptances | Historical evidence only (no writer since 2026-08-18): version_id → the version, plus denormalised document_id/version, user_id, account_id (null = authorized user), role_at_acceptance, ip, user_agent; unique (version_id, user_id) |
document_id/version are denormalised on purpose: an acceptance record must stay readable as evidence without a join, forever. role_at_acceptance freezes the role, because roles change and "was the person who agreed an admin" is otherwise unanswerable later.
Both tables have RLS enabled with no policies — every read/write goes through the API on the service-role client. A browser holding the anon key must not be able to read the ledger or forge a row in it.
The Order layer: accounts.contract
workspace/agency/contract.js — one jsonb blob, same pattern and reasoning as branding, slimmed to its legal core on 2026-08-11: { notes }, one free-text line for anything negotiated off-template that has no Stripe home. The commercial fields (price, term, notice, order ref, billing rail, signed PDF) and BILLING_MODES/defaultBillingFor are gone — subscriptions, invoices and records are Stripe's job (workspace/agency/billing.js, see docs/spine/workspace.md), and the internal dashboard links to the customer's Stripe profile instead of recording deals by hand. Old blobs may still hold the retired keys; nothing reads them and they age out on save.
The contract body still never forks per customer, and accounts.type no longer forks anything in the legal layer.
Operator-owned; nothing in the customer-facing app writes here.
Surfaces
| Where | What |
|---|---|
/legal, /legal/:documentId (public app) | The published text, served from the API — never from the bundle, so a reader always sees the version in force. Tables render in the docs' block design (components/MarkdownTableBlocks.tsx, shared with DocViewer) |
| Internal → Legal tab | Author + publish a version in a dialog: markdown editor (@uiw/react-md-editor) seeded with the text in force (repo file if never published), version label, effective date, material/clarification, summary; publish history and acceptance counts |
| Internal → Accounts → expand a row | AccountContractPanel: customer type, contract notes, and the acceptance ledger for that account (billing itself lives in Stripe — the row's billing block links the Stripe profile) |
Legal text IS written through the dashboard since 2026-08-12 (previously deliberately not — the repo drafted, the dashboard only published). What kept the old rule's guarantees: a version row is still immutable and append-only (authoring always creates a NEW version, nothing edits a published one), so history is complete by construction; the editor seeds from the version in force, so every new version is a conscious delta on what people actually agreed to; and the publish path strips authoring comments and refuses an implausibly short body. What was given up, knowingly: new wording no longer passes through git review before it binds.
Published state, and the counsel round still owed
v1.0 of all three documents is LIVE (published 2026-08-12 by operator action, effective immediately), and every user existing at that date carries a backfilled acceptance — they had signed paper versions, so the ledger rows record operator-backfill: accepted via signed paper contract in user_agent instead of a fake click context. Those 16 backfilled rows are the whole ledger — no in-app acceptance was ever recorded through the (now removed) dialog. The contracting party is Homiq BV, trading as Luniq (Kruisvest 5B, 8000 Brugge, BE 1020.314.690), governing law is Belgian, and disputes go to the Business Court of Ghent, Bruges division. The ToS carries the party block and everything else inherits it.
The counsel review (Mace Legal) is still owed and now lands as a v1.1+ re-publish instead of a pre-launch edit. Flags for that round: (a) the 2026-08-12 merge — the former standalone DPA and Agency Annex are now Annex 1 and body sections of the ToS; folding the Art. 28 terms into the main contract is a drafting call counsel should bless, and a customer demanding a countersignable standalone DPA would mean splitting it back out (one catalog entry + one file); (b) the privacy draft's usage-data clause was widened (2026-08-10) to name product improvement as a purpose — in-app engagement telemetry (plans/engagement-tracking-2026-08-10.md) runs on legitimate interest, first-party only, and account deletion must also drop the user's app_engagement_daily rollup rows (the raw events age out at 90 days on their own).