Job Policy Seam — Design Note
Date: 2026-09-03 Status: Implemented
Summary
Some deployments must judge what they generate. Not “filter the picker” (that is
the catalog pack seam) and not “fold a clause into every
prompt” (that is the prompt policy, docs/deployment.md), but: decide whether
this generation may run at all, and whether its output may be published.
The job policy seam makes that decision a registration. A deployment registers one policy object at its composition root; the platform asks it at two chokepoints in a job’s life and applies the verdict itself — including the money and the storage consequences, which a deployment must not have to re-implement and must not be able to get wrong.
With no policy registered the platform is byte-identical to stock: the funnels return allow before calling anything and before writing anything, so an install that does not use the seam does not even acquire the audit table as a dependency.
The two hook points
Everything the platform generates is a job row. Two moments matter, and they matter for different reasons.
| Request | Result | |
|---|---|---|
| Chokepoint | lib/insert-job.ts — the funnel every job-creating route, orchestrator node and pipeline stage passes through. Plugin-created jobs are the one exception: the plugin toolkit’s insertJobWithIdempotencyKey inserts through lib/idempotent-insert.ts directly and is never asked at the request gate, so for a plugin job (generate-video-pro, video-analysis import) the result gate is the only enforcement point |
the completion funnels: workers/shared.ts :: markJobCompleted and lib/job-finalize.ts :: finalizeJobWithMedia |
| Asked | before the row exists and before credits are reserved | after the output is written to storage, before the completion write, the asset row and the credit commit |
| Verdicts | allow, block |
allow, flag, block, hold |
| Why here | a block leaves nothing to unwind: no row, no reservation, no queue entry, no refund | the last moment at which nothing is published — the asset row, the gallery, output_data and every downstream node read what happens after this point |
Choosing the insert funnel rather than worker pickup is the load-bearing decision on the request side. At pickup a row already exists and a reservation has already been taken, so every block becomes a refund, a status transition and a queue entry to clean up — three chances to be wrong about money. At insert the answer is a 422 and an absence.
What the request gate can see is stated, not hidden. Jobs created by a
route, the MCP server, the SDK, the CLI or the browser extension — and the
children a pipeline stage creates — arrive at the funnel with their full input,
so the request gate can judge content. Jobs a plugin creates through the
toolkit’s insertJobWithIdempotencyKey (generate-video-pro, the video-analysis
import) do not arrive at all: that lane inserts outside lib/insert-job.ts,
the request gate is never asked, and the result gate is their only enforcement
point. Jobs a workflow run or an app run creates for its nodes arrive without
their content: the orchestrator inserts a provenance-only row ({ type,
node_id } plus the provenance columns) and attaches the real payload in a
separate update after the insert. For those the request gate can gate by user,
source, node type or rate, but it cannot judge content — inputData in the
request context is documented as “may be a placeholder for orchestrated
children; check workflowExecutionId”. The result gate is the enforcement
point for orchestrated jobs, which is exactly why it is fail-closed too.
Both funnels are covered by totality tests
(lib/__tests__/no-direct-job-insert.test.ts and
lib/__tests__/job-policy-request-totality.test.ts on the request side,
workers/__tests__/job-policy-result-totality.test.ts on the result side) that
fail the build if a new job insert, completion or failure write is added
outside them — with one named exception on the request side: the plugin
toolkit’s insertJobWithIdempotencyKey lane is allowlisted by the insert guard
and is not gated. It is the same discipline the prompt-policy and upload-policy
totality tests apply to their own coverage.
The policy object
One registration, two optional checks, mirroring the upload-policy registry:
interface JobPolicy {
readonly id: string
checkRequest?(ctx: JobRequestContext): JobRequestVerdict | Promise<JobRequestVerdict>
checkResult?(ctx: JobResultContext): JobResultVerdict | Promise<JobResultVerdict>
}
type JobRequestVerdict =
| { verdict: "allow" }
| { verdict: "block"; reason: string; userMessage?: string }
type JobResultVerdict =
| { verdict: "allow" }
| { verdict: "flag"; reason: string; labels?: readonly string[] }
| { verdict: "block"; reason: string; userMessage?: string }
| { verdict: "hold"; reason: string }
A block carries two strings on purpose, and they never substitute for one
another. reason is machine text: it goes to the audit row and nowhere else.
userMessage is what the user sees — in the 422 body and in
error_hint.reason. A policy that omits userMessage gets a platform-owned
sentence for that hook point (“This request is not allowed on this deployment”,
“This result was blocked by content policy”), never its own reason. A
moderation label that reads like a classifier’s output must never be the
sentence a person is shown, and no default quietly makes it one.
The result context hands the policy outputs[] — every http(s) URL found in
the job’s output_data, each with the storage key when the object is the
platform’s own — plus mediaKind, holdEligible (below) and which funnel is
asking. A policy never re-implements URL extraction, and a media job that
arrives with zero outputs is visible as such, so a policy can fail closed on
“media kind, nothing to inspect”.
Three policy ids are reserved and registerJobPolicy throws on them: * (an
allow every registered policy agreed to), platform (a fail-closed
resolution or a hold expiry) and review (a human decision on a held job — an
admin’s approve or reject, or the owner’s own cancel, recorded as
withdrawn).
Ordering. With several policies registered the request gate is asked in
registration order and the first block wins. The result gate asks every
policy — an audit wants each one’s opinion — and combines by severity
block > hold > flag > allow, short-circuiting only on block, so a later
flag can never soften an earlier hold.
Verdicts, and what the platform does with them
| Verdict | Job | Credits | Storage | Caller |
|---|---|---|---|---|
allow |
— | — | — | — |
block (request) |
no row created | no reservation taken | nothing written | 422 { error: { code: "job_blocked", message } }; an internal creator gets a typed error it must handle |
flag (result) |
completes; nothing is written on the job row | commits | publishes | nothing withheld; the annotation lives only in the audit row |
block (result) |
failed, error_hint = { kind: "policy-block", policyId, reason, hookPoint } |
refunded in full | output_data is cleared (a checkpoint already on the row moves to held_output_data) and every object in the job’s own key family is deleted |
the failure, with a user-safe reason |
hold (result) |
pending_review |
stay reserved | written, not published | “awaiting review”; the job stays in flight |
pending_review is a job status in the in-flight set: waiters keep waiting,
and it is exempt from the reconcile, timeout and cancel-in-flight sweeps. Its
resolution is a decision — approve puts the job back on the normal completion
path (nothing about the output differs from a job that was never held), reject
fails it with a refund, and the owner’s own cancel wins: a held job is
cancellable like any in-flight job, and cancelling refunds the reservation and
deletes the withheld object, so a reservation is never stranded behind a
reviewer’s response time.
Who may be held. hold is honoured only where the platform can replay the
completion later: a job completing through the finalize funnel, standing on its
own (no workflow execution, pipeline or parent job; not the director lane), on
an edition that has an admin surface to review it. The platform computes this
holdEligible flag itself and hands it to the policy. A hold on any other job
is applied as a block and recorded with hold_downgraded = true — never
quietly softened to flag, so the audit stays honest. The user sees a
platform-owned sentence for that block rather than the hold’s own text: a hold
carries no userMessage, so its reason was never written for a person to
read. The reason for the narrowness is mechanical: the finalize funnel’s
post-completion tail (gallery asset, execution reopen, reference-video attach)
is extracted and replayable; the many direct completion callers each run bespoke
side effects after their completion write that an approve hours later cannot
reproduce. Widening eligibility is a config change once those callers are made
replayable, and is a named follow-up.
Why block deletes the object rather than leaving it unreferenced: outputs live
in a public-read bucket at keys derived from the job id with a long immutable
cache. An unreferenced object at such a key is not withheld — it is unlisted and
permanently fetchable. Deleting removes future reach. It does not revoke a copy
an edge already served, which is the honest limit of a post-generation gate on
this storage posture; deferred publication (a private staging key, copied to the
public key on allow) is the fix and is not built. Deletion is by the job’s own
key family, never by URL: output_data routinely echoes the user’s input
URLs, and a URL-driven delete would destroy them. The family is the key’s stem —
<jobId> itself and anything under a <jobId>- prefix, so a plugin’s
per-segment and intermediate objects go with the block while a continuation’s
parent-owned objects stay with their parent — and the delete is uncapped:
truncating it would leave the tail of a long job live at guessable public keys
forever.
A block also clears output_data rather than merely declining to write
it. A long job may have checkpointed segment URLs into that column mid-run, and
those are owner-readable; stripping a key is not clearing a column. Whatever was
already there is moved into held_output_data, which no client-facing query
and no realtime payload can reach — the evidence stays available to an operator
without being published.
The same limit applies to hold: a held job’s output is unlisted, not
unreachable. The job row’s output_data is cleared to NULL, the held payload
lives in columns no client-facing query can read, and a reviewer sees the media
by streaming it through the admin route — never through a public URL — but the
object itself sits at its deterministic key until the review resolves: approve
publishes it; reject, cancel and expiry delete it.
pending_review is also a status a client cannot put a row into. The jobs
insert policy refuses it outright, so a signed-in user writing to the database
directly cannot plant a row in a reviewer’s queue.
Fail-closed
Once a policy is registered, an observer error is never a publish. The request
gate blocks; the result gate holds when the job is hold-eligible and
blocks otherwise. Both are recorded with policy_id = "platform" and
reason = "policy-unavailable", and the user sees a platform-owned sentence
(“Generation could not be verified”) — never a policy’s own wording, so an
outage cannot read as a judgement about someone’s prompt. The platform adds no
timeout of its own around a check; it applies only a 120-second backstop that
resolves to this same path rather than throwing, so a deployment’s own
error-handling stance inside its check stays reachable. A deployment that
prefers availability over enforcement catches inside its own check and returns
allow — the same escape hatch the upload policy documents, and deliberately not
an environment variable: it is a decision about what the product is.
The same stance covers the one input the gate cannot do without. The result gate
reads the job row once — for holdEligible, and for whatever a mid-run
checkpoint may already have written — and a read that fails is not a job that
is gone: the database driver reports both as an empty result. Only a confirmed
zero-row read answers allow. Any other error is retried once and then blocks,
recorded the same way (policy_id = "platform", reason = "policy-unavailable").
It never holds: hold eligibility is mostly columns on the row nobody could read,
and unknown eligibility is not eligibility.
Asked once per payload
Every decision — allow included — is written to job_policy_decisions
(job_id, hook_point, policy_id, verdict, reason, user_message,
labels, payload_hash, applied, hold_downgraded, user_id, job_type,
latency_ms, created_at, plus the resolver on review rows), and a verdict
already recorded for (job_id, hook_point, payload_hash) is never
re-asked. That is what makes the seam safe under the two things that
legitimately re-run a job: the queue’s own stall retries and the reconcile
cron, both of which re-derive the same output_data from the same provider
result.
Re-asking and re-applying are different things. An allow or flag hit is
simply returned, and costs not even a row read. A block or hold hit reads
the job row once: if the job has already reached the verdict’s outcome — failed,
cancelled, completed, or parked for review — the stored verdict is returned and
nothing is written. If the job is somehow still running, the stored verdict
is applied to it now, through the same compare-and-set as a fresh one. What the
user is shown then is the sentence that decision recorded in user_message —
never its reason, which is machine text kept for the audit; a decision written
before that column existed falls back to a platform-owned sentence rather than
guessing. That case exists because the decision is recorded before it is carried
out: a process that dies in between, or a database that refuses the write, would
otherwise leave a job that a policy has judged but nothing ever acted on —
running forever with its credits held. The policy is still asked only once, and
only the first recording is kept. Recording allow is not bookkeeping — it is
the idempotency key; without it every retry re-asks the
deployment’s gate, and a gate that costs money or rate-limits would be asked an
unbounded number of times for one job. An allow row is written at a hook point
only when some registered policy implements that hook, so a policy that checks
results alone does not leave one dead request row per generation.
payload_hash is over the gated content, not over the row: output_data at
the result gate, { jobType, userId, inputData } at the request gate. Two
genuinely different payloads on the same job are two decisions; an incidental
column change is not a re-gate.
The guarantee is asymmetric, and deliberately so. At the result gate there is a
job id to key on and a produced output that a retry would re-judge identically —
so a recorded verdict is reused. At the request gate a block has no row at all
(job_id is NULL on that audit row) and an allow can only be recorded once the
insert has returned an id, so a repeated request is a repeated decision. Request
deduplication is the insert funnel’s existing idempotency-key path, not the
policy’s.
The audit table is readable by the service role only — row-level security on, no policies. Its rows carry a moderation reason, which is the deployment’s business, not the job owner’s; the admin surface reads it through the backend.
Review
Held jobs are resolved from Admin → Content Review (/admin/review),
backed by GET /v1/admin/review/jobs (the queue, oldest first — the job’s
status is the authority, so a job cancelled out from under a hold never
appears), GET /v1/admin/review/jobs/:jobId,
GET /v1/admin/review/jobs/:jobId/output/:index (the held bytes, streamed
through the admin route with Cache-Control: private, no-store; the key is
read server-side from the job row, never from the client),
POST …/:jobId/approve, POST …/:jobId/reject (a reason is required — it
becomes error_hint.reason, which the owner sees), and
GET /v1/admin/review/decisions (the audit trail; never a URL). The routes
are admin-gated and there is no bulk verb: one job per decision, and every
resolution records who made it.
Approve performs its own compare-and-set pending_review → completed, replays
the completion fields the funnel stored at hold time (including the caller’s
metering flag, so a metered provider is charged its actual cost rather than the
reservation ceiling) and runs the extracted completion tail. The completion
funnel’s own compare-and-set is not widened to admit pending_review —
that would let a stray worker complete a held row and re-enter the result gate.
A second approve, or an approve after a reject or a cancel, answers 409 with
the current status; it is a normal outcome, not an error.
A hold has a clock. JOB_HOLD_TTL_HOURS (docs/deployment.md) bounds
how long a job may wait in pending_review. On expiry the platform
auto-rejects: refund, delete, and an audit row with policy_id =
"platform", reason = "hold-expired". The sentence the owner is left with is
checked against what the refund actually moved: if nothing was still reserved,
the expiry says so instead of promising credits back, and an operator report is
filed — a hold that expires with nothing to refund is a billing defect, not a
message to soften. Unset, holds never expire and the reservation waits for a
human. This is the one sweep permitted to touch a
pending_review row, stated as an explicit exception to the exemption above.
Auto-approve is deliberately not offered: it would publish exactly the output
a human declined to look at.
Failure writes, consolidated
The seam needed one place to fail a job — and found five shapes. Failures were
inline compare-and-set updates scattered across the workers and the reconcile
sweeps, two of them without a compare-and-set at all, plus a route-side helper
in the plugin toolkit. markJobFailed(jobId, { error_message, error_hint?, … })
in lib/job-failure.ts is now the compare-and-set-guarded failure writer for
every lane the result gate can hold — the media workers, the reconcile sweeps
and the plugin-toolkit helper. It is not the only writer in the codebase:
in-route settlers, sinks and the text/JSON lanes keep their own terminal write
(a few of them still without a compare-and-set), because none of them can
produce a held row. Each is enumerated with its reason in
backend/src/workers/__tests__/job-policy-result-totality.test.ts, and that
allowlist — not this paragraph — is the source of truth. The guard fails the
build on a status: "failed" write in any file not on the list; a new write
added inside a file already on it is not caught. markJobFailed
deliberately does not refund — the refund decision is caller-specific — and its
default from set is pending, queued, processing: pending_review is
absent on purpose, so no sweep can ever fail a held job; only reject names it
explicitly. A policy block is not a new column: it is a new error_hint
kind, so it reaches the editor, the API, the SDK and MCP through the same
path a provider content-policy block already uses.
Result context and classification labels
The result context includes the job’s stored inputData. A deployment can
select the prompt fields it needs without sending the entire request payload
to a moderation service. requesterIdentity() is a lazy lookup of the job
requester’s trusted SSO provider and subject from Auth app_metadata; it
returns null for an account without a federated identity and throws when the
identity service is unavailable. User-editable metadata is never consulted.
No identity request occurs unless a policy calls the method.
flag, block and hold verdicts can carry machine-readable labels into
the decision audit. Labels never become the user-visible explanation.
What is deliberately not gated
The seam sits on jobs. Anything that produces no job row — synchronous text
routes, streaming LLM responses, in-handler planning calls, prompt drafts written
before a row exists — is out of scope by construction, and no amount of
registration reaches it. A deployment that needs those judged needs a different
seam (the prompt policy covers the media-prompt lanes; the upload policy covers
ingested bytes). flag writes nothing on the job row and is invisible to the
user by design; turning a flag into a quieter enforcement lever (forcing the
output private, say) is a product decision and a named follow-up, not a shipped
behaviour.