Skip to content

Commit db5a723

Browse files
Merge pull request #3 from corbitsdev/fix/artifacts-guardrails-correctness
fix(artifacts): guardrails and correctness (CL-4686)
2 parents 8115b66 + 7faf502 commit db5a723

28 files changed

Lines changed: 2153 additions & 162 deletions

‎.github/workflows/test.yml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
2525
env:
2626
ARTIFACT_DATABASE_URL: postgres://postgres:postgres@localhost:5457/artifact_core
27+
# Package suite TRUNCATEs tables / some migration tests DROP SCHEMA — both
28+
# refuse unless opted in against an allowlisted ephemeral database name.
29+
ALLOW_DESTRUCTIVE_ARTIFACT_TESTS: "1"
2730

2831
steps:
2932
- uses: actions/checkout@v4
@@ -38,14 +41,17 @@ jobs:
3841
- name: check-deps
3942
run: bun run --cwd packages/artifacts scripts/check-deps.ts
4043

44+
# typecheck builds first: examples import @corbits/artifacts through the
45+
# published dist types, same path a consumer resolves.
4146
- name: typecheck
4247
run: bun run typecheck
4348

4449
- name: unit + integration tests
4550
run: bun run --cwd packages/artifacts test:coverage
4651

47-
# Emits dist/, which is also what the reference host resolves through —
48-
# so this proves the published artifact, not just the sources.
52+
# dist/ is already emitted by typecheck; re-run for a clean package build
53+
# so acceptance and the node consumer smoke test do not depend on typecheck
54+
# having side-effected the tree.
4955
- name: build
5056
run: bun run build
5157

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ coverage/
99
# npm pack output
1010
*.tgz
1111
.worktrees/
12+
13+
# Dispatch orchestration artifacts (local agent runs)
14+
dispatch/
15+
.agent-state/
16+
.intercode/

‎ARCHITECTURE.md‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,30 @@ which store is installed.
123123
Four physical tables — `artifact`, `artifact_version`, `upload`,
124124
`mail_attachment_ref` — plus this package's own migration ledger.
125125

126-
**Hard control-plane foreign keys, by design.** `tenant_id` references
127-
`public.tenant(id)` (`ON DELETE CASCADE` — a deleted tenant takes its artifacts
128-
with it) and `principal_id` / `owner_principal_id` reference
126+
**Hard control-plane foreign keys, by design.** `tenant_id` is `NOT NULL` and
127+
references `public.tenant(id)` (`ON DELETE CASCADE` — a deleted tenant takes its
128+
artifacts with it) and `principal_id` / `owner_principal_id` reference
129129
`public.principal(id)` (`ON DELETE SET NULL` — a removed principal detaches its
130130
artifacts rather than destroying them). This package is coupled to Interchange:
131131
it mounts on Interchange-shaped hosts only, and the host's own migrations must
132132
have run before `runArtifactMigrations`. The internal key —
133133
`artifact_version.artifact_id` — cascades with its artifact.
134134

135+
**Cheap row-local CHECKs.** `artifact.version` and `artifact_version.version`
136+
must be ≥ 1; `upload.size` and `mail_attachment_ref.size` must be ≥ 0. These are
137+
single-column constraints applied by a ledgered migration — free at write time.
138+
139+
**Principal↔tenant alignment is host-owned.** The package FKs each column into
140+
the control plane independently; it does **not** enforce that `principal_id` (or
141+
`owner_principal_id`) belongs to the same tenant as `tenant_id`. A multi-table
142+
trigger or composite FK into `public.principal` would couple every write to a
143+
control-plane lookup and is deliberately out of scope. The host's
144+
`resolvePrincipal` is the authority: it returns the `(tenantId, principalId)`
145+
pair every route and tool write stamps, so a correctly mounted host never
146+
plants a cross-tenant principal. Operators cleaning legacy rows before the
147+
`tenant_id NOT NULL` migration must assign a valid tenant or delete orphans —
148+
the migration fails with an explicit message if null `tenant_id` rows remain.
149+
135150
**`kind` is free-form text, not a pg enum,** validated at the application edge.
136151
New kinds cost no migration. What is *not* free-form is the import allowlist:
137152
`POST /api/artifacts` may only mint `link` or `document`, so an untrusted caller
@@ -186,6 +201,22 @@ every boot of every replica.
186201
fresh databases diverge silently. Ship a new migration instead. The column is
187202
`NOT NULL`, so the guarantee is unconditional: there is no unrecorded row for
188203
the runner to adopt and wave through.
204+
- Event timestamps (`created_at`, `updated_at`, `archived_at`) are
205+
**`timestamptz`**. The initial create migration still lays them down as
206+
zoneless `timestamp`; a follow-on migration retypes them with
207+
`USING col AT TIME ZONE 'UTC'`, treating existing walls as the UTC clocks the
208+
package always assumed. List keyset cursors project through
209+
`AT TIME ZONE 'UTC'` and compare with `::timestamptz`, so paging and date
210+
filters stay on the absolute instant under any session `TimeZone`. Rollback is
211+
the reverse cast (`TYPE timestamp USING col AT TIME ZONE 'UTC'`) plus a new
212+
ledgered migration — never edit a shipped one.
213+
- A later ledgered migration sets `artifact.tenant_id NOT NULL` and adds the
214+
version/size CHECKs. If null-tenant rows still exist, that migration raises
215+
before altering the column so the operator can clean them up first.
216+
- Empty ledger + pre-existing package objects fails closed
217+
(`MigrationAdoptError`). `{ adopt: true }` records checksums without re-DDL
218+
only after shape validation: tables, column types, required nullability, and
219+
the named CHECK constraints. Column presence alone is not enough.
189220

190221
**The package owns its own Postgres schema.** Every table, index and the ledger
191222
live in `artifacts`, created by the runner and qualified in every

‎CONTRIBUTING.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ Nothing is mocked at the database boundary. Migrations, indexes, keyset cursors,
1414
version-bump race and tenant scoping are asserted against a live server, because that is
1515
the only place they are true.
1616

17+
The harness truncates package tables between tests and drops the package schema in a
18+
couple of migration cases. Those paths are fail-closed: set
19+
`ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1` and point `ARTIFACT_DATABASE_URL` at an allowlisted
20+
ephemeral database (`artifact_core`, or any name ending in `_test`). Without both, the
21+
suite throws before mutating. The gate itself is pure URL/env parsing and is covered by
22+
unit tests that do not need Postgres.
23+
1724
## The reference host is the acceptance suite, not a demo
1825

1926
`examples/reference-host` mounts the package on a real `@intx/hub-api` app against a

‎README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ bun install
2222
docker run -d --name corbits-artifact-pg -p 5457:5432 \
2323
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=artifact_core postgres:16
2424

25+
# The package suite TRUNCATEs tables and some migration tests DROP SCHEMA.
26+
# Both refuse unless you opt in and the database name is allowlisted.
27+
export ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1
28+
2529
bun run test:package # dependency check, then unit + integration
2630
bun run build # dist/ (JS + .d.ts)
2731
bun run test:acceptance # builds, then the acceptance scenarios
@@ -34,7 +38,10 @@ run stops meaning anything.
3438

3539
Tests and the example expect
3640
`postgres://postgres:postgres@localhost:5457/artifact_core`; override with
37-
`ARTIFACT_DATABASE_URL`.
41+
`ARTIFACT_DATABASE_URL`. Destructive package tests additionally require
42+
`ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1` and an allowlisted database name
43+
(`artifact_core`, or any name ending in `_test`). See the package README Development
44+
section for the full gate contract.
3845

3946
## Conventions
4047

‎bun.lock‎

Lines changed: 7 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎examples/reference-host/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"@intx/hub-sessions": "0.2.2",
1919
"@intx/types": "0.2.2",
2020
"arktype": "2.1.29",
21-
"drizzle-orm": "0.45.1",
21+
"drizzle-orm": "0.45.2",
2222
"hono": "4.12.32",
2323
"hono-openapi": "1.2.0",
2424
"postgres": "3.4.9"

‎examples/reference-host/src/index.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
type ResolvedPrincipal,
3535
type ContentStore,
3636
type Identity,
37-
type SerializedArtifact,
37+
type SerializedArtifactBase,
3838
} from "@corbits/artifacts";
3939

4040
export const DATABASE_URL =
@@ -130,7 +130,7 @@ function createIdentity(db: ArtifactDb): Identity {
130130
}
131131

132132
/** Display-only decorator. Adds a label, never changes what is returned. */
133-
async function decorate(_tenantId: string, rows: SerializedArtifact[]) {
133+
async function decorate(_tenantId: string, rows: readonly SerializedArtifactBase[]) {
134134
for (const row of rows) {
135135
(row as Record<string, unknown>).generatedByLabel =
136136
typeof row.source.generatedBy === "string" ? row.source.generatedBy : null;
@@ -371,7 +371,7 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
371371

372372
return {
373373
db,
374-
tenant,
374+
tenantId: tenant,
375375
agentPrincipal,
376376
scope: () => ({
377377
tenantId: tenant,

‎examples/reference-host/test/acceptance.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ describe("list: keyset paging, creatorKind, and the archived toggle", () => {
197197
await host.db.execute(sql`
198198
INSERT INTO "artifacts"."artifact" ("tenant_id", "principal_id", "owner_principal_id",
199199
"kind", "title", "content", "source", "version")
200-
VALUES (${host.tenant}, ${host.agentPrincipal}, ${host.agentPrincipal}, 'document',
200+
VALUES (${host.tenantId}, ${host.agentPrincipal}, ${host.agentPrincipal}, 'document',
201201
'Agent memo', 'written by an agent', '{"origin":"agent"}'::jsonb, 1)
202202
`);
203203

@@ -455,7 +455,7 @@ describe("pdf parsing is the host's, and the module's contract with it holds", (
455455
const row = await host.db.transaction((tx) =>
456456
createFileArtifact(tx, InlineContentStore, {
457457
scope: host.scope(),
458-
ownerPrincipalId: host.scope().principal,
458+
ownerPrincipalId: host.scope().principalId,
459459
filename: "report.pdf",
460460
mimeType: "application/pdf",
461461
bytes: PDF,
@@ -484,7 +484,7 @@ describe("pdf parsing is the host's, and the module's contract with it holds", (
484484
host.db.transaction((tx) =>
485485
createFileArtifact(tx, InlineContentStore, {
486486
scope: host.scope(),
487-
ownerPrincipalId: host.scope().principal,
487+
ownerPrincipalId: host.scope().principalId,
488488
filename: "logo.svg",
489489
mimeType: "image/svg+xml",
490490
bytes: new Uint8Array(Buffer.from("<svg/>")),
@@ -503,7 +503,7 @@ describe("a skill-draft is invisible over the mounted host", () => {
503503
const [draft] = await host.db.execute<{ id: string }>(sql`
504504
INSERT INTO "artifacts"."artifact" ("tenant_id", "principal_id", "owner_principal_id",
505505
"kind", "title", "content", "source", "version")
506-
VALUES (${host.tenant}, ${draftAuthor}, ${draftAuthor}, 'skill-draft', 'Scratch',
506+
VALUES (${host.tenantId}, ${draftAuthor}, ${draftAuthor}, 'skill-draft', 'Scratch',
507507
'draft body', '{"origin":"agent"}'::jsonb, 1)
508508
RETURNING "id"
509509
`);

‎package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"examples/*"
88
],
99
"scripts": {
10-
"typecheck": "tsc --noEmit",
10+
"typecheck": "bun run build && tsc --noEmit",
1111
"build": "bun run --cwd packages/artifacts build",
1212
"test": "bun run test:package && bun run test:acceptance",
1313
"test:package": "bun run --cwd packages/artifacts test",
@@ -17,5 +17,8 @@
1717
"@types/bun": "1.1.14",
1818
"@types/node": "22.10.5",
1919
"typescript": "5.7.2"
20+
},
21+
"overrides": {
22+
"drizzle-orm": "0.45.2"
2023
}
2124
}

0 commit comments

Comments
 (0)