Asset gating & NDA workflow — pre-release blur, scheduled actions
Ce contenu n’est pas encore disponible dans votre langue.
Amendment (2026-08-04): 0020 governs the IMAGE. It does not govern the PAYLOAD — and where it implied one, it is narrowed (#899)
Section titled “Amendment (2026-08-04): 0020 governs the IMAGE. It does not govern the PAYLOAD — and where it implied one, it is narrowed (#899)”The “Sensitive asset gating” section below is written as though a
restricted asset stays listed and identifiable: a blurred thumbnail, a
lock icon, a “Reveal” button, reviewers commenting on blurred content, an
embargo card reading “this content is embargoed until YYYY-MM-DD”. Every one
of those describes what the tile LOOKS LIKE. None of them says anything about
what the JSON carries, and for three releases the answer was “everything”.
⭐ Further amended 2026-08-13 (#902, PR #1063). The #899 narrowing above fixed what the
payload carried. It left a third channel open, and “listed and identifiable” turned out to
promise something on that channel too: the asset’s own withheld title was still in its
search_text, so any caller could recover it word by word — query a phrase only that title
holds, watch the result total move 0→1, then walk the remaining tokens. Identifiable to the
reader had quietly meant identifiable to the index.
Every full-text surface over assets now ANDs the field-plane rule onto the match
(visibility.AssetSearchMatchSQL; see ADR 0056 §4c). A restricted asset still appears in an
unfiltered browse with its blurred thumbnail and lock icon, exactly as this section requires —
it simply no longer answers text queries about words it does not show you.
The general form, worth carrying to the next tier decision: a tier that displays something withheld has at least three channels to close — what the tile renders, what the JSON carries, and what the index answers. This ADR governed the first, #899 the second, #902 the third.
Verified on a live build on 2026-08-04, signed in as a user with
capabilities: [] who owned none of the assets involved:
GET /api/v1/assets/{id} on someone else’s restricted asset returned 200
with the title, the description, the complete SHA-256, the exact byte size, the
original filename, and the whole free-form metadata blob. Search returned the
same title and description; /search/suggest completed the title from a
prefix; the SENSITIVITY facet counted it. The BYTES were correctly gated
throughout — /file, /download and /variants/* all 404’d — so ADR 0064’s
content plane was working exactly as specified. The leak was never in the
binary plane. It was in every surface that describes a row.
The split, stated so the next reader does not have to infer it:
- ADR 0020 (this ADR) governs the IMAGE. Whether a tile renders blurred, whether there is a lock icon, whether a “Reveal” button appears, and how the blur is produced (server-baked variant, never CSS). Unchanged, and still the plan for Phase 1.28.
- #883 / #899 govern the FIELDS. Whether the payload behind that tile carries a
title, a hash, a size, a filename. The rule is
visibility.FieldsReadable— the conjunction of the row plane (ADR 0063) and the content plane (ADR 0064) — and a caller who fails it receives a placeholder whose complete key set isid,restricted,owner_display_name. The owner’s rule, verbatim (2026-08-03): “The placeholder should never leak info. Not even title. Only the owner’s name.”
The row still exists in every feed, which is 0020’s and 0064’s shared position
and is what makes “request access” (#881) mean anything. Nothing here removes a
row. Narrowed 2026-08-05 (#921) — see the amendment below.
Amendment (2026-08-05): the RULE still returns the row; the default FEED no longer draws it (#921)
Section titled “Amendment (2026-08-05): the RULE still returns the row; the default FEED no longer draws it (#921)”The struck sentence above conflated two layers, and #921 pulled them apart. Read as a statement about the access rule, it is still exactly true and still 0020’s and 0064’s shared position. Read as a statement about what the browse feed renders by default, it stopped being true when #921 made hiding restricted placeholders the default rather than an opt-in.
| layer | before #921 | after #921 | changed? |
|---|---|---|---|
| the access rule | does not exclude rows; sensitivity gates content, not rows | identical | NO |
| the default presentation | renders every row the rule returned | subtracts restricted ones in the feed | YES |
ListPosts still receives every row the rule returns. applyHideRestricted subtracts
afterwards, reading one already-computed field (PostMember.Restricted, written in exactly
one place off the single visibility.FieldsReadable call). Nothing about who may read what
moved. What moved is what the feed chooses to draw.
“Request access” still means something, which was the struck sentence’s real point. #913’s
button lives on the placeholder, and the placeholder still renders on GET /posts/{id} and in
collection contents — the two surfaces where a reader asked a question or opened a
container. It is the feed, where they were handed a grid they did not ask for, that stopped
drawing them. Measured motivation: one seeded account’s feed was 82 posts of which 27 were
entirely placeholders.
A fork this ADR should reconsider when Phase 1.28 lands. The Decision section below specifies
server-baked blurred thumbnails with a lock icon for restricted and embargo assets. A
blurred tile is a genuinely different proposition from a “you cannot have this” placeholder — it
shows the shape of the work rather than only its absence, so the busyness argument that motivated
#921 may not survive it. Whether hiding-by-default is still right once blur-and-reveal ships is
an open question, deliberately left open here. Do not treat #921 as having settled it.
Full reasoning, including the hide_restricted → show_restricted rename and the inverted
nil/error seam, lives in ADR 0064’s 2026-08-05 amendment.
Two places where the implementation is narrower than what 0020 says, deliberately:
- The thumbhash is withheld. A thumbhash IS a blur — a low-frequency reconstruction of the image — and shipping it to a caller who cannot open the asset is a client-side blur of exactly the kind the “Alternatives considered” section rejects as “trivially defeated”. It is withheld now. That does not pre-empt the server-baked blur variant this ADR specifies: that variant is a deliberate, operator-controlled derivative served under a capability, and Phase 1.28 remains free to serve it. The distinction is deliberate blur vs accidental blur.
- The embargo card’s DATE is not currently sent. “This content is embargoed
until YYYY-MM-DD” is a fact about the item, and the 2026-08-03 rule permits
only the owner’s name on a placeholder. When Phase 1.28 builds the embargo
card, adding
embargo_untilto the placeholder’s allow-list is a deliberate decision to make then, with the owner — not something to assume from this ADR’s prose. The allow-list is enforced byassets/field_withholding_test.go, so the decision cannot be made by accident.
Everything below stands as the Phase 1.28 design. Read it as the visual specification it is.
Amendment (2026-08-22): the engine grew a POST arm, and the pair table became explicit (#1238, PR #1256)
Section titled “Amendment (2026-08-22): the engine grew a POST arm, and the pair table became explicit (#1238, PR #1256)”Three facts the engine now embodies that this ADR did not state, plus one acceptance:
1. The supported (action, target) pairs are DERIVED from the executors and enforced at schedule
time. validTargets() used to accept post/collection/user for every verb while three of
the four mutating arms refused everything but asset — so a bad pair enqueued cleanly and failed
at fire time with nobody watching. executorTargets() now maps each verb to the targets its
executor genuinely acts on (notify runs on any target, because notifyRecipient addresses via
params.recipient; the mutating verbs are asset-only except as below), and Store.Schedule
refuses everything outside the table. Eleven previously-schedulable pairs became unschedulable.
An unrunnable action is refused while someone is watching, never deferred to a failure nobody
sees.
2. change_state on a post target goes through the PUBLICATION CORE, never through SQL. A
post’s state is its publication (ADR 0091), and publication commits the state move and the
federation activity in ONE transaction — an executor writing posts.state_id directly would
publish without federating. The seam: posts.movePublicationAs(caller, …) carries every gate and
the transaction; the HTTP wrapper keeps only context-identity → 401 and response hydration, so the
endpoints’ 404-vs-403 ordering (their existence-probe defence) is untouched. The executor reaches
it through a Publisher interface and owns no SQL; the actor is the action’s created_by, loaded
as a REAL identity (auth.Resolver.LoadIdentity) — capabilities, username, actor URI — and the arm
refuses to run rather than synthesise an identity.
3. A scheduled post state change requires created_by AT SCHEDULE TIME. The column is nullable
because a retention delete has nobody behind it; a publication cannot borrow that — it publishes as
that user and federates in their name.
4. Scheduled UNPUBLISH is deliberately allowed. change_state to wip on a post runs through
the same seam and gates. It fits this ADR’s own family — admin-scheduled takedowns at dates
(restrict, delete) — and refusing wip would special-case one legitimate post state, re-creating
the enqueue-then-die class for it. The surface remains system.admin (this file’s own cap ruling);
an AUTHOR scheduling their own post is a different authorization story and stays with epic #1119.
Fire-time semantics, decided with the owner: draft → published · already published → idempotent no-op success (audit-noted) · soft-deleted → StateFailed with reason · unpublished-again → publishes, because a schedule is a standing instruction until cancelled.
Amendment (2026-09-12): an author schedules their OWN post through the same engine (#1119 sprint 21e)
Section titled “Amendment (2026-09-12): an author schedules their OWN post through the same engine (#1119 sprint 21e)”The 2026-08-22 amendment closed with “an AUTHOR scheduling their own post is a different authorization story and stays with epic #1119”. This is that story. It adds a second door to the same row; it does not add a second scheduler, a second state machine, or a second spelling of who may publish.
1. The generic admin surface stays system.admin; the author surface is separate.
GET/POST /admin/scheduled-actions keep this ADR’s own cap ruling and are not opened to authors.
The author’s door is PUT, GET and DELETE /posts/{id}/publication-schedule, served by the
scheduled-action package over its own rows and gated by the posts package
(posts.Handler.PublicationScheduleGate, reached through the scheduledactions.PostAuthority
interface, in the same direction the Publisher seam already runs). The engine asks the
publication core; it does not re-derive its rules.
2. Own-post, current-author, current-draft, future-only, decided AT SCHEDULE TIME. In the
endpoints’ own order: the read gate first (an unreadable or soft-deleted post answers 404, so the
surface is not an existence oracle), then strict authorship (403; a global posts.admin who could
publish by hand is deliberately not admitted, because the row publishes and federates in its
creator’s name), then the instance’s posts.publish policy (403), then the post’s current state
(409 when already published), then scheduled_for > NOW() decided in the insert’s own
transaction (400). The last two are author-surface rules only: Store.Schedule still accepts a
due-now instruction from an operator or the system, and its fire-time table above is unchanged.
3. created_by is the requesting caller’s user ref, persisted at schedule time. Never an
admin surrogate, a service identity or zero; the store refuses a zero creator rather than
substituting one. What is persisted is what fires.
4. Fire time re-loads the real identity, capabilities and authorship. Nothing is frozen at
creation. The row executes through the unchanged change_state arm and
posts.MovePostPublication, which loads the actor through the real resolver and runs
movePublicationAs with every gate. A capability revoked between the schedule and the fire fails
the action (posts.publish named in the reason) and leaves the post a draft; a post deleted in
between fails it; a post published by hand first is the idempotent no-op; a post unpublished again
publishes, because the instruction stands until cancelled.
5. The status and cancel seam are the author’s own rows only. Rows written by this surface
carry scheduled_actions.origin = 'author' (migration 00069); every other writer keeps the default
'generic'. Status returns the caller’s pending author row or null, and cancel reaches only the
caller’s pending author row: an operator’s or the system’s action on the same post is neither shown
nor cancellable here. Reading and cancelling need authorship only, so an author whose posts.publish
was revoked after scheduling can still withdraw the instruction.
6. One pending author publication schedule per post, held by the database. The partial unique
index scheduled_actions_author_pending_post_idx covers (target_id) WHERE state = 'pending' AND origin = 'author' AND action = 'change_state' AND target_kind = 'post'. A PUT cancels the
caller’s own pending row and inserts the new one in a single transaction, so changing the time is one
request; two requests overlapping for one post are serialised by the index, one wins and the other
answers 409 and re-reads. Terminal rows (done, failed, cancelled) are outside the predicate, so a
later valid schedule inserts cleanly.
7. Unchanged, and stated so nobody infers otherwise. The generic engine’s multiplicity (two
operator schedules for one post, a scheduled unpublish beside a scheduled publish) is untouched: the
index predicate does not reach origin = 'generic'. Scheduled UNPUBLISH remains supported for the
generic surface and is not offered to authors, whose surface schedules exactly the accepted
publication transition. The reaper cadence (ReapInterval, five minutes) is unchanged, and the
author surface promises what it can keep: an action becomes eligible at scheduled_for and is
carried out on the scheduler’s next normal pass, never at that exact second.
Context
Section titled “Context”Game studios constantly handle pre-announcement material that must NOT be visible outside a small approved audience until a marketing date. The patterns are universal:
- A character design lands six months before reveal. The whole studio shouldn’t see it casually — but the art director and lead artist need to review it.
- A trailer cut goes out under NDA to a publisher; on the announcement date, restrictions auto-lift.
- A contractor’s NDA expires; their access to all their referenced assets must restrict automatically without an admin remembering.
Two related patterns from existing DAM tooling: sensitive-image thumbnail blur until permitted to view, and scheduled actions on resources (delete, restrict, archive at a future date). Neither is optional for a studio with any meaningful IP discipline.
Decision
Section titled “Decision”Add Phase 1.28 — Asset gating & NDA workflow — combining sensitive- asset gating with a generic scheduled-action engine.
Sensitive asset gating
Section titled “Sensitive asset gating”- Every asset has a
sensitivitytier:public,team,restricted,embargo. Default per resource type is configurable. restrictedandembargoassets show blurred thumbnails + a lock icon in browse views — including to users who have view capability. The blur is server-side baked into a special preview variant so even a network-tap leak is blurred.- A “Reveal” button on the asset removes the blur for the session; the reveal is logged.
embargoassets are visible to a configurable list of users / roles / teams only; everyone else sees a “this content is embargoed until YYYY-MM-DD” placeholder card.- Reviewers can comment + annotate on blurred content; the annotation overlay is rendered against the unblurred source for users who can reveal.
Scheduled actions
Section titled “Scheduled actions”A generic scheduled_actions table queues actions to run at a future
timestamp. Action shape:
{ "id": "sa_abc123", "target": { "type": "asset|post|collection|user", "id": 123 }, "action": "restrict|delete|change_state|change_sensitivity|notify", "params": { "to_state": "archived", "reason": "NDA expiry" }, "scheduled_for": "2026-12-01T00:00:00Z", "created_by": 42, "created_at": "2026-05-30T12:00:00Z", "status": "pending|done|failed|cancelled", "executed_at": null, "trail": []}A daily job picks up pending actions whose scheduled_for is past,
executes them through the existing job queue, logs the trail.
Common scheduled-action recipes
Section titled “Common scheduled-action recipes”- NDA expiry on contractor:
change_sensitivityof every asset the contractor uploaded toteamon the NDA end date. - Reveal-on-announcement-date:
change_sensitivityfromembargo→publicat a marketing-team-supplied timestamp. - Auto-archive stale builds:
change_statetoarchived90 days after a build is superseded. - Auto-delete trash:
delete30 days after a soft delete (this is the same engine as Phase 1.27 bulk delete’s trash retention).
Discoverability
Section titled “Discoverability”A “Scheduled actions” admin surface lists every pending action by target, owner, and date. Filterable, cancellable, bulk-cancellable.
Sensitivity vs license tier
Section titled “Sensitivity vs license tier”The license tier does NOT bound sensitivity — Community users get the same blur + embargo features as Enterprise. The Enterprise audit log export does include scheduled-action history as part of compliance.
Consequences
Section titled “Consequences”Positive
- Two patterns studios already write themselves with admin scripts + cron, now first-class. Replaces a large portion of typical bespoke plugin tooling.
- The scheduled-action engine is reusable for trash retention, notification delivery, federation outbox flushes — one engine, many consumers.
- Sensitive-content blur is server-baked so it survives client-side inspection / network sniffing.
Negative
- A blurred preview is an additional preview variant per asset, +
storage cost. Mitigation: only mint the blur variant for assets
whose sensitivity is
restrictedorembargo; refresh on sensitivity change. - The scheduled-action engine runs at daily granularity by default. Sub-day precision needs a tighter cron; configurable.
Alternatives considered
Section titled “Alternatives considered”- Blur client-side via CSS only. Trivially defeated by devtools. Rejected — gives a false sense of security.
- Use existing workflow_states as the only sensitivity primitive.
Conflates two concerns (workflow vs visibility). Rejected — they
evolve independently and an asset can be
approved-state but stillembargo-sensitivity. - No scheduled-action engine — admins remember to act. Real-world this fails routinely; the cost of forgetting an NDA expiry is high. Rejected.
Reference
Section titled “Reference”- Phase 1.28 in
docs/roadmap.md. - Job queue (Phase 1.18.A — shipped) executes scheduled actions.
- Audit log (Phase 1.17 + 1.20.A) records sensitivity changes + reveal events.