Assemble the v1 code layout #35

Closed
opened 2026-08-03 16:47:07 +00:00 by christian · 1 comment
Owner

Question

The destination of this map. Assemble the v1 code layout in this repo: the directory and module
structure, the domain model as real schema files, the permission boundaries expressed as code, and
stubs at the seams where undecided parts will land. Not a running product.

Structural requirements accumulated by earlier resolutions, all of which the layout must satisfy:

  • A single database entry point that makes bypassing actor context awkward, since every data
    path must run inside a transaction that has issued SET LOCAL app.actor_id / app.org_id / app.capability. From the tenancy decision.
  • A scheduler entry point that establishes actor context without a request, for document expiry
    recompute and the eligibility read model. From the vault and entitlement decisions.
  • An entitlement assertion helper, carrying the principle that entitlement gates what a vendor
    may newly undertake and never what a retailer has come to rely on. Its call sites are the
    paid-feature list. From entitlement and pricing.
  • An enumerated list of privileged write paths — currently only category split/merge grant
    propagation. From the eligibility decision.
  • Capability-scoped routing: two nav trees under one shell, every route carrying capability.
    From the app framework.
  • One extraction-and-confirmation module used twice, retailer document to requirement overlay
    and vendor document to answer set. From the app framework.

Also decide what a seam looks like concretely — an interface with a throwing stub, an empty module
with a doc comment, or a typed boundary with no implementation — since six tickets will land in
these and the answer determines whether they land cleanly.

Blocked on data release: the permission boundaries are half of what this layout exists to express,
and RLS policies cannot be stubbed convincingly without them.


Parent: #1

## Question The destination of this map. Assemble the v1 code layout in this repo: the directory and module structure, the domain model as real schema files, the permission boundaries expressed as code, and stubs at the seams where undecided parts will land. Not a running product. Structural requirements accumulated by earlier resolutions, all of which the layout must satisfy: - **A single database entry point** that makes bypassing actor context awkward, since every data path must run inside a transaction that has issued `SET LOCAL app.actor_id / app.org_id / app.capability`. From the tenancy decision. - **A scheduler entry point** that establishes actor context without a request, for document expiry recompute and the eligibility read model. From the vault and entitlement decisions. - **An entitlement assertion helper**, carrying the principle that entitlement gates what a vendor may newly undertake and never what a retailer has come to rely on. Its call sites are the paid-feature list. From entitlement and pricing. - **An enumerated list of privileged write paths** — currently only category split/merge grant propagation. From the eligibility decision. - **Capability-scoped routing**: two nav trees under one shell, every route carrying capability. From the app framework. - **One extraction-and-confirmation module used twice**, retailer document to requirement overlay and vendor document to answer set. From the app framework. Also decide what a seam looks like concretely — an interface with a throwing stub, an empty module with a doc comment, or a typed boundary with no implementation — since six tickets will land in these and the answer determines whether they land cleanly. Blocked on data release: the permission boundaries are half of what this layout exists to express, and RLS policies cannot be stubbed convincingly without them. --- Parent: #1
christian added the
wayfinder:task
wayfinder:ticket
labels 2026-08-03 16:47:07 +00:00
christian added a new dependency 2026-08-03 16:47:07 +00:00
christian added a new dependency 2026-08-03 20:52:59 +00:00
christian self-assigned this 2026-08-03 20:56:10 +00:00
christian referenced this issue from a commit 2026-08-03 21:21:06 +00:00
Author
Owner

Resolution

Committed: da74362, 46 files on master. The map has reached its destination.

Two decisions, then the build, then three things the build itself found.

1. A seam is real types plus a throwing stub that names its ticket

export const auditWriter: AuditWriter = {
  async recordScoreCommit() { throw new Seam('#21', 'the audit trail') },
}

The signature is the handoff. A ticket lands by replacing a body, not by
designing an interface — which matters because six tickets land in adjacent
seams and would otherwise invent incompatible shapes.

Why not types-only. Nothing could be wired up or run, so the layout could
never be sanity-checked, and #37's prototype would have nothing to mock against.

Why not empty modules with doc comments. The hard part of a handoff is the
signature, and that is exactly what it defers.

yarn seams greps the todo list out of the code. docs/seams.md
also records what is deliberately not a seam — the database entry point, the
privileged-path list, the release matrix, the routing table. Changing those means
reopening a closed ticket rather than filling in a blank.

2. Ownership splits on what Drizzle can diff

Drizzle owns Hand-authored
Tables, columns, enums SQL functions
RLS policies, via pgPolicy beside the table The release matrix and its versions

Nothing generated is ever hand-edited. Policies sit in the same file as the
table they guard
, because a new table shipping with no policy is the failure
that matters and RLS default-deny only helps if it was enabled on that table.

The matrix is seeded by a --custom migration because it is versioned, never
updated
— a rule change is an INSERT at a new version.

What was built

src/db/index.ts        THE entry point. The pool is not exported; withActor is the only way in.
src/db/actor.ts        ActorContext, SET LOCAL discipline, and the system context a job runs under.
src/db/privileged.ts   The three enumerated RLS bypasses. Adding a fourth should feel like a decision.
src/db/schema/         Nine files. The domain model, policies beside their tables.
src/auth/entitlement.ts  The assertion helper, carrying the governing principle as a comment.
src/release/matrix.ts  The matrix, mirrored from SQL so screen and policy cannot disagree.
src/scoring/routing.ts What a model may decide. Two lanes of six.
src/extraction/        One module used twice; completeness.ts explicitly forbidden from sharing with it.
src/scheduler/         Actor context with no request.
src/http/routes/       Two capability-scoped nav trees plus a public pre-auth surface.

Every one of the four outstanding structural requirements is now satisfied and
pointed at from the README: the single database entry point (#11), the
scheduler entry point (#13), the entitlement assertion helper (#20, #34),
and the enumerated privileged-path list (#33).

What the build found

1. An ordering cycle between Drizzle's policies and our functions. Drizzle
emits a policy in the same migration as its table, so class_released must
already exist — but the functions read solicitation, participation and
release_matrix, which that same migration creates.

Resolved by making the functions plpgsql, which defers body validation to
first call, and giving them text signatures rather than the release_level
enum, which does not exist yet either. Functions are migration 0000, schema and
policies 0001, the matrix seed 0002.

The cost is real and belongs to #21: a typo inside a function body is not
caught at migration time.
It surfaces the first time a policy runs — and
because policy failures present as empty results, that is precisely the failure
mode #11 flagged as hard to diagnose. Database-level tests over these
functions are not optional.

2. drizzle-kit below 0.31 cannot resolve .js extensions in a TS schema.
0.30.6 fails with MODULE_NOT_FOUND on the ESM import style the rest of the stack
uses; 0.31.10 works. Pinned, and worth knowing before anyone downgrades.

3. pgPolicy works. It was the flagged risk on decision 2 and it holds:
generation produces RLS on 18 tables and 6 policies calling the
release-level functions, with the sql template passing class names through
untouched.

Verification

  • tsc --noEmit clean.
  • drizzle-kit generate produces 0001_initial.sql with RLS enabled on 18
    tables and 6 policies.
  • Not verified: nothing has been run against a live Postgres. The functions
    are plpgsql, so their bodies are unvalidated until first call — see finding 1.
    A migration dry-run against a real database is the obvious next check and is
    the kind of thing #30's corpus work will force anyway.

Consequences

  • #36 is unblocked. The seam shape it was waiting on is real types plus a
    throwing stub
    , so components are built against live signatures rather than
    guesses.
  • The destination is reached. Every remaining ticket is either behaviour
    landing in a seam, v1 content filling a vocabulary, or the demo chain. Nothing
    left on the map redesigns the skeleton.
## Resolution **Committed: `da74362`, 46 files on `master`.** The map has reached its destination. Two decisions, then the build, then three things the build itself found. ### 1. A seam is real types plus a throwing stub that names its ticket ```ts export const auditWriter: AuditWriter = { async recordScoreCommit() { throw new Seam('#21', 'the audit trail') }, } ``` The signature is the handoff. A ticket lands by **replacing a body**, not by designing an interface — which matters because six tickets land in adjacent seams and would otherwise invent incompatible shapes. **Why not types-only.** Nothing could be wired up or run, so the layout could never be sanity-checked, and https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/37's prototype would have nothing to mock against. **Why not empty modules with doc comments.** The hard part of a handoff *is* the signature, and that is exactly what it defers. `yarn seams` greps the todo list out of the code. [`docs/seams.md`](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/src/branch/master/docs/seams.md) also records what is deliberately **not** a seam — the database entry point, the privileged-path list, the release matrix, the routing table. Changing those means reopening a closed ticket rather than filling in a blank. ### 2. Ownership splits on what Drizzle can diff | Drizzle owns | Hand-authored | |---|---| | Tables, columns, enums | SQL functions | | RLS policies, via `pgPolicy` beside the table | The release matrix and its versions | Nothing generated is ever hand-edited. Policies sit **in the same file as the table they guard**, because a new table shipping with no policy is the failure that matters and RLS default-deny only helps if it was enabled on that table. The matrix is seeded by a `--custom` migration because it is **versioned, never updated** — a rule change is an INSERT at a new version. ### What was built ``` src/db/index.ts THE entry point. The pool is not exported; withActor is the only way in. src/db/actor.ts ActorContext, SET LOCAL discipline, and the system context a job runs under. src/db/privileged.ts The three enumerated RLS bypasses. Adding a fourth should feel like a decision. src/db/schema/ Nine files. The domain model, policies beside their tables. src/auth/entitlement.ts The assertion helper, carrying the governing principle as a comment. src/release/matrix.ts The matrix, mirrored from SQL so screen and policy cannot disagree. src/scoring/routing.ts What a model may decide. Two lanes of six. src/extraction/ One module used twice; completeness.ts explicitly forbidden from sharing with it. src/scheduler/ Actor context with no request. src/http/routes/ Two capability-scoped nav trees plus a public pre-auth surface. ``` Every one of the four outstanding structural requirements is now satisfied and pointed at from the README: the single database entry point (https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/11), the scheduler entry point (https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/13), the entitlement assertion helper (https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/20, https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/34), and the enumerated privileged-path list (https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/33). ### What the build found **1. An ordering cycle between Drizzle's policies and our functions.** Drizzle emits a policy in the **same migration as its table**, so `class_released` must already exist — but the functions read `solicitation`, `participation` and `release_matrix`, which that same migration creates. Resolved by making the functions **plpgsql**, which defers body validation to first call, and giving them `text` signatures rather than the `release_level` enum, which does not exist yet either. Functions are migration `0000`, schema and policies `0001`, the matrix seed `0002`. **The cost is real and belongs to https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/21: a typo inside a function body is not caught at migration time.** It surfaces the first time a policy runs — and because policy failures present as empty results, that is precisely the failure mode https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/11 flagged as hard to diagnose. **Database-level tests over these functions are not optional.** **2. drizzle-kit below 0.31 cannot resolve `.js` extensions in a TS schema.** 0.30.6 fails with MODULE_NOT_FOUND on the ESM import style the rest of the stack uses; 0.31.10 works. Pinned, and worth knowing before anyone downgrades. **3. `pgPolicy` works.** It was the flagged risk on decision 2 and it holds: generation produces RLS on **18 tables** and **6 policies** calling the release-level functions, with the `sql` template passing class names through untouched. ### Verification - `tsc --noEmit` clean. - `drizzle-kit generate` produces `0001_initial.sql` with RLS enabled on 18 tables and 6 policies. - **Not verified:** nothing has been run against a live Postgres. The functions are plpgsql, so their bodies are unvalidated until first call — see finding 1. A migration dry-run against a real database is the obvious next check and is the kind of thing https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/30's corpus work will force anyway. ### Consequences - **https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/36 is unblocked.** The seam shape it was waiting on is *real types plus a throwing stub*, so components are built against live signatures rather than guesses. - **The destination is reached.** Every remaining ticket is either behaviour landing in a seam, v1 content filling a vocabulary, or the demo chain. Nothing left on the map redesigns the skeleton.
Sign in to join this conversation.
No description provided.