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. It assumes the creator-role and authoring
foundations described in
Creator Roles and the Authoring Workspace,
and it feeds into
Taxonomy, Localization, and Versioning
and Collaboration, Review, and Templates.
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— noscheduledPublishUnixSecondsset.publish-window-not-open—nowis before the scheduled publish time.publish-window-closed—nowis at or past the scheduled unpublish time.blocked-dependencies— one or more declared dependency artifacts are not yet in apublishedorupdatedstate (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:
// 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 AssetMetadataIssues:
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 BulkUploadManifestEntrys 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 AssetUsageEvents (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.