Aller au contenu

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 is id, 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.

layerbefore #921after #921changed?
the access ruledoes not exclude rows; sensitivity gates content, not rowsidenticalNO
the default presentationrenders every row the rule returnedsubtracts restricted ones in the feedYES

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_restrictedshow_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:

  1. 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.
  2. 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_until to 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 by assets/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.

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.

Add Phase 1.28 — Asset gating & NDA workflow — combining sensitive- asset gating with a generic scheduled-action engine.

  • Every asset has a sensitivity tier: public, team, restricted, embargo. Default per resource type is configurable.
  • restricted and embargo assets 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.
  • embargo assets 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.

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.

  • NDA expiry on contractor: change_sensitivity of every asset the contractor uploaded to team on the NDA end date.
  • Reveal-on-announcement-date: change_sensitivity from embargopublic at a marketing-team-supplied timestamp.
  • Auto-archive stale builds: change_state to archived 90 days after a build is superseded.
  • Auto-delete trash: delete 30 days after a soft delete (this is the same engine as Phase 1.27 bulk delete’s trash retention).

A “Scheduled actions” admin surface lists every pending action by target, owner, and date. Filterable, cancellable, bulk-cancellable.

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.

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 restricted or embargo; refresh on sensitivity change.
  • The scheduled-action engine runs at daily granularity by default. Sub-day precision needs a tighter cron; configurable.
  • 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 still embargo-sensitivity.
  • No scheduled-action engine — admins remember to act. Real-world this fails routinely; the cost of forgetting an NDA expiry is high. Rejected.
  • 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.