Oshun Platform · Features

Editorial Calendar and Asset & Media Library

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

5sections11 minread1table

On this page

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.tstwelve states, reused across every domain:

text
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 retractedneither 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-opennow is before the scheduled publish time.
  • publish-window-closednow 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 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.

New assets enter the queue as pending. enqueueAssetForApproval inserts and re-sorts by priority (expeditedstandardlow, 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 surfaceweb | 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.