|
| 1 | +# Architecture |
| 2 | + |
| 3 | +How `@corbits/artifact-core` is put together, and where its boundaries are. For |
| 4 | +install, the mount snippet, the route table and the response contracts, see the |
| 5 | +[package README](./packages/artifact-core/README.md) — this document is about |
| 6 | +structure and reasoning, and does not repeat them. |
| 7 | + |
| 8 | +## The shape of the thing |
| 9 | + |
| 10 | +A **library, not a service**. It creates no app, starts no background work, and |
| 11 | +opens no pool unless asked. A host calls two functions: |
| 12 | + |
| 13 | +- `runArtifactMigrations(db)` — once at boot, before serving. |
| 14 | +- `mountArtifacts(app, opts)` — registers the artifact routes on a Hono app the |
| 15 | + host already built. |
| 16 | + |
| 17 | +## Where the routes are served |
| 18 | + |
| 19 | +The core registers root-relative paths (`/artifacts*`, |
| 20 | +`/instances/:id/mail-attachments`) and takes no base path, so the *mount point* |
| 21 | +is the host's decision. The convention every `@corbits/*-core` package |
| 22 | +documents, and every example here demonstrates, is **`/api`** — the same prefix |
| 23 | +Interchange serves its own routes under (`app.route("/api/me", …)`, |
| 24 | +`app.route("/api/tenants", …)`). No `/v1` segment, no vendor prefix. |
| 25 | + |
| 26 | +```ts |
| 27 | +const api = new Hono<AppEnv>(); |
| 28 | +mountArtifacts(api, { db, contentStore, resolvePrincipal }); |
| 29 | +app.route("/api", api); |
| 30 | +``` |
| 31 | + |
| 32 | +which serves `/api/artifacts`, `/api/artifacts/:id`, |
| 33 | +`/api/artifacts/:id/versions`, `/api/artifacts/:id/download`, and |
| 34 | +`/api/instances/:instanceId/mail-attachments`. Nesting rather than teaching the |
| 35 | +core a base path keeps the frozen `mountX<E extends Env>(app, opts) => Hono<E>` |
| 36 | +seam untouched. |
| 37 | + |
| 38 | +Everything else it needs arrives through `opts`. Nothing is reached for; the |
| 39 | +dep-guard enforces that mechanically. |
| 40 | + |
| 41 | +## The mount seam |
| 42 | + |
| 43 | +`mountArtifacts<E extends Env>(app: Hono<E>, opts): Hono<E>` is generic over the |
| 44 | +host's Hono `Env`, so it composes with an app that carries its own environment |
| 45 | +rather than requiring a bare `Hono`. |
| 46 | + |
| 47 | +Three options have no sensible default — `db`, `contentStore`, |
| 48 | +`resolvePrincipal` — and the rest degrade a *feature*, never safety, when |
| 49 | +omitted. The README's table of what a minimal host passes is the reference; what |
| 50 | +matters architecturally is that every default **fails closed**: no `adminAuthz` |
| 51 | +means nobody is an admin, no `identity` means no directory and no cross-tenant |
| 52 | +reads, no `provenance` means no decoration. |
| 53 | + |
| 54 | +What the package does **not** require of a host: no auth middleware, no session |
| 55 | +library, no particular schema, no control-plane tables, no id scheme, no UI. |
| 56 | + |
| 57 | +`resolvePrincipal`'s signature is identical across the Corbits cores, so a host |
| 58 | +mounting more than one passes the same function to each. The resolved tenant is |
| 59 | +authoritative — there is no caller-supplied tenant override anywhere in the |
| 60 | +route surface. |
| 61 | + |
| 62 | +## The ports |
| 63 | + |
| 64 | +Five host seams. Four are types declared in `ports.ts`; `resolvePrincipal` is a |
| 65 | +`mountArtifacts` option, typed at the mount boundary because it takes the host's |
| 66 | +request context as `unknown`. Every optional seam has a **named, exported** |
| 67 | +default. |
| 68 | + |
| 69 | +| Seam | What it is for | Default | |
| 70 | +| --- | --- | --- | |
| 71 | +| `resolvePrincipal` | Who the request runs as. Reads the host session; returns `null` when signed out. | none — required | |
| 72 | +| `ContentStore` | Where an artifact's file bytes live. | none — required | |
| 73 | +| `AdminAuthz` (Seam A) | Authorization. Only archive/unarchive consults it. | `denyAllAdminAuthz` | |
| 74 | +| `Identity` (Seam B) | Owner display names, the agent→human ownership resolution, creator-kind principal sets, and cross-tenant membership. | `anonymousIdentity` | |
| 75 | +| `Provenance` (Seam C) | A **display-only** decorator over serialized rows. | `noProvenance` | |
| 76 | + |
| 77 | +Seam C's display-only status is a contract, not a convention: it may add fields |
| 78 | +to rows on their way out and must never affect *what* is returned or *who* may |
| 79 | +see it. That is why provenance is a port at all — joining a host's workflow |
| 80 | +tables to decorate a row would make this package depend on a schema it must not |
| 81 | +know, and the dep-guard fails the build on exactly that. |
| 82 | + |
| 83 | +Seam B's `ownerIsMemberOfTenant` gates cross-tenant reads and must fail closed; |
| 84 | +the shipped `anonymousIdentity` does. |
| 85 | + |
| 86 | +### ContentStore |
| 87 | + |
| 88 | +`ContentStore` is the substrate seam: |
| 89 | + |
| 90 | +- `put(tx, scope, blob)` persists bytes **inside the caller's transaction** and |
| 91 | + returns the two artifact-row fields a stored file determines (`content` and |
| 92 | + `source`). Taking the transaction rather than the handle is what makes "bytes, |
| 93 | + artifact row, and version 1" a single atomic write — a failure anywhere leaves |
| 94 | + no orphan bytes and no orphan artifact. |
| 95 | +- `get(db, artifact)` resolves **out-of-band** bytes, or `null` when the store |
| 96 | + keeps content in the row itself. It is tenant-scoped: a reference resolving to |
| 97 | + another tenant's bytes must return `null`. |
| 98 | + |
| 99 | +Two implementations ship and both pass the same suite: `InlineContentStore` |
| 100 | +(bytes in a tenant-owned `upload` row, referenced by `source.upload.id`) and |
| 101 | +`DataUrlContentStore` (bytes inline in `content` as a base64 `data:` URL, no |
| 102 | +side-table row, so its `get` returns `null`). A third backend — object storage, |
| 103 | +say — is a third implementation, not a rewrite. The single download path |
| 104 | +resolves the storage conventions in precedence order rather than branching on |
| 105 | +which store is installed. |
| 106 | + |
| 107 | +## Modules |
| 108 | + |
| 109 | +| File | Role | |
| 110 | +| --- | --- | |
| 111 | +| `mount.ts` | HTTP surface: parsing, validation, status codes. | |
| 112 | +| `artifacts.ts` | The core domain — create, revise, list, get, archive, serialize. | |
| 113 | +| `uploads.ts` | `createFileArtifact`, the MIME policies, and the size caps. | |
| 114 | +| `download.ts` | One download path over the three storage conventions. | |
| 115 | +| `content-store.ts` | The two shipped `ContentStore` implementations. | |
| 116 | +| `tools.ts` | Agent-facing tool definitions and windowed artifact reads. | |
| 117 | +| `web-site.ts` | The `web-site` kind's content encoding and validation. | |
| 118 | +| `mail-attachments.ts` | Artifact↔message associations. | |
| 119 | +| `ports.ts` | Four of the five host seam types, and the three fail-closed defaults. | |
| 120 | +| `schema.ts` / `migrations.ts` | The four tables, and the DDL that creates them. | |
| 121 | + |
| 122 | +## Data model |
| 123 | + |
| 124 | +Four physical tables — `artifact`, `artifact_version`, `upload`, |
| 125 | +`mail_attachment_ref` — plus this package's own migration ledger. |
| 126 | + |
| 127 | +**Zero control-plane foreign keys.** `tenant_id`, `principal_id` and |
| 128 | +`owner_principal_id` are plain `text` held by value. The package must stand up on |
| 129 | +a host schema it knows nothing about, so it cannot assume the host has tables by |
| 130 | +those names, let alone that their keys are uuids. The only foreign keys are |
| 131 | +*internal*: `artifact.parent_id` (self-referential, cascading) and |
| 132 | +`artifact_version.artifact_id`. |
| 133 | + |
| 134 | +**`kind` is free-form text, not a pg enum,** validated at the application edge. |
| 135 | +New kinds cost no migration. What is *not* free-form is the import allowlist: |
| 136 | +`POST /api/artifacts` may only mint `link` or `document`, so an untrusted caller |
| 137 | +cannot stamp a file-shaped, downloadable kind onto a row whose content is a URL |
| 138 | +or a pasted body. |
| 139 | + |
| 140 | +**History is append-only.** Every create and every revision writes an |
| 141 | +`artifact_version` row — including version 1, written eagerly with the artifact — |
| 142 | +so a version-pinned read always resolves. The version bump has a double guard: |
| 143 | +`SELECT ... FOR UPDATE` serializes writers, and the `(artifact_id, version)` |
| 144 | +unique constraint makes a racing writer that somehow computed the same next |
| 145 | +version fail loudly rather than corrupt history. |
| 146 | + |
| 147 | +**Archival is a soft hide.** `archived_at` null means visible; a timestamp means |
| 148 | +hidden from discovery. Deep links to archived artifacts still load. |
| 149 | + |
| 150 | +**`parent_id` has no live writer.** It is kept deliberately as the declared |
| 151 | +nesting seam: one nullable column and one cascade now, versus a breaking schema |
| 152 | +change the moment a host nests. |
| 153 | + |
| 154 | +**`upload` is never a standalone resource.** There is no `POST /uploads`; every |
| 155 | +upload eagerly mints its artifact, and the row is reachable only through |
| 156 | +`source.upload.id`. `mail_attachment_ref` carries no bytes at all — the file |
| 157 | +already *is* an artifact, and the ref only records which artifacts rode with |
| 158 | +which message. |
| 159 | + |
| 160 | +The list index is `(tenant_id, updated_at, id)`. The `id` is the list's |
| 161 | +tie-break and must be *in* the index, or the keyset cursor's row-value |
| 162 | +comparison falls out of the index condition into a filter and drags a sort |
| 163 | +behind it. |
| 164 | + |
| 165 | +## Migrations |
| 166 | + |
| 167 | +`runArtifactMigrations(db)` is idempotent and safe to call unconditionally on |
| 168 | +every boot of every replica. |
| 169 | + |
| 170 | +- The whole run is one transaction whose first statements are |
| 171 | + `SET LOCAL client_min_messages = warning` and a **transaction-scoped** |
| 172 | + advisory lock. A transaction pins one pooled connection, so the lock, the |
| 173 | + ledger read and the DDL are the same session; the lock releases on commit or |
| 174 | + rollback, so there is no unlock call to lose on an error path. |
| 175 | + `CREATE TABLE IF NOT EXISTS` is not itself race-safe, so the lock — not the |
| 176 | + `IF NOT EXISTS` — is what makes concurrent cold starts safe. |
| 177 | +- Lowering `client_min_messages` is why a re-run prints **nothing**: every |
| 178 | + statement is `IF NOT EXISTS`, and on the second boot Postgres answers each with |
| 179 | + a NOTICE that postgres.js would otherwise dump to the console, making a clean |
| 180 | + re-boot look like a wall of errors. `SET LOCAL` scopes it to the transaction |
| 181 | + and stops at NOTICE — WARNING and above still reach the host. |
| 182 | +- Each migration applies inside a nested transaction (a savepoint) together with |
| 183 | + its ledger row, so a migration can never be recorded as applied with only some |
| 184 | + of its statements run. |
| 185 | +- The ledger is this package's own table, `corbits_artifact_core_migrations`, |
| 186 | + never shared with a host's. Each row records a **checksum of the migration's |
| 187 | + rendered SQL**, so editing a shipped migration fails with |
| 188 | + `MigrationChecksumError` on the next boot instead of letting existing and |
| 189 | + fresh databases diverge silently. Ship a new migration instead. The column is |
| 190 | + `NOT NULL`, so the guarantee is unconditional: there is no unrecorded row for |
| 191 | + the runner to adopt and wave through. |
| 192 | + |
| 193 | +**Mounting under a host's own Postgres schema works.** No DDL and no query here |
| 194 | +is schema-qualified, so everything resolves through `search_path`: point a handle |
| 195 | +at `search_path=its_schema` and the four tables, their indexes and the ledger are |
| 196 | +created there, isolated from the host's own tables. `test/migrations.test.ts` |
| 197 | +exercises exactly this against a live database. |
| 198 | + |
| 199 | +## Boundaries |
| 200 | + |
| 201 | +Owned by this package: the four tables and their migrations; the HTTP surface, |
| 202 | +its validation and its status codes; the version and archive semantics; the |
| 203 | +upload **gate** (`createFileArtifact` takes `policy` as a required argument and |
| 204 | +refuses anything outside it before the `ContentStore` is touched); and the |
| 205 | +download path with its `nosniff`/`attachment` behaviour. |
| 206 | + |
| 207 | +Supplied by the host: the Hono app and the database handle; who the caller is; |
| 208 | +whether they are an admin; the directory, if there is one; provenance |
| 209 | +decoration; and a `ContentStore`. |
| 210 | + |
| 211 | +## Known limits |
| 212 | + |
| 213 | +- **No file parsing, ever.** No PDF parser, no spreadsheet parser, no text |
| 214 | + extractor, and none is planned. An extractor is a heavyweight, fast-moving |
| 215 | + native dependency, and what the extracted text is *for* is the host's product. |
| 216 | + The contract the package offers a parsing host instead is **parse before you |
| 217 | + store**: `createFileArtifact` is the only way a file becomes an artifact, so a |
| 218 | + host that parses first and fails leaves nothing orphaned. |
| 219 | +- **Two of the three MIME allowlists are host-owned surfaces.** This package |
| 220 | + ships all three constants but only owns the gallery import route; spreadsheet |
| 221 | + ingest and attachment divert are the host's routes calling this package's gate. |
| 222 | +- **Upload caps are fixed constants,** not configuration: 10 MB per file, 50 |
| 223 | + files and 100 MB per request. |
| 224 | +- **`InlineContentStore` keeps bytes in Postgres.** That is a deliberate |
| 225 | + zero-dependency default, not a recommendation at scale; a large corpus wants a |
| 226 | + `ContentStore` over object storage. |
| 227 | +- **List paging caps at 100** rows (default 20). |
| 228 | +- **One 404 covers four causes** for a resolved caller — never minted, |
| 229 | + malformed, a `skill-draft`, or another tenant's. Distinguishing them would be |
| 230 | + an existence oracle. Expect no more detail than that from the API. |
0 commit comments