# Editorial Calendar and Asset & Media Library

Once a creator has drafted an artifact in the authoring workspace, two
cross-cutting systems carry it the rest of the way: the **editorial calendar and
lifecycle** that schedules, gates, and publishes it, and the **asset and media
library** that holds every image, audio clip, voiceover, rendering, and scan it
depends on. Both are domain logic in `libs/oshun/studio-authoring`
(`@oshun/studio-authoring`), shared across every content domain so a Tara
passage, a Veritas briefing, and a Metis lesson all move through the same state
machine and draw from the same asset pool. This page is the feature-side
companion to the architecture catalog; the hub is
[../features.md](../features.md). It assumes the creator-role and authoring
foundations described in
[Creator Roles and the Authoring Workspace](./authoring-workspace-and-roles.md),
and it feeds into
[Taxonomy, Localization, and Versioning](./taxonomy-localization-versioning.md)
and [Collaboration, Review, and Templates](./collaboration-and-templates.md).

## What ships, honestly

The editorial lifecycle state machine, the publishing-pipeline binding, the
recurrence engine, the calendar export, and the asset-metadata/rights/provenance
logic are all **real, deterministic, non-stub functions** in
`libs/oshun/studio-authoring/src/editorial-lifecycle/` and
`libs/oshun/studio-authoring/src/asset-metadata/`. They are pure: timestamps are
passed in as `nowUnixSeconds`, and the functions compute verdicts rather than
performing side effects.

One important correction, because the architecture doc previously got this
wrong: the "Asset and media library" note in the architecture deep-dive
(`architecture/oshun-studio.md`) says the asset library is "backed by MinIO/S3
with provenance bundles travelling with every asset." That conflates two layers.
**Object storage is real at the infrastructure level** — MinIO is a
`docker-compose.dev.yml` dependency — but the **authoring library does not
contain a MinIO/S3 client**. `asset-metadata/` (`metadata.ts`,
`approval-queue.ts`, `bulk-upload.ts`, `exif-and-presets.ts`, `analytics.ts`)
implements metadata, rights, provenance, approval, and analytics **logic** over
content hashes and identifiers; the binding of those identifiers to binary blobs
in object storage lives outside this library. This page describes the logic that
actually ships and is explicit about where the storage seam sits.

## Editorial calendar and lifecycle

### The twelve-state lifecycle

The canonical content lifecycle is the exported constant
`EDITORIAL_LIFECYCLE_STATES` in
`libs/oshun/studio-authoring/src/editorial-lifecycle/lifecycle.ts` — **twelve
states**, reused across every domain:

```
idea → draft → in-review → changes-requested → approved → scheduled
→ published → updated → deprecated → sunset → archived → takedown
```

Transitions are not freeform. A frozen `TRANSITIONS` adjacency map defines the
legal moves, and `isEditorialTransitionAllowed(from, to)` /
`tryEditorialAdvance({ from, to, gates })` enforce them. The adjacency is:

| State               | Allowed next states                           |
| ------------------- | --------------------------------------------- |
| `idea`              | `draft`                                       |
| `draft`             | `in-review`, `archived`                       |
| `in-review`         | `changes-requested`, `approved`               |
| `changes-requested` | `draft`, `archived`                           |
| `approved`          | `scheduled`, `published`, `changes-requested` |
| `scheduled`         | `published`, `changes-requested`              |
| `published`         | `updated`, `deprecated`, `takedown`           |
| `updated`           | `published`, `deprecated`                     |
| `deprecated`        | `sunset`, `published`                         |
| `sunset`            | `archived`                                    |
| `archived`          | _(terminal)_                                  |
| `takedown`          | `archived`                                    |

A few honest notes on the naming, because the docs have diverged here. The code
uses **`changes-requested`** (not `rejected`) for a returned review, and
**`takedown`** (not `retracted`) for an emergency pull. The older state mermaid
in `architecture/oshun-studio.md` draws a smaller "core editorial subset"
(roughly
`draft → in-review → approved → scheduled → published → retracted/archived`) and
uses the names `rejected` and `retracted` — **neither of which exists as a code
enum member.** `features.md` correctly lists the full twelve-state set and notes
the architecture diagram is an intentional subset, but the subset's terminal
names (`rejected`, `retracted`) are stale relative to the canonical code
(`changes-requested`, `takedown`). When in doubt, the array in `lifecycle.ts` is
the source of truth.

### Stage gates that actually gate

`tryEditorialAdvance` returns one of three results: `{ ok: true, to }`,
`{ ok: false, reason: 'transition-not-allowed' }`, or
`{ ok: false, reason: 'unsatisfied-gates', missingGateIds }`. Each gate is an
`EditorialGate` (`gateId`, `description`, `required`, `satisfied`); only
`required && !satisfied` gates block, and the function returns the specific
missing gate ids. This is the mechanism behind "stage-aware checklists,
blockers, gating questions, mandatory reviewers, required evaluations, and
required disclosures."

Crucially, the authoring-time evaluation harness is wired into this gate.
`editorialGateFromAuthoringFindings(findings)` produces a `required` gate keyed
`AUTHORING_EVALUATION_GATE_ID = 'authoring-evaluation:no-blocking-findings'`
whose `satisfied` flag is `false` whenever any finding is `block`-severity (for
example, a tone-policy banned phrase). So a blocking authoring finding genuinely
refuses the transition into a published state until it is resolved;
`warn`/`info` findings surface in review but do not gate.

### The editorial calendar

A calendar row is an `EditorialCalendarEntry`: `entryId`, `artifactId`,
`domain`, `owner`, `assignedReviewer`, `deadlineUnixSeconds`,
`embargoUntilUnixSeconds`, `scheduledPublishUnixSeconds`,
`scheduledUnpublishUnixSeconds`, an optional `EditorialRecurrence`, and a
`dependencies` list of other artifact ids. Two helpers express embargo and
publish readiness: `isEmbargoed` is true while `now < embargoUntil`, and the
richer `evaluateEditorialPublishWindow` returns a typed verdict whose failure
reasons are the actual edge cases the calendar must respect:

- `embargoed` — still inside the embargo window.
- `not-yet-scheduled` — no `scheduledPublishUnixSeconds` set.
- `publish-window-not-open` — `now` is before the scheduled publish time.
- `publish-window-closed` — `now` is at or past the scheduled **unpublish**
  time.
- `blocked-dependencies` — one or more declared dependency artifacts are not yet
  in a `published` or `updated` state (the offending ids are returned).

That dependency check is what makes "dependency graphs across artifacts" real: a
translation cannot publish ahead of the source it depends on.

### The recurrence engine

The recurrence primitive is a real engine, not just a list of examples. The
fileoverview of `editorial-lifecycle/recurrence.ts` literally names the four
canonical cadences it exists for — **daily Veritas briefings, daily Tara
passages, weekly Arete reflections, and nightly Nyx highlights** — and an
`EditorialRecurrence` is
`{ cadence: 'daily' | 'weekly' | 'monthly', anchorUnixSeconds, tags }`.
`nextOccurrences` projects the next N occurrences from an anchor at a
daily/weekly (×7)/monthly (×30 day) step, fast-forwarding past
`fromUnixSeconds`; `nextPublishableOccurrences` filters those against the
entry's embargo window. So the "daily Tara passage" promise is backed by a
function that computes the actual next publish timestamps.

### Calendar export and cross-team views

`exportEditorialCalendarAsIcal` emits a real iCalendar (`VERSION:2.0`,
`PRODID:-//Oshun Studio//Editorial Calendar//EN`) string with a `VEVENT` per
occurrence — `UID`, `DTSTAMP`, `DTSTART`, a `SUMMARY` of `[domain] artifactId`,
an `ORGANIZER` of the owner, and an `ATTENDEE` for the assigned reviewer — so
operator dashboards and partner calendars can import the schedule.
`buildCrossTeamCalendarView` rolls entries up per domain with owner counts and
an upcoming-deadline count, and `buildAssignmentNotifications` emits typed
payloads (`editorial-assignment`, `deadline-reminder`, `embargo-lifted`,
`takedown`) for reviewers and owners. Together these are "calendar export,
cross-team views, editorial assignment notifications, and deadline reminders."

### Publishing pipeline binding

Publishing binds the editorial decision to upstream policy. `PublishingBindings`
is the contract:

```jsonc
// PublishingBindings (editorial-lifecycle/pipeline.ts)
{
  "sophiaEvidencePackId": "sophia-pack-...", // null blocks publish
  "lilithToneReviewId": "tone-review-...", // null blocks publish
  "isisReleaseGateIds": ["isis-gate-a", "..."],
  "rightsProvenanceBundleId": "rights-...", // null blocks publish
  "localizationReadinessRatio": 0.92,
  "satisfiedIsisGateIds": ["isis-gate-a"],
}
```

`evaluatePublishReadiness` returns `{ ok: true }` or
`{ ok: false, missing, unmetIsisGateIds, blockingFindings }`, where `missing`
draws from `PublishReadinessMissing`: `evidence-pack`, `tone-review`,
`rights-provenance`, `isis-release-gate` (any Isis gate id not in
`satisfiedIsisGateIds`), `localization-readiness` (ratio below the configured
minimum), and `blocking-findings` (any `block`-severity authoring finding). This
is the literal "bind editorial decisions to Sophia evidence packs, Isis release
gates, Lilith tone reviews, rights and provenance bundles, and localization
readiness."

### Embargo, takedown, and hotfix flows

`buildTakedownCascade` constructs the takedown record. It **requires** a
non-empty `reason` and a non-empty `userVisibleNote` (throwing otherwise) and
returns the affected artifact ids (the entry plus its downstream), satisfying
"audit and user-visible correction notes where transparency policy requires."

Hotfixes are deliberately constrained. `evaluateHotfix` encodes which bindings
are negotiable in an emergency: a `block`-severity authoring finding is
**non-negotiable** and refuses the hotfix with `blocking-findings-present`. The
request then requires an `executiveSignoffById`, a correction note of at least
16 characters, a rights/provenance bundle, and a Lilith tone review — failing
with `missing-executive-signoff`, `missing-correction-note`,
`missing-rights-bundle`, or `missing-tone-review`, respectively. A successful
hotfix returns an ordered `applicationOrder`:
`snapshot-pre-hotfix-version → apply-content-patch → emit-user-visible-correction-note → enqueue-post-incident-pack → reopen-isis-release-gates`.
In other words, rights and tone are sacred even in an emergency, while a full
evidence-pack refresh and the complete Isis release gate can be deferred to the
post-incident pack. `buildPublishingPipelineDecision` folds readiness and hotfix
into a single `normal | hotfix | blocked` mode with a human summary.

## Asset and media library

### The unified asset model

Every uploaded or generated asset is a `StudioAssetMetadata`. The kinds
(`STUDIO_ASSET_KINDS`, eleven) are `image`, `audio`, `voiceover`, `video`,
`avatar-rendering`, `diagram`, `illustration`, `manuscript-scan`, `motion-clip`,
`ambient-loop`, and `ritual-sound-pack` — exactly the "images, audio,
voiceovers, video clips, avatar renderings, diagrams, illustrations,
manuscripts, scans, motion clips, ambient loops, and ritual sound packs" the
prose lists. Licenses (`STUDIO_ASSET_LICENSES`, ten) span `cc0`, the Creative
Commons family, `platform-commercial`, `platform-internal`, `creator-reserved`,
`public-domain`, and `all-rights-reserved`.

The metadata record itself carries `assetId`, `kind`, `contentHash`,
`perceptualHash`, `version`, `creatorId`, `tenantId`, `license`,
`rightsExpiryUnixSeconds`, `usageScopes`, `languages`, an accessibility record
(`StudioAssetAccessibility`: `altText`, `transcript`, `captionTrackId`,
`audioDescriptionId`, `altTextCoveragePct`), a provenance record
(`StudioAssetProvenance`: `provenanceBundleId`, `generatorId`, `modelId`,
`promptHash`, `lineageIds`), an optional `StudioAssetWatermark` (`algorithm`,
`payloadHash`, `visibleOverlayId`), `nsfwLabels`, `approvalStatus`
(`pending | approved | rejected | taken-down`), and `usedByArtifactIds`. That is
the full "rights, license, source, generator, model, prompt, hashes, version,
lineage, watermark, provenance bundle, NSFW labels, language, accessibility
text, captions, transcripts, and usage scopes" promise as a single typed shape —
note that prompt and model are stored as a `promptHash` and `modelId`, not raw
prompt text.

### Metadata validation, dedupe, and replacement cascade

`validateAssetMetadata(asset, now)` returns typed `AssetMetadataIssue`s:
`rights-expired` (now past `rightsExpiryUnixSeconds`), `missing-watermark` (a
generator-produced asset with no watermark), `missing-accessibility` (an `image`
with no alt text, or an `audio`/`voiceover`/`video` with neither transcript nor
caption track), and `low-alt-coverage` (`altTextCoveragePct < 0.9` for non-audio
kinds). `findDuplicateAssets` groups by `perceptualHash` to surface
near-duplicate media — the perceptual-hash dedupe the prose calls for.
`findSimilarAssetsByEmbedding` ranks a catalog by **cosine similarity** over a
precomputed embedding vector (top-k above a `minSimilarity` floor) for
"similar-asset discovery." When an asset is replaced,
`buildAssetReplacementCascade` returns the `invalidatedArtifactIds` (the asset's
`usedByArtifactIds`), so a replacement propagates downstream rather than leaving
stale references.

### Approval queue, broken links, and rights-expiry alerts

New assets enter the queue as `pending`. `enqueueAssetForApproval` inserts and
re-sorts by priority (`expedited` → `standard` → `low`, then by submission
time), and `decideAssetApproval` records an `AssetApprovalDecision`
(`approved | rejected | taken-down`, with reviewer, reason, and follow-up
actions) and removes the item. `surfaceBrokenLinks` flags assets that are
`taken-down` but still referenced (`missing-replacement`) or `rights-expired`
while still in use, and `buildRightsExpiryAlerts` graduates severity over a
warning window: `block` once expired, `warn` in the final third of the window,
`info` earlier. These are the "asset approval queues, takedown workflows,
replacement workflows with downstream artifact invalidation, broken-link
surfacing, and rights-expiry alerts."

### Bulk upload, resumable chunking, and EXIF stripping

`validateBulkUploadManifest` checks a manifest of `BulkUploadManifestEntry`s and
returns typed issues: `duplicate-content-hash` (within the manifest or against
existing assets), `oversize` (against a per-kind byte budget in
`MAX_BYTES_PER_KIND` — e.g. 100 MiB for `image`, 8 GiB for `video`),
`missing-alt-text` (images require alt text on upload), `missing-transcript`
(audio/video require transcript or caption track), and
`restricted-license-without-tenant` (`platform-commercial` / `platform-internal`
licenses require a tenant). `summarizeBulkUpload` rolls the result into
attempted/acceptable/rejected counts with reason histograms.

Resumable upload is content-addressed, not byte-offset-based:
`planResumableChunks` validates that the supplied chunk-hash count equals
`ceil(totalByteSize / chunkSizeBytes)`, and `detectChunkDivergence` compares a
resume's chunk hashes against the plan to catch a client that retries with
different bytes — a deliberate defense against silent corruption on resume.

EXIF stripping is policy-driven. `DEFAULT_EXIF_STRIP_POLICY` **retains**
`Orientation`, `ColorSpace`, `ICC_Profile`, and `Accessibility`, and
**discards** GPS coordinates, camera/body/lens serial numbers,
owner/artist/copyright tags, IPTC by-lines, and maker notes, with
`unknownNamespacePolicy: 'discard'` as the safe default. `applyExifStripPolicy`
returns the retained and stripped key sets — so an uploaded photo's GPS location
and device serial do not leak into the published artifact.

### Per-domain delivery presets

`STUDIO_REENCODE_PRESETS` registers the rendition each surface fetches, keyed by
`(kind, surface)`. A few examples: `web-image-1600` (AVIF, 1600 px),
`mobile-image-960` (AVIF, 960 px), `web-video-720` (MP4, 1280 px, 2.5 Mbps),
`voice-audio-mono-24k` (Opus, 32 kbps), `avatar-clip-1080-h264` (MP4, 1920 px, 6
Mbps), `web-diagram-svg`, and `web-ambient-loop-aac` (AAC, 96 kbps, 600 s cap).
`presetsForKindAndSurface(kind, surface)` and `presetsByKind(kind)` select them.
This is the concrete "re-encoding presets and per-domain delivery format
generation."

### Asset analytics

`rollupAssetUsage` aggregates `AssetUsageEvent`s (each carrying the `surface` —
`web | mobile | voice | avatar | admin` — and an optional `renditionPresetId`)
into per-asset serve counts broken down by surface and rendition with
first/last-seen timestamps. `detectOrphanAssets` flags approved assets with no
references and no recent serves beyond a `maxIdleDays` budget;
`rollupAccessibilityCoverage` computes alt-text and caption coverage ratios; and
`snapshotLicenseWindow` buckets assets by license and lists those expiring
within a horizon. That covers "where used, how often, which renditions,
accessibility coverage, license-window status, and orphan detection." These
functions are backed by the metadata-integrity, rights-propagation,
replacement-cascade, watermark-verification, and provenance-preservation tests
the prose calls for.

## Honest gaps and the storage seam

Two boundaries are worth restating plainly. First, **storage**: the library
reasons over `contentHash`/`perceptualHash` and asset ids; binding those to
bytes in MinIO/S3 is an infra-level responsibility outside
`@oshun/studio-authoring`. The architecture doc's "asset library is backed by
MinIO/S3" is therefore true at infra level but not inside this library. Second,
**persistence and time**: every function takes `nowUnixSeconds` as input and
computes a verdict — the durable home of calendar entries, approval queues, and
asset records lives in the surfaces and services that call this logic. The
`v1-completeness-audit-2026-06-22.md` separately notes that the
`editorial-review-approval` walkthrough currently exercises the incident
decision panel (INC-2041) more than the editorial artifact lifecycle, so the
end-to-end proof of the editorial flow is thinner than the breadth of this logic
suggests. The backlog and dependency context for this section live at §16 in
`V1/TODOS.md`.

## Related

- [Creator Roles and the Authoring Workspace](./authoring-workspace-and-roles.md)
- [Taxonomy, Localization, and Versioning](./taxonomy-localization-versioning.md)
- [Collaboration, Review, and Templates](./collaboration-and-templates.md)
- [Sophia Grounding](./sophia-grounding.md)
- [Isis Generation Control](./isis-generation-control.md)
- [Output Gallery, Lineage, Branch, and Replay](./output-gallery-lineage.md)
- [Review, Compliance, and Trust & Safety](./review-trust-safety.md)
- [../features.md](../features.md)
