diff --git a/README.md b/README.md index acc436f..1d6288f 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,17 @@ Run-scoped routes for a workflow run, which authenticates with a bearer token an | `uploadPolicy` | `UploadPolicy` | Optional. MIME types `POST /artifacts/binary` accepts. Defaults to `ARTIFACT_UPLOAD_POLICY`. | | `maxBinaryBytes` | `number` | Optional. Byte ceiling for `POST /artifacts/binary`. Defaults to `MAX_UPLOAD_BYTES`. | | `maxContentChars` | `number` | Optional. Character ceiling on the resulting content — full text or edited text — for `POST /artifacts` and `PATCH /artifacts/:id/versions`. Defaults to 64,000. | +| `beforeWrite` | `WorkflowBeforeWrite` | Optional. Checks each text create (`POST /artifacts`) and revision (`PATCH /artifacts/:id`) before it is written. Omitted, every valid write lands. | + +`beforeWrite({ scope, kind, write, content })` gets the run scope, the artifact kind, `write` as `{ op: "create" }` or `{ op: "revise", artifactId }`, and the content the version will hold after any edits. It returns one of: + +- `{ action: "allow" }` writes as usual. +- `{ action: "refuse", code, message }` answers `422` with `{ code, message }`, and the agent's tool error is `message`. `code` must match `/^[a-z0-9_]+$/`; `message` must be 1 to 1,000 characters. +- `{ action: "redirect", artifactId, expectedVersion? }` turns a create into a revision of that artifact, which must be of the same kind and in the run's tenant, answering `200` with its `{ id, version }`. The revision takes the create's title. A missing, archived or other-tenant artifact answers `404`, and a stale `expectedVersion` answers `409` telling the agent to read the existing artifact and revise it. + +Any other decision, a redirect returned for a revision, or a redirect to an artifact of another kind answers `500` and writes nothing. Binary uploads and linked files are not checked. + +A create's check is not serialized: two concurrent creates can both be allowed, so a policy like "one plan per tenant" can still end with two plans when runs race. A revision's check runs inside the transaction holding the artifact's row lock, so it should be fast and must not write that artifact. Every write (`POST /artifacts`, `POST /artifacts/binary`, `POST /artifacts/link-file`, `PATCH /artifacts/:id`) answers `{ data: { id, version, contentSha256 } }`, with the same digest the tenant routes report. For `link-file` the digest covers the stored `preview` text (empty when none is given), not the workspace file at `path`. diff --git a/e2e/reference-host.test.ts b/e2e/reference-host.test.ts index 3a0b7fb..ca4af72 100644 --- a/e2e/reference-host.test.ts +++ b/e2e/reference-host.test.ts @@ -17,6 +17,7 @@ import { UnsupportedUploadTypeError, type ContentStore, } from "../src/index.js"; +import { sha256Hex } from "../src/artifacts.js"; import { createReferenceHost, type ReferenceHost, @@ -722,3 +723,43 @@ describe("the migration runner is re-runnable", () => { expect(survived.artifacts.length).toBeGreaterThan(0); }); }); + +describe("the host's beforeWrite policy on agent writes", () => { + const runPost = (body: unknown): RequestInit => ({ + method: "POST", + headers: { "content-type": "application/json", ...host.runHeaders }, + body: JSON.stringify(body), + }); + + test("refuses a plan with no heading, then folds a second plan into the first", async () => { + const refused = await host.request( + "/api/workflow-artifacts/artifacts", + runPost({ title: "Plan", kind: "plan", content: "Ship." }), + ); + expect(refused.status).toBe(422); + expect(await refused.json()).toEqual({ + code: "plan_needs_heading", + message: "Start the plan with a '# ' heading.", + }); + + const first = await host.request( + "/api/workflow-artifacts/artifacts", + runPost({ title: "Plan", kind: "plan", content: "# Plan\nShip." }), + ); + expect(first.status).toBe(201); + const { data: created } = await json<{ data: { id: string } }>(first); + + const second = await host.request( + "/api/workflow-artifacts/artifacts", + runPost({ title: "Plan v2", kind: "plan", content: "# Plan\nShip now." }), + ); + expect(second.status).toBe(200); + expect(await second.json()).toEqual({ + data: { + id: created.id, + version: 2, + contentSha256: sha256Hex("# Plan\nShip now."), + }, + }); + }); +}); diff --git a/e2e/workflow-mount.test.ts b/e2e/workflow-mount.test.ts index cbd250b..1a0654c 100644 --- a/e2e/workflow-mount.test.ts +++ b/e2e/workflow-mount.test.ts @@ -2,11 +2,16 @@ import { describe, expect, test } from "bun:test"; import { sql } from "drizzle-orm"; import { createWorkflowArtifactRoutes, + type CreatedWorkflowArtifact, type ResolvedWorkflowRunScope, + type WorkflowBeforeWrite, + type WorkflowWriteCheck, + type WorkflowWriteDecision, } from "../src/workflow-mount.js"; import { InlineContentStore } from "../src/content-store.js"; import { getArtifact, + listArtifacts, setArtifactArchived, sha256Hex, } from "../src/artifacts.js"; @@ -28,6 +33,7 @@ function host( opts: { resolves?: ResolvedWorkflowRunScope | null; maxContentChars?: number; + beforeWrite?: WorkflowBeforeWrite; } = {}, ) { return createWorkflowArtifactRoutes({ @@ -36,6 +42,9 @@ function host( ...(opts.maxContentChars !== undefined ? { maxContentChars: opts.maxContentChars } : {}), + ...(opts.beforeWrite !== undefined + ? { beforeWrite: opts.beforeWrite } + : {}), resolveRunScope: (token, address) => { if (opts.resolves === undefined) { return token === VALID_TOKEN && address === VALID_ADDRESS @@ -823,3 +832,164 @@ describe("user-input errors", () => { expect((await read("?version=2147483648")).status).toBe(400); }); }); + +describe("beforeWrite", () => { + const plan = { title: "Plan", kind: "plan", content: "## Plan\nShip." }; + const created = async (res: Response) => + ((await res.json()) as { data: CreatedWorkflowArtifact }).data; + + test("a refused create is 422 with the code and message, and writes nothing", async () => { + const db = await testDb(); + const checks: WorkflowWriteCheck[] = []; + const app = host(db, { + beforeWrite: (check) => { + checks.push(check); + return { + action: "refuse", + code: "plan_exists", + message: "Revise the existing plan instead.", + }; + }, + }); + const res = await app.request("/artifacts", json(plan)); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + code: "plan_exists", + message: "Revise the existing plan instead.", + }); + expect(checks).toEqual([ + { + scope: RUN_SCOPE, + kind: "plan", + write: { op: "create" }, + content: plan.content, + }, + ]); + expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(0); + }); + + test("a refused revision sees the edited content and leaves the version alone", async () => { + const db = await testDb(); + const row = await seedArtifact(db, { + tenantId: "acme", + kind: "design", + content: "## Plan\nShip.", + }); + const checks: WorkflowWriteCheck[] = []; + const app = host(db, { + beforeWrite: (check) => { + checks.push(check); + return { action: "refuse", code: "too_vague", message: "Add a date." }; + }, + }); + const res = await app.request( + `/artifacts/${row.id}`, + patchJson({ edits: [{ oldText: "Ship.", newText: "Ship soon." }] }), + ); + expect(res.status).toBe(422); + expect(checks).toEqual([ + { + scope: RUN_SCOPE, + kind: "design", + write: { op: "revise", artifactId: row.id }, + content: "## Plan\nShip soon.", + }, + ]); + expect((await getArtifact(db, row.id))?.version).toBe(1); + }); + + test("a redirected create revises the named artifact under expectedVersion", async () => { + const db = await testDb(); + const row = await seedArtifact(db, { tenantId: "acme", kind: "plan" }); + const redirectTo = (expectedVersion: number) => + host(db, { + beforeWrite: () => ({ + action: "redirect", + artifactId: row.id, + expectedVersion, + }), + }); + + const res = await redirectTo(1).request("/artifacts", json(plan)); + expect(res.status).toBe(200); + expect(await created(res)).toEqual({ + id: row.id, + version: 2, + contentSha256: sha256Hex(plan.content), + }); + const revised = await getArtifact(db, row.id); + expect(revised?.title).toBe("Plan"); + expect(revised?.content).toBe(plan.content); + expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(1); + + const stale = await redirectTo(1).request("/artifacts", json(plan)); + expect(stale.status).toBe(409); + expect(await stale.json()).toEqual({ + error: `A plan already exists as artifact ${row.id} at version 2. Read it, then revise it.`, + }); + expect((await getArtifact(db, row.id))?.version).toBe(2); + }); + + test("a redirect to another tenant's artifact is 404 and writes nothing", async () => { + const db = await testDb(); + const foreign = await seedArtifact(db, { tenantId: "other" }); + const app = host(db, { + beforeWrite: () => ({ action: "redirect", artifactId: foreign.id }), + }); + const res = await app.request("/artifacts", json(plan)); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: + "This plan could not be created: the existing plan it should revise was not found.", + }); + expect((await getArtifact(db, foreign.id))?.version).toBe(1); + expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(0); + }); + + test("a redirect to an artifact of another kind fails closed", async () => { + const db = await testDb(); + const design = await seedArtifact(db, { tenantId: "acme", kind: "design" }); + const app = host(db, { + beforeWrite: () => ({ action: "redirect", artifactId: design.id }), + }); + expect((await app.request("/artifacts", json(plan))).status).toBe(500); + const unchanged = await getArtifact(db, design.id); + expect(unchanged?.version).toBe(1); + expect(unchanged?.content).not.toBe(plan.content); + expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(1); + }); + + test("a malformed decision fails closed", async () => { + const db = await testDb(); + const row = await seedArtifact(db, { tenantId: "acme" }); + const malformed: unknown[] = [ + { action: "deny" }, + { action: "refuse", code: "Bad Code", message: "x" }, + { action: "refuse", code: "no_message" }, + { action: "refuse", code: "empty_message", message: " " }, + { action: "refuse", code: "long_message", message: "x".repeat(1_001) }, + ]; + for (const decision of malformed) { + // A host bug that the decision type alone cannot rule out at runtime. + const app = host(db, { + beforeWrite: () => decision as WorkflowWriteDecision, + }); + expect((await app.request("/artifacts", json(plan))).status).toBe(500); + const revise = await app.request( + `/artifacts/${row.id}`, + patchJson({ content: "changed" }), + ); + expect(revise.status).toBe(500); + } + const reviseRedirect = host(db, { + beforeWrite: () => ({ action: "redirect", artifactId: row.id }), + }); + const res = await reviseRedirect.request( + `/artifacts/${row.id}`, + patchJson({ content: "changed" }), + ); + expect(res.status).toBe(500); + expect((await getArtifact(db, row.id))?.version).toBe(1); + expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(1); + }); +}); diff --git a/examples/reference-host/src/index.ts b/examples/reference-host/src/index.ts index a874631..2337552 100644 --- a/examples/reference-host/src/index.ts +++ b/examples/reference-host/src/index.ts @@ -41,12 +41,16 @@ import { import { InlineContentStore, createArtifactRoutes, + createWorkflowArtifactRoutes, + listArtifacts, runArtifactMigrations, type ArtifactDb, type ResolvedPrincipal, type ContentStore, type SerializedArtifactBase, + type WorkflowBeforeWrite, } from "../../../src/index.js"; +import { WORKFLOW_ARTIFACTS_BASE_PATH } from "../../../src/sidecar-bundle.js"; import { databaseConfig, DATABASE_URL } from "../../../e2e/helpers.js"; const EPOCH = new Date(0); @@ -66,6 +70,39 @@ async function decorate( export type Session = { userId: string } | null; +/** The one workflow run this host resolves, standing in for its run table. */ +const RUN_TOKEN = "reference-sidecar-token"; +const RUN_ADDRESS = "reference-run"; + +/** + * The host's write policy for agents: a plan opens with a heading, and a + * tenant keeps one plan, so a second plan create revises the first. + */ +function planPolicy(db: ArtifactDb): WorkflowBeforeWrite { + return async ({ scope, kind, write, content }) => { + if (kind !== "plan") return { action: "allow" }; + if (!content.startsWith("# ")) { + return { + action: "refuse", + code: "plan_needs_heading", + message: "Start the plan with a '# ' heading.", + }; + } + if (write.op === "revise") return { action: "allow" }; + const { rows } = await listArtifacts(db, scope.tenantId, { + kind: "plan", + limit: 1, + }); + const [existing] = rows; + if (existing === undefined) return { action: "allow" }; + return { + action: "redirect", + artifactId: existing.id, + expectedVersion: existing.version, + }; + }; +} + export type ReferenceHost = { db: ArtifactDb; /** The `DBConfig` the host migrates with. */ @@ -81,6 +118,8 @@ export type ReferenceHost = { * `createFileArtifact` with this. */ scope: () => ResolvedPrincipal; + /** Headers a workflow run presents to `WORKFLOW_ARTIFACTS_BASE_PATH`. */ + runHeaders: Record; /** Who the hub's session middleware will report. `null` means signed out. */ setSession: (session: Session) => void; /** Request the default host (InlineContentStore, real DB-backed grants). */ @@ -355,6 +394,22 @@ export async function createReferenceHost(): Promise { decorate, }), ); + app.route( + WORKFLOW_ARTIFACTS_BASE_PATH, + createWorkflowArtifactRoutes({ + db, + contentStore, + resolveRunScope: (token, address) => + token === RUN_TOKEN && address === RUN_ADDRESS + ? { + tenantId: tenant.id, + principalId: agentPrincipal, + runId: address, + } + : null, + beforeWrite: planPolicy(db), + }), + ); const mounted = app.route("/api", api); // `Hono#request` may answer synchronously; normalize to a promise so every // caller can simply await it. @@ -371,6 +426,10 @@ export async function createReferenceHost(): Promise { config, tenantId: tenant.id, agentPrincipal, + runHeaders: { + authorization: `Bearer ${RUN_TOKEN}`, + "x-workflow-run-address": RUN_ADDRESS, + }, scope: () => ({ tenantId: tenant.id, principalId: principals.find( diff --git a/src/artifacts.ts b/src/artifacts.ts index b3558d9..d2cd8a8 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -419,6 +419,9 @@ export async function reviseArtifactVersion( * thrown and nothing is written. Omitted preserves today's behavior. */ expectedVersion?: number; + /** Runs under the lock with the content this version will hold; a throw + * rolls the revision back. */ + beforeWrite?: (locked: ArtifactRow, content: string) => Promise; }, now: Date, ): Promise { @@ -478,6 +481,7 @@ export async function reviseArtifactVersion( ); } } + if (args.beforeWrite !== undefined) await args.beforeWrite(existing, content); const metadata = args.metadata === undefined ? (existing.metadata as Record | null) @@ -542,6 +546,8 @@ export async function writeArtifactVersion( parentVersionIds?: string[] | null; /** See `VersionConflictError` — omitted preserves today's behavior. */ expectedVersion?: number; + /** See {@link reviseArtifactVersion}. */ + beforeWrite?: (locked: ArtifactRow, content: string) => Promise; }, ): Promise<{ artifactId: string; diff --git a/src/index.ts b/src/index.ts index ccbabf7..6bf9dc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,10 @@ export type { CreateWorkflowArtifactRoutesDeps, ResolvedWorkflowRunScope, WorkflowArtifactEnv, + WorkflowBeforeWrite, WorkflowRunResolver, + WorkflowWriteCheck, + WorkflowWriteDecision, } from "./workflow-mount.js"; export { diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts index 1db90c9..d022113 100644 --- a/src/sidecar-bundle.test.ts +++ b/src/sidecar-bundle.test.ts @@ -278,6 +278,30 @@ describe("the artifacts sidecar bundle", () => { expect(result.content).toBe("Artifact not found"); }); + test("a host refusal reaches the agent as its message", async () => { + const recorded: Recorded[] = []; + const bundle = artifacts( + env( + recorded, + () => + new Response( + JSON.stringify({ code: "duplicate", message: "Revise the plan." }), + { status: 422, headers: { "content-type": "application/json" } }, + ), + ), + ); + const result = await bundle.run( + { + id: "r", + name: "artifact_write", + arguments: { title: "Plan", kind: "plan", content: "x" }, + }, + signal, + ); + expect(result.isError).toBe(true); + expect(result.content).toBe("Revise the plan."); + }); + test("an unknown tool name is a tool error", async () => { const recorded: Recorded[] = []; const bundle = artifacts(env(recorded, ok)); diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts index ca27660..fd1f51e 100644 --- a/src/sidecar-bundle.ts +++ b/src/sidecar-bundle.ts @@ -140,6 +140,11 @@ async function readErrorMessage(response: Response): Promise { const error = (body as { error: unknown }).error; if (typeof error === "string") return error; } + // A host's `beforeWrite` refusal: `{ code, message }`. + if (typeof body === "object" && body !== null && "message" in body) { + const message = (body as { message: unknown }).message; + if (typeof message === "string") return message; + } return `the hub answered ${String(response.status)}`; } diff --git a/src/workflow-mount.ts b/src/workflow-mount.ts index f415c38..26ebbb6 100644 --- a/src/workflow-mount.ts +++ b/src/workflow-mount.ts @@ -48,6 +48,7 @@ import { type UploadPolicy, } from "./uploads.js"; import type { ContentStore } from "./ports.js"; +import type { ArtifactRow } from "./schema.js"; const DEFAULT_RECENT_LIMIT = 10; const MAX_RECENT_LIMIT = 50; @@ -108,6 +109,40 @@ export type AgentTokenAuth = { | null; }; +/** A text write about to land, as `beforeWrite` sees it. `content` is what the + * version will hold, after any edits are applied. */ +export type WorkflowWriteCheck = { + readonly scope: ResolvedWorkflowRunScope; + readonly kind: string; + readonly write: + | { readonly op: "create" } + | { readonly op: "revise"; readonly artifactId: string }; + readonly content: string; +}; + +/** + * `refuse` answers 422 with `{ code, message }`; `code` must match + * `/^[a-z0-9_]+$/` and `message` must be 1 to 1,000 characters. `redirect` is + * only for a create: it becomes a revision of `artifactId`, an artifact of the + * same kind in the run's tenant, under `expectedVersion` when given. + */ +export type WorkflowWriteDecision = + | { readonly action: "allow" } + | { + readonly action: "refuse"; + readonly code: string; + readonly message: string; + } + | { + readonly action: "redirect"; + readonly artifactId: string; + readonly expectedVersion?: number; + }; + +export type WorkflowBeforeWrite = ( + check: WorkflowWriteCheck, +) => Promise | WorkflowWriteDecision; + export type CreateWorkflowArtifactRoutesDeps = { db: ArtifactDb; contentStore: ContentStore; @@ -124,6 +159,8 @@ export type CreateWorkflowArtifactRoutesDeps = { maxBinaryBytes?: number; /** Character ceiling for `POST /artifacts`' `content`. */ maxContentChars?: number; + /** Checks `POST /artifacts` and `PATCH /artifacts/:id` before they write. */ + beforeWrite?: WorkflowBeforeWrite; }; export type CreatedWorkflowArtifact = { @@ -133,6 +170,19 @@ export type CreatedWorkflowArtifact = { readonly contentSha256: string | null; }; +const REFUSAL_CODE = /^[a-z0-9_]+$/; +const MAX_REFUSAL_MESSAGE_CHARS = 1_000; + +class WriteRefusedError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "WriteRefusedError"; + } +} + // Opaque to this package, same shape `mount.ts` validates: any JSON object, // or `null` to clear it explicitly. Mirrors `NullableMetadata` there. const NullableMetadata = MetadataShape.or("null"); @@ -221,8 +271,12 @@ function readFailure(c: Context, err: unknown): Response { } /** An archived or missing artifact reads as 404, an oversized field or an - * edit that does not land as 400, a stale `expectedVersion` as 409. */ + * edit that does not land as 400, a stale `expectedVersion` as 409, a host + * refusal as 422. */ function writeFailure(c: Context, err: unknown): Response { + if (err instanceof WriteRefusedError) { + return c.json({ code: err.code, message: err.message }, 422); + } if (err instanceof ArtifactNotFoundError) { return c.json({ error: "Artifact not found" }, 404); } @@ -240,6 +294,33 @@ function writeFailure(c: Context, err: unknown): Response { throw err; } +/** The agent asked to create, so a failed redirect speaks of the existing + * artifact of that kind rather than an id the agent never named. */ +function redirectFailure( + c: Context, + err: unknown, + kind: string, + artifactId: string, +): Response { + if (err instanceof ArtifactNotFoundError) { + return c.json( + { + error: `This ${kind} could not be created: the existing ${kind} it should revise was not found.`, + }, + 404, + ); + } + if (err instanceof VersionConflictError) { + return c.json( + { + error: `A ${kind} already exists as artifact ${artifactId} at version ${err.currentVersion}. Read it, then revise it.`, + }, + 409, + ); + } + return writeFailure(c, err); +} + function parseRecentLimit(raw: string | undefined): number { if (raw === undefined || raw === "") return DEFAULT_RECENT_LIMIT; const n = Number.parseInt(raw, 10); @@ -262,9 +343,46 @@ export function createWorkflowArtifactRoutes({ uploadPolicy = ARTIFACT_UPLOAD_POLICY, maxBinaryBytes = MAX_UPLOAD_BYTES, maxContentChars = DEFAULT_MAX_WORKFLOW_CONTENT_CHARS, + beforeWrite, }: CreateWorkflowArtifactRoutesDeps): Hono { const app = new Hono(); + /** Throws `WriteRefusedError` on a refusal; a malformed decision is the + * host's bug and throws plainly, so nothing is written. */ + async function decideWrite( + check: WorkflowWriteCheck, + ): Promise { + if (beforeWrite === undefined) return { action: "allow" }; + const decision = await beforeWrite(check); + switch (decision.action) { + case "allow": + return decision; + case "refuse": + if (!REFUSAL_CODE.test(decision.code)) { + throw new Error( + `beforeWrite refusal code must match ${String(REFUSAL_CODE)}`, + ); + } + if ( + typeof decision.message !== "string" || + decision.message.trim() === "" || + decision.message.length > MAX_REFUSAL_MESSAGE_CHARS + ) { + throw new Error( + `beforeWrite refusal message must be 1 to ${MAX_REFUSAL_MESSAGE_CHARS} characters`, + ); + } + throw new WriteRefusedError(decision.code, decision.message); + case "redirect": + if (check.write.op !== "create") { + throw new Error("beforeWrite can only redirect a create"); + } + return decision; + default: + throw new Error("beforeWrite returned an unknown action"); + } + } + const authenticate: MiddlewareHandler = async ( c, next, @@ -331,6 +449,50 @@ export function createWorkflowArtifactRoutes({ } const scope = c.get("workflowRunScope"); + let decision: WorkflowWriteDecision; + try { + decision = await decideWrite({ + scope, + kind: parsed.kind, + write: { op: "create" }, + content: parsed.content, + }); + } catch (err) { + return writeFailure(c, err); + } + if (decision.action === "redirect") { + let written: Awaited>; + try { + written = await writeArtifactVersion(db, { + scope: { tenantId: scope.tenantId, principalId: scope.principalId }, + artifactId: decision.artifactId, + title: parsed.title, + content: parsed.content, + ...(decision.expectedVersion !== undefined + ? { expectedVersion: decision.expectedVersion } + : {}), + ...(parsed.metadata !== undefined + ? { metadata: parsed.metadata } + : {}), + maxContentChars, + beforeWrite: async (locked) => { + if (locked.kind !== parsed.kind) { + throw new Error( + `beforeWrite redirected a ${parsed.kind} create to a ${locked.kind} artifact`, + ); + } + }, + }); + } catch (err) { + return redirectFailure(c, err, parsed.kind, decision.artifactId); + } + const revised: CreatedWorkflowArtifact = { + id: written.artifactId, + version: written.version, + contentSha256: written.contentSha256, + }; + return c.json({ data: revised }); + } let row: Awaited>; try { row = await db.transaction(async (tx) => { @@ -565,6 +727,18 @@ export function createWorkflowArtifactRoutes({ : {}), ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), maxContentChars, + ...(beforeWrite !== undefined + ? { + beforeWrite: async (locked: ArtifactRow, content: string) => { + await decideWrite({ + scope, + kind: locked.kind, + write: { op: "revise", artifactId }, + content, + }); + }, + } + : {}), }); } catch (err) { return writeFailure(c, err);