The repository boundary is implemented with a deterministic in-memory adapter for local tests, and PostgreSQL 16+ for real. Application code remains independent of the database client.
Seven migrations apply in order under migrations/; 001 establishes the schema below and 002–007 add the tenant role separation, snapshot completeness, assigned identity, the isolation and constraint work of Spec-018, and the reply-operation lease of Spec-015. The ERD reflects the schema as of 007.
- Preserve stable internal identifiers while retaining provider identifiers.
- Make comments queryable by internal post and platform.
- Support deduplication when the same provider comment is observed repeatedly.
- Record reply attempts and idempotency keys for safe client retries.
- Keep credentials and access tokens outside this schema.
Represents the existing platform tenant or account that owns connected social accounts.
Represents one authorized connection to a provider. It stores provider identity and references credentials managed by existing infrastructure.
Represents a scheduled/published post in the host platform. It maps to a provider-specific post identifier.
Stores a normalized snapshot of a provider comment, including its external identity, author information, parent relationship, and timestamps.
Records a client-requested reply and its idempotency key. It supports auditability and safe retry handling without making the operation itself the source of truth for the external comment.
erDiagram
ACCOUNT ||--o{ SOCIAL_ACCOUNT : owns
ACCOUNT ||--o{ POST : owns
SOCIAL_ACCOUNT ||--o{ POST : publishes
POST ||--o{ COMMENT : contains
COMMENT ||--o{ COMMENT : replies_to
COMMENT ||--o{ REPLY_OPERATION : receives
ACCOUNT {
uuid id PK
string external_tenant_id UK
datetime created_at
}
SOCIAL_ACCOUNT {
uuid id PK
uuid account_id FK
string platform
string external_account_id
string credential_reference
datetime created_at
}
POST {
uuid id PK
uuid account_id FK
uuid social_account_id FK
string external_post_id
string status
datetime published_at
string provider_cursor
boolean provider_exhausted
datetime provider_completed_at
}
COMMENT {
uuid id PK
uuid account_id FK
uuid post_id FK
uuid social_account_id FK
string external_comment_id
string external_parent_comment_id
string author_external_id
string author_display_name
text body
datetime published_at
datetime updated_at
datetime last_seen_at
}
REPLY_OPERATION {
uuid id PK
uuid account_id FK
uuid comment_id FK
string idempotency_key UK
string request_fingerprint
string status
uuid resulting_comment_id FK
text failure_code
datetime lease_expires_at
string external_reply_id
datetime created_at
datetime completed_at
}
posts carries provider_cursor and provider_exhausted (Spec-013), plus provider_completed_at (Spec-014). Together they record how much of a post's provider comment stream has been read into the local snapshot: the continuation for the next unfetched page, whether the end has been reached, and when it was last reached.
Without them the service could not distinguish an exhausted provider from one it had never asked, because that knowledge lived only inside a cursor handed to one client. A caller starting pagination fresh was then told a post held fewer comments than it did. provider_completed_at was added because exhaustion without a timestamp is a one-way latch: a post read to its end once would hide every comment published afterwards.
The service is granted update on just these three columns; everything else about a post remains read-only to it.
- Treat
account_idas the tenant boundary for all account-owned entities and scope every query to it. - Prefer database row-level security when supported; policies must fail closed without trusted tenant context and be tested across at least two tenants.
- Unique provider identity within a provider scope, such as
(social_account_id, external_comment_id). - Unique idempotency key within the authenticated account scope.
- Index comments by
(account_id, post_id, published_at, id)for deterministic cursor queries. The tenant column leads, because every query is tenant-scoped and the index is useless without it. - Index the referencing side of every foreign key that is looked up or cascaded through. PostgreSQL indexes only the referenced side.
- Constrain
platformat the database, not only in the validator: the column is the shared fact and the validator is one code path. - Define deletion and retention behavior before production use. Migration
006fixes the referential half of it (see below); automated retention remains open. - Treat provider payloads and raw error details as sensitive operational data.
The application sets the transaction-local app.account_id value from trusted authentication context before querying tenant-owned tables, which the Database port does for every operation (ADR-0012). Policies fail closed when the value is absent. Repository predicates remain mandatory defence in depth.
Isolation depends on which role connects, not only on the policies existing:
comments_ownerowns the schema and runs migrations and the seed.comments_appruns the service. It owns nothing and is not a superuser, so the policies apply to it.
PostgreSQL exempts superusers and BYPASSRLS roles from row-level security unconditionally, and exempts a table's owner unless FORCE ROW LEVEL SECURITY is set. Migration 002 sets FORCE on the four tenant tables and creates comments_app. FORCE alone is not sufficient against a superuser owner, which is why the role separation is the primary control and FORCE is the safeguard for managed environments where the owner is not a superuser.
Migration 006 closes the two gaps that were left (Spec-018). comments_app had its attributes set only at creation, so a role of that name that already existed with SUPERUSER or BYPASSRLS defeated every policy while the migration reported success; the attributes are now pinned unconditionally on every run. And accounts — the one table comments_app could read without a policy — now carries one keyed on its own identifier.
Policy behaviour is verified against a real database across two tenants in tests/repositories/postgres.integration.test.ts, including a query with its account_id predicate deliberately removed, now covering comments, posts, social_accounts, reply_operations, and accounts. That case is what distinguishes working row-level security from repository predicates that merely resemble isolation. A separate test reads pg_roles and fails if the service role has drifted.
Every foreign key states its ON DELETE behaviour as of migration 006. Before that they carried PostgreSQL's default, which meant deletion behaviour was whatever the default happened to be rather than a decision anyone made.
The rule: deleting a tenant removes everything that tenant owns, and within a tenant a post owns its comments — but the record of something published under a customer's name does not disappear quietly.
| Foreign key | On delete | Why |
|---|---|---|
every account_id |
cascade |
Deleting a tenant is a whole-tenant operation. |
posts.social_account_id |
cascade |
A post has no meaning without the connection it went out through. |
comments.post_id, comments.social_account_id |
cascade |
A comment is a snapshot of that post's conversation. |
reply_operations.comment_id |
no action |
The row records a publication under a customer's name. |
reply_operations.resulting_comment_id |
no action |
As above. |
reply_operations uses NO ACTION rather than RESTRICT deliberately. Both refuse to let a comment be deleted while an operation references it; they differ in when the check runs. RESTRICT fires immediately and would make deleting a whole account impossible, because that account's cascade removes the operations and the comments in the same statement. NO ACTION defers to the end of the statement, by which point the operations are already gone. Deleting one comment with a reply behind it fails; deleting a tenant succeeds.
Automated retention — how long a snapshot or an audit row is kept — remains an open operational decision recorded in operations.md.