发布

  • feat(media): write generated media through the asset pool under server-backed persistence (#1392)

    frostbyte_neo 发布于 2026-09-11 11:38:08 +00:00

    • feat(media): store generated media in the asset pool when persistence is server-backed

    With server-backed persistence the document is durable and shared, but
    generated media stayed in the producing browser: the document kept its
    gen_img_* / gen_vid_* placeholder and narration kept a browser-derived audio
    id. Every new browser that opened such a course re-ran generation for every
    slide, and it never converged, because the address of the generated bytes was
    never written back into the document.

    Under server-backed persistence only, the classic generation chain now stores
    bytes in the asset pool first and writes the id the pool allocated into the
    document.

    • The client bootstrap configures the asset seam alongside the document and
      runtime seams: an HttpAssetStore over the persistence endpoint carrying the
      same credentials the document store carries, marked server-backed. The seam
      preflight now covers all three, so a failure still cannot half-configure
      persistence.
    • Image, video and TTS generation commit in one fixed order: provider, pool,
      document, local cache, task. A reference reaches the document only after put
      returned an id, so a document can never name bytes that were not stored. A
      failure before the write-back leaves the placeholder with the provider called
      exactly once; the retry happens on the next owner load.
    • The write-back is a per-slot rewrite through mutateDocument, which re-reads
      the current document under the per-stage lock, so it cannot clobber a newer
      scene. The open course is refreshed with the same rewrite without being
      marked dirty.
    • "Has this already been generated?" is answered by the document (the slide
      exists and no longer holds the placeholder) instead of by this browser's task
      table.
    • The classroom's resume effect fails closed on ownership: only a resolved
      owner starts generation, so a viewer opening a shared course spends nothing.
    • The local media and audio tables become a per-tab cache. A failed cache write
      costs a re-download, never the media.

    Browser-only mode is unchanged: every new call site sits behind the
    server-backed gate, the local tables stay authoritative there, and placeholders
    stay in the document.

    Rendering and export needed no changes. HttpAssetStore.resolve mints an object
    URL exactly as the browser store does, and the export byte resolver was already
    pool-first.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(classroom): make the generation owner gate a three-outcome rule and apply it everywhere

    The gate refused everything but a resolved owner, which read a sidecar that
    answered "no ownership fact exists for this course" as a reason to block. That
    is the answer a deployment without the sidecar's server-side prerequisites
    gives for every course, and the answer a course with no ownership record gives:
    in both, there is nobody the operator's budget needs protecting from, and
    refusing strands the course's own author behind a question that can never be
    answered.

    Ownership is now four states over the sidecar's three outcomes. A definite
    answer splits into owner and not-owner. An absent record is its own answer,
    ownerless, and generation proceeds — the behaviour such a deployment had before
    the gate existed. Only the absence of an answer, a transport failure or a load
    that has not asked yet, stays unresolved and fails closed: "we could not ask"
    must never be read as "nobody owns this". One mapper turns a sidecar result
    into that state, and one predicate decides on it.

    The workbench classroom pane runs the same resume effect and had no ownership
    input at all, so a viewer opening a shared course there could still spend the
    budget. It now asks the sidecar once per course, in parallel with its load and
    feeding only the generation gate, so its read-only and edit behaviour is
    unchanged. The shared progressive-load policy carries the gate for it, with
    both new inputs required rather than defaulted so a future caller cannot omit
    them into an open budget. Its stale comment claiming ownership could not be
    expressed here is corrected.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): make the write-back survive autosave, arrive before the scene does, and never leak

    Independent reviews of the write-back found three ways a durable document could
    still end up naming a placeholder, and two ways the gate that protects the
    operator's budget could be walked around.

    An autosave round captures the store synchronously and writes that capture, so
    a round already in flight when a rewrite landed wrote the placeholder straight
    back over the allocated id, and nothing marked the store dirty again to correct
    it. The rewrite now marks the units it changed, which leaves a corrective flush
    queued behind the stale one; re-saving a scene that already holds the id is
    idempotent, losing the id is not.

    Media is generated from outlines in parallel with scene content and usually
    finishes first, so the slide that will carry the placeholder does not exist yet
    and the write-back has nothing to rewrite. That was the ordinary path, not a
    tail case, and its result was discarded: the task was marked done, the scene was
    added afterwards with its placeholder intact, and a second pass in the same run
    could call the provider again. The allocation is now held under the placeholder
    — which also answers the skip test, so nothing pays twice — and applied when
    that scene is committed, before its first save. One complete pass now leaves no
    placeholder behind.

    A failed commit used to abandon what it had already allocated. A poster upload
    that failed threw away a stored video and sent the retry to submit the most
    expensive job in the system again; a rejected write-back left registry rows that
    name bytes nothing references, which the byte collector cannot reclaim because
    it only collects blobs no row names. A poster failure now costs the poster, and
    a write-back that reached nothing reclaims what it allocated. A partial write is
    left alone, because the document already names it.

    The ownership gate is fail-closed again. Treating the sidecar's 404 as
    permission was wrong: the client cannot tell "this course has no owner" from
    "this deployment told me nothing", so a visitor who opened a shared course could
    bill the operator. The root cause was the sidecar itself, which gated on the
    agent runtime although every persisted course has an owner regardless — the
    persistence route resolves one for every request. It now gates on server
    persistence, so the configuration that made 404 the universal answer has real
    ownership facts to report, and the gate can refuse everything but a named owner.

    Retry affordances answered to no gate at all. A viewer of a shared course with
    one failed image was shown a Retry button that called the provider. Both retry
    entry points and every surface that draws them now read one shared permission,
    so what is offered and what is allowed are the same value.

    Also: narration regeneration no longer pretends it can replace bytes behind a
    live id — the exclusivity proof that would allow it is refused by construction
    once references leave the browser, so it forks to a fresh id and says so; the
    "already generated" test lets a finished deck answer from the document alone,
    since scene order stops identifying an outline once slides are inserted or
    deleted; stored assets record a specific media type rather than a generic
    transfer type; the pane no longer asks the sidecar in browser-only mode; and the
    funnel's docstring now states what the per-stage lock actually guarantees, which
    is same-browser serialization and not a cross-browser compare-and-swap.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): park allocations in the deciding turn, never reclaim on an ambiguous write

    A delta review of the write-back found the first-pass fix still had a window,
    and the reclamation it added could delete media the document already names.

    The allocation was parked after an awaited local cache write. A scene committed
    in that window reconciled against a registry that did not hold it yet, so the
    document kept the placeholder — and the entry recorded a moment later then
    answered the skip test as "already handled", so nothing could correct it. Parking
    now happens inside the write-back, in the same synchronous turn as the decision
    that nothing could take the reference; no await separates the live check from the
    park. Allocations parked by an earlier pass are handed to their slides at the
    start of the next one, before anything decides what still needs generating, so a
    held allocation whose scene has since arrived becomes a rewrite rather than an
    answer.

    Reclaiming on a rejected write was unsound: a rejection does not prove the server
    did not apply the write, so deleting the asset could break the scene that now
    names it. The funnel decides instead, and says so: it reclaims only when no store
    write was ever issued and nothing took the reference. Anything else is placed if
    its slide exists and parked if it does not, so the next pass reuses the bytes
    instead of paying for them again. When a write fails after part of it landed, the
    live store is brought up to the document before the error is rethrown — otherwise
    the next ordinary flush would overwrite the half that did land, with the ids
    deliberately not reclaimed.

    Parked allocations are now cleared with the course. Classic placeholders are
    reused across runs, so one surviving an interrupted run would be handed to a
    different slide of the next deck: the previous picture, on a slide whose provider
    was never asked. Both classroom surfaces clear the arriving course, the deletion
    cascade clears the deleted one, and clearing the database clears them all.

    Two more ways generation could start without asking the gate are closed. An
    overlapping pass — an outline retry re-enters generation with every outline while
    the first is still working — re-requested elements whose provider call was
    already in flight; a task that is not done is an answered request, not an
    unanswered one. And narration regeneration in the timeline editor called the TTS
    provider and allocated a pool asset with no ownership check at all; it now reads
    the same permission, which withholds both the per-line and whole-timeline
    controls and refuses the call.

    Finally, a pane opened during the stage-link availability gap recorded the
    sidecar's 404 for a course that was moments from existing and never asked again,
    leaving the real owner locked out of generation until it remounted. Ownership is
    re-fetched once the document becomes available; the gate stays closed until an
    answer arrives, so asking again can only open it for someone entitled to it.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): make the asset routes reachable, and stale snapshots harmless

    A full-branch audit found that the deployment this project documents could not
    store a single generated asset, and that several routes into durable storage
    could still write a placeholder over a reference that had already landed.

    The persistence route sent asset requests through the development
    authenticator, which refuses outright in a production build that has not
    explicitly opted into it — and the documented server-persistence recipe
    produces exactly that build. Every store and every read answered 401, so images
    and video failed on every slide while re-billing the provider on each retry, and
    a narration failure stopped the deck at its first slide. Assets live in one
    shared partition by design, so there was never anything per-caller for that
    authenticator to decide: the route now resolves the asset principal itself,
    alongside the owner it already resolves for documents. Runtime sessions are
    genuinely per-learner and keep the development authenticator until real session
    verification replaces it. And narration that cannot be stored no longer fails
    its scene: the line stays unvoiced and retryable, which is what an image that
    cannot be stored does to its slide.

    Placeholders could also come back from behind. A queued autosave's snapshot, an
    editor-history entry replayed by an undo, the departing save a course switch
    flushes — each captures content at its own moment, and any of those moments can
    predate a write-back. Point fixes at each producer would leave the next producer
    to rediscover the bug, so the check lives at the write boundary every producer
    passes through, and the allocation record it consults now outlives the parked
    queue: a placeholder whose rewrite landed long ago is exactly the case it
    catches.

    Two ways generation could be lost or repeated are closed. A pass now claims the
    elements it will reach and releases them however it ends, so an overlapping pass
    stands down while an aborted one strands nothing — previously its tasks stayed
    pending and every later pass skipped them with no retry control to recover
    them. And the media abort controller is aborted before being replaced, so a
    superseded pass stops calling providers instead of running on for a course the
    user has left.

    The remaining two are narrower. The workbench pane asks for ownership only after
    a document load succeeds, and after every later one, mirroring the page route:
    the load is what creates the ownership row the first time a course is opened, so
    asking beforehand asked about a course that did not exist yet and locked its
    author out for the mount. And the ownership gate on the timeline editor now
    withholds narration regeneration alone; listening back to existing narration and
    seeing whether a line has any spend nothing and stay available.

    Known limitation, unchanged and now stated plainly in the comments that used to
    point at it as a solution: nothing reclaims an unreferenced pool asset. The
    registry sweep is written but not wired up, and the byte collector only reclaims
    blobs no registry row names, so every narration regeneration and every abandoned
    allocation leaves storage behind.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): gate asset mutations, and make claims and allocation records survive a handoff

    Opening the asset routes opened all of them. Reads and allocations are meant to
    be as open as document reads and creates already are, but no authorization hook
    was supplied, so the handler's default admitted PUT and DELETE too — and those
    scope by principal key alone, which is one shared constant. Any caller who
    learned an id, and a document read hands out every id its slides name, could
    overwrite or destroy another author's media. Mutations now require the
    deployment's credential, which in a production build without the development-auth
    opt-in means they are refused outright; reads and allocations stay open. The
    route comment says what the posture is and what it is not: the deployment-level
    fence is the access code, and no per-principal quota is configured. The client's
    own reclaim is best effort to match — losing an argument about deleting an asset
    must not cost a task its retry, and the bytes are left for server-side
    reclamation.

    The pass claim could not survive the handoff it was written for. A retry aborts
    the live media pass and starts its replacement in the same synchronous block,
    long before the aborted pass's cleanup runs, so the replacement saw every element
    still claimed, collected nothing, and returned — leaving each unreached element
    at pending with nobody coming back for it and no retry control to recover it,
    which is the exact failure the claim was introduced to prevent. A claim now
    carries its pass's signal and is retired the moment that signal aborts, and a
    pass releases only claims it still owns, so a late unwind cannot take its
    replacement's work. Claims are also acquired at the single point every request
    passes through, so a single-task retry participates too — previously a retry
    awaiting its provider was invisible to a pass starting alongside it and both
    called it.

    The allocation record could outlive the bytes it named. It was written before the
    write-back attempted anything and survived the reclaim that followed a failure,
    so when the slide finally arrived the write boundary stamped a deleted id into
    the document — and the placeholder it replaced was gone, which reads as already
    generated and stops anything from retrying. The record is now written only where
    the allocation is retained, and forgotten wherever a reclaim removes the bytes,
    including the narration rollback path.

    The tests follow. The route test drives the real storage handler against an
    in-memory registry instead of a stub, so it can see what the resolved principal
    is then allowed to do; the handoff test performs a real abort mid-pass rather
    than starting from an already-aborted signal; and the guards that could only
    assert file layout now assert the property they care about, or have been replaced
    by behaviour.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): make media passes serial per course instead of tracking element ownership

    Three rounds of per-element claims each produced a new way to lose an element.
    Whole-pass reservations swallowed a Retry for an element the same pass had
    already failed, leaving it pending with the affordance gone. Retiring a claim by
    its signal freed an element whose commit was still uploading, so the replacement
    pass paid for it twice. A claim held for a failed element stranded its retry. The
    bookkeeping is the defect: every refinement of "who owns this element right now"
    answered the question at a moment when the answer was already stale.

    Passes for one course are now serial. A replacement aborts its predecessor, as
    before, and then waits for it to settle before collecting. That removes the
    question entirely: a commit already under way finishes — its bytes stored and its
    reference written, so the new pass sees a resolved slide and skips it — and an
    element the aborted pass never reached is still a placeholder and gets collected
    like any other. The claim set, the reservations, the signal retirement and the
    identity-checked release are all gone.

    The task table is consulted for one thing only: an element that is generating
    right now is a single-element retry running alongside the pass, and taking it too
    would pay twice. Pending is deliberately not a skip reason — it means a pass once
    intended to reach an element, which an abandoned pass leaves behind with nobody
    acting on it, and reading that as answered is what stranded elements before. A
    retry runs concurrently with a pass, because a pass never revisits an element it
    has processed, and it re-reads the task after its own await and refuses before
    touching it: marking first and refusing afterwards destroyed the failed state
    that draws the affordance.

    Browser-only mode is back to exactly what it was. The abort is now conditional,
    the waiting does not apply, and the original status-based skip is restored
    verbatim. Two baseline lines remain changed in each of the two files, and both
    are behind a server-backed fork whose else-branch is the original.

    Two smaller things. The allocation record becomes visible when a write goes on
    the wire rather than when the round trip ends, and the write boundary reconciles
    under the document lock rather than before it — a save queued during a write-back
    was otherwise captured with the placeholder and, for a course the user had left,
    had no corrective flush to follow. And the comments that said a refused reclaim
    leaves its bytes for server-side reclamation were wrong: nothing collects them,
    because the registry entry still names its blob and the sweep that would remove
    it is not wired up. They now say the bytes leak.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): make a deferred pass re-earn its right to run, and bound the commit it waits on

    Serializing passes moved their body out of the block that launched them, and
    three things followed from that.

    A pass now wakes when its predecessor settles, which can be after the user has
    left the course. It enqueued before it looked at its signal, into a task table
    keyed by element id alone — and placeholder ids are not unique across courses,
    which is why the classroom clears that table on arrival. So a departing course's
    pass seeded the arriving course's table with tasks carrying the wrong stage id,
    and a Retry routes by that id: the reference went into the wrong document. A pass
    now re-validates after the wait, before touching anything shared.

    The same lateness broke the skip test. documentSkipIndex answers only while the
    live store is on the pass's stage, and returning nothing put the collection loop
    on the browser-only rule — a silent demotion from "the document is the authority"
    to "this browser's task table is", on exactly the path where that table has just
    been cleared. Every element the predecessor had committed was collected again,
    paid for again, and its second write-back found no placeholder to rewrite, so its
    bytes were parked where nothing will ever reference them. In server-backed mode
    an unreadable document now means the pass stands down.

    And waiting was unbounded. A commit is uncancellable: the asset client takes no
    signal, and a document write cannot be half-undone. One stalled upload therefore
    froze the course's media generation for the session — the replacement never
    collected, the element sat on a skeleton that draws no Retry, and only a reload
    recovered. The pass's signal is now threaded into the media proxy fetch, and the
    commit is bounded by a deadline. The deadline is on the wait, not the work: the
    commit carries on, and if it lands late the document simply ends up correct,
    while the element becomes retryable and the queue moves on.

    The tests that were meant to pin the previous round were not sensitive to it.
    Two asserted end states where the mechanism only changes ordering, and one of
    them rigged the document read so the assertion held whether or not the pass had
    waited; a third covered half of what it claimed. They now observe the ordering
    directly — nothing is issued while another pass for the course is working; in
    browser-only mode a second pass reaches its provider immediately — and the
    reconciliation under the document lock has a test that fails when it moves back
    outside it.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • revert(media): drop the commit deadline and the abortable download

    The deadline bought less than it cost. Abandoning a commit after two minutes
    makes the element retryable while the real commit is still running, so a Retry
    starts a second commit for the same placeholder against the first: two provider
    calls, two allocations, and whichever lands second stamps its result over the
    other's task by element id. The allocation record is keyed by placeholder, so the
    loser's cleanup erases the winner's record, and the write boundary then puts the
    raw placeholder back into the document. That is the overlap serial passes were
    built to remove, reopened through the one door serialization never covered.

    So a stalled commit holds the course's media queue until it settles or the page
    is reloaded, and that is written down rather than papered over. The wait is
    unbounded on purpose: every ceiling on it turns out to be a way of running two
    commits for one element.

    Threading the pass signal into the download was also a mistake, in the other
    direction. The provider call that produced the URL has already been billed, so
    cancelling the download throws away work that is paid for — and the shared proxy
    cache records a cancelled request as a transient failure against that URL, which
    after three of them blocks it for every consumer in the session. Browser-only
    mode never asked for this: it had no way to observe an abort there, which is
    exactly why the bytes were kept. The signal is gone from the download again, and
    fetchAsBlob is byte-for-byte what it was before this branch.

    The regression guard for the stranded-element rule is restored alongside the
    timing test that was meant to supersede it. It catches a different rule — a task
    left pending being read as answered — and nothing else does: making the pass skip
    pending leaves every other suite green.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): stop asking the pool for refs it never issued, bound it, and adopt cached bytes

    Four things a deployment found once this was running for real.

    A reference this application mints itself — a generation placeholder, a derived
    narration key — was never in the pool, because the pool allocates every id it
    holds. Asking anyway used to be an IndexedDB miss; once the pool is server-backed
    it is a request that answers 404, one per element per load, forever on a course
    that still holds placeholders. Every lease and probe now checks first. The check
    is a negative test on shapes this application owns, not an id validator: the
    pool's id domain stays unconstrained, and anything that is not one of ours is
    still asked about.

    The asset store can bound how much one principal holds, and enforces it inside
    the write transaction, but nothing ever passed the number. It does now, with a
    default rather than an opt-in: allocation is reachable by any caller a
    deployment admits, and with one shared principal an unbounded store is
    unbounded database growth with no operator-visible brake.

    Refusing asset mutations to unauthenticated callers was not enough, because
    every authenticated caller resolves to that same shared principal — so
    authentication decided nothing, and any signed-in visitor could delete any id
    they learned. Since this branch began storing media the registry is the only
    copy a course has. Replacing and deleting are now refused to everyone, and the
    browser no longer tries: an entry nothing references waits for server-side
    reclamation instead. What a browser must still do is forget its own record of an
    allocation that reached nothing, or a later save would stamp an id the document
    has no reason to trust.

    And a course generated before any of this holds placeholders in its document
    with its bytes only in the author's browser. Those bytes are paid for, so the
    author's next load converts them — stored to the pool and written back through
    the ordinary commit path, with no provider call — instead of buying them again.
    A row that records only a hosted URL is treated as absent: that URL is the
    provider's address, not something a document may hold.

    One renderer expectation moved with this. An untracked placeholder used to paint
    as pending on first render because asking the pool left a lease in flight; it
    settled to disabled a moment later either way, and now says so from the start.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): surface a full store as a refusal and convert legacy narration

    A quota refusal reached the browser as HTTP 500 with a generic message, which
    reads as a transient failure: the element kept a Retry that would pay a
    provider again and be refused again. The store raises the contract's own error
    and the handler maps it to 507, but the store answering a request is not always
    built by the same bundle as the handler -- the persistence provider is reached
    from the route bundle and from instrumentation, which is why its state lives on
    a Symbol.for global -- and instanceof is false across that boundary while the
    declared code is still right. Classify on the code as well as the class, and
    make the code a permanent, persisted refusal in the browser: recorded locally so
    it survives a reload, shown as "storage is full", and refused by the retry entry
    point so a stale button cannot buy a second generation. Every other storage
    failure stays retryable.

    Convert what a pre-server-backed course still holds. Generated media is adopted
    under either key this application has used for it -- the placeholder, and the
    allocated id of a course converted once and later rolled back -- instead of only
    the first. Narration is converted by a load-time pass over the open course's
    speech actions, since nothing re-enters generation for an action that already
    has an id: bytes to the pool, id written back through a funnel that mirrors the
    media one, owner-only and server-backed-only. A line whose bytes are in no
    browser is left alone rather than re-synthesized.

    Also: the pool guard is now a positive ast_ test rather than an enumeration of
    the shapes we mint (imports never reach the pool, so this is safe in both
    modes); the slide ref collection is an exported pure function so its four lease
    sites are covered behaviourally; ASSET_QUOTA_BYTES treats every spelling of zero
    as opting out and refuses a malformed value at startup instead of falling back;
    the abort signal is re-checked after the cache read, before an uncancellable
    commit; and the unused removeAsset and pool replace surfaces are gone.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • chore(storage): release 0.29.1

    The asset HTTP handler now recognises a store refusal by the contract code it
    declares as well as by its class, so a quota refusal raised in another module
    realm answers 507 instead of 500. Same contract, stricter recognition, no API
    change: a patch.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): make a full store recoverable and adoption course-safe

    Narration adoption read the local audio row by its derived key alone. That key
    carries no stage id and the table is keyed by id alone, so two courses can mint
    the same one -- a PPTX import numbers its scenes and actions deterministically,
    which gives every imported deck's first slide tts_s1_speech-scene-p1. Locally
    a collision only means one course plays another's clip in one browser; adopting
    it wrote that clip into the shared document permanently, for every device and
    every visitor. A row that names a course is now adopted only into that course,
    and a row from before that column existed only when the text it recorded is the
    text of the action being converted.

    A full asset store was made permanent last round, which was wrong three times
    over: it overwrote the refused bytes with an empty blob -- on the conversion
    path that row is a course's only copy of its own media -- it kept sending the
    rest of the deck to a provider against a ceiling it already knew was reached,
    and it left no way back once an operator raised that ceiling. A full store is
    neither the content's fault nor the configuration's, so it is now its own case:
    the bytes are kept, the pass stops at the first refusal, and the element shows
    the reason together with a Retry that re-attempts the upload from those bytes.
    Nothing retries automatically, so no one is re-billed.

    The narration write-back now reaches the write boundary every producer of a
    durable write passes through, not only the dirty mark: adoption never deletes
    the derived row, so a snapshot that reverts the rewrite is adopted again on the
    next load and allocates a fresh asset every time. Adoption is also mounted by
    both classroom surfaces rather than one, takes the course's abort signal, and
    re-validates that this browser still has the course open before each write.

    ASSET_QUOTA_BYTES is validated from instrumentation, where the README and the
    docstring already claimed it was: its only other consumer is lazy and memoised,
    so a malformed ceiling let the process boot and then failed every persistence
    request, documents and runtime included.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): remember a full store per course, and never lose retained bytes

    A stopped pass left the elements it never reached as placeholders with no
    persisted record -- deliberately, since nothing was attempted for them. But
    that left the next load with no reason not to try: it called a provider for the
    next placeholder and was refused at exactly the same point, once per reload,
    indefinitely. A full store is not a property of any slide. It belongs to the
    deployment and changes for reasons the document knows nothing about, so it is
    now remembered once per course in the browser's device KV. A pass that finds
    the marker stands down before spending anything and leaves every placeholder
    its "storage is full" state and its Retry; the first upload that succeeds
    clears it and the next pass runs normally.

    Narration adoption latched per course so it runs once per load, and the latch
    outlived the abort that leaving a course performs. On a surface that stays
    mounted across switches -- the workbench pane is one component for every course
    it shows -- owner course A, visitor course B, then back to A skipped exactly
    the clips the abort had cut off, and nothing else converts them. The latch is
    released with the abort now, and a course adopts one run at a time so a
    re-entry cannot hand a clip a second allocation while the previous run's
    uncancellable tail is still settling.

    A quota-blocked element retried into a network error or a 500 lost the bytes
    that were kept for it: the retry deleted the row before attempting the upload
    and wrote no replacement for an error carrying no structured code, so the next
    retry went back to a provider for media this browser had a moment earlier. The
    row now survives until an upload succeeds, the failure handler keeps whatever
    bytes the attempt was given, and the retry asks the question a pass asks --
    does this browser already hold bytes for this element -- rather than reading an
    error code that a second failure has already overwritten.

    Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

    • fix(media): adopt real legacy narration, queue re-entries, report attempt outcomes

    Narration adoption admitted a stage-less row only when the text it recorded
    matched the action being converted. Both of those columns were added to the
    local audio table by the very change that moved narration onto allocated ids,
    so a row still carrying a derived key has neither: the rule refused every real
    pre-allocation course and passed only on fixtures built from post-allocation
    rows. What the row cannot say, the key can. A derived key names two clips only
    when two courses share a scene order and an action id, and an action id repeats
    only when something other than the generator minted it -- an import numbers
    them by slide position. So a key built from a generated action id is adopted on
    that basis, a key an import could have reproduced still needs matching text,
    and a row that names another course is refused however unique its key looks.

    Handing a re-entering caller the adoption run already in flight undid the latch
    release it was paired with: that run is bound to the signal the departure just
    aborted, so it stops at its next clip while the caller -- which has the course
    open and a live signal -- is told the work is done, and an effect replayed as
    mount, cleanup, mount adopts nothing at all. A later caller now waits for the
    uncancellable tail and scans again, which costs a lookup on a course that has
    nothing left and finishes the clips the abort cut off on one that does.

    One attempt at an element now reports both facts its callers need instead of a
    bare boolean: whether the store refused it for room, and whether bytes actually
    reached the store. Leaving a course clears the task table, so a retry that
    landed afterwards read "no failed task" as success and deleted the row holding
    the only copy of the media. Nothing is inferred from that table any more.

    Reading the localStorage property can throw where storage is denied by policy,
    typeof included, so the availability check moved inside the guard: this metadata
    is best-effort, and a rejection here strands a generation pass that has already
    enqueued its tasks.

    A retry is never blocked by the per-course "store is full" marker, but a retry
    that is refused again re-sets it, and adoption now reads and writes the same
    marker rather than issuing one refused upload per clip on every load. The two
    canvas element renderers and both thumbnail renderers show the reason beside
    the Retry, so a full store does not look like an ordinary failure.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

    • fix(media): probe a full store instead of standing down, and pair notices with a Retry

    Narration adoption was given both halves of the per-course "the store is full"
    marker last round: it stood down when the marker was set, and it set the marker
    when its own upload was refused for room. Those halves are only safe together
    if something can lift the marker, and for adoption nothing could. It has no
    affordance of its own, it stood down before reaching its own clear, the media
    pass returns before its marker gate when there is nothing to generate -- so a
    narration-only deck, or one whose slides are already satisfied, painted no
    storage-full element and offered no Retry -- and narration generated rather
    than adopted allocates directly rather than through the media commit. The
    course's cached narration was then lost for good, where before it converted on
    the first load after the ceiling was raised.

    The gate is a probe now. A marked course attempts exactly one clip per load:
    refused, it stops and the marker stands, which costs what standing down cost;
    stored, it lifts the marker and finishes the course. Adoption spends no
    provider money, so the whole cost of probing a store that is still full is one
    refused upload. Generated narration lifts the marker too.

    The three surfaces that gained a failure notice last round drew it for any
    failure with a reason, including the one refusal that is reachable without
    server-backed persistence, so a browser-only deck painted something it had not
    painted before. The notice is drawn beside a Retry and nowhere else, which is
    what it was added for and what leaves browser-only output unchanged. Both are
    now asserted through the render harness the surface matrix already had.

    A caller arriving while a rescan is queued shares it rather than appending
    another. One rescan converts whatever the run in flight left and every later
    one would find an allocated id on every action, so a chain bought nothing and
    turned a single stalled upload into a course that never adopts again.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

    • fix(media): treat a refusal for room as a fact about one clip, not the deck

    The asset store checks each write against the headroom it has left, so a store
    that refuses a long opening clip can still hold every short clip behind it.
    Narration adoption assumed the opposite: it broke the deck at the first refusal
    and then re-attempted that same first clip on every later load, because the
    document names it first. A deck whose longest clip exceeds current headroom
    therefore never converted the clips that would have fit, with no affordance to
    recover it -- the state the probe was introduced to remove, reached through a
    narrower door.

    An unmarked load now attempts every clip, skipping the ones that do not fit,
    and remembers the condition only if the load ends with clips it still could not
    store. A marked load spends its single upload on the smallest clip left rather
    than the first one named: that is the clip that answers the question the marker
    asks, because if the smallest does not fit nothing does. The media pass keeps
    stopping at its first refusal, and for a reason adoption does not share --
    every element it attempts costs a provider call.

    A rescan several callers share took the newest caller's signal, and the newest
    caller is not necessarily the one still there: a surface that opened a course
    and closed it again would stop work a surface still showing that course was
    waiting for, and that surface is latched, so it would never ask again. The
    shared run now takes a signal that is aborted only once every caller has left.

    The comment claiming the shared rescan contains a stalled upload was wrong --
    the rescan is chained off the run in flight, so a stalled upload leaves every
    caller pending exactly as a chain would. It claims the bounded queue it
    actually provides, and the stall is recorded as a limitation.

    The failed-state containers took their stacking classes unconditionally, so
    markup differed in browser-only mode even though nothing moved on screen. Those
    classes are applied only when there is a notice to stack, and the tests assert
    the exact class attribute rather than a substring.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

    • fix(media): stop narration adoption writing the media pass's store-full marker

    The marker means "do not call a provider for this course". A path is entitled
    to write it only if its own refusal cost a provider call, and narration
    adoption's refusals cost nothing: it uploads bytes this browser already holds.
    The store also checks each write against the headroom it has left, so a clip
    that does not fit says nothing about whether a slide's image would. Adoption
    was writing it anyway, and one over-long narration clip was therefore enough to
    stand a course's entire image pass down on every later load -- on a store that
    had just accepted adoption's other clips. The author could still recover each
    element by hand, every load, for ever.

    Three rounds of narrowing this seam produced a finding each time, so it is
    removed rather than narrowed again. Gone: the marker read, the single-clip
    probe, the smallest-clip selection, and the up-front read of every row into an
    array -- which also retires a sampled-then-stale flag and the retention of a
    whole deck's blobs for the length of a run, and returns the loop to streaming
    one row at a time.

    Adoption's rule is now that every load attempts every clip it holds, once; any
    failure skips that clip and the load continues. The noise the coupling was
    meant to avoid does not arise, because after the first load the clips still
    outstanding are exactly the ones that did not fit -- normally none, or one.

    A successful write still clears the marker, and that is a different kind of
    statement: a write that went through is a fact this run established, where a
    refusal is an inference about what some other write would cost. For a course
    whose media needs nothing, adoption and generated narration are also the only
    paths that can establish it.

    The failure module still documented the deck-wide premise this contradicts. It
    now says what is true: the check is per write, and the media pass stops the
    deck as a judgement about cost rather than about certainty.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

    • fix(media): bound a full store's cost from the store's own arithmetic

    Removing the store-full marker from narration adoption removed its bound too,
    and the code then asserted the bound was unnecessary. It is, on a store with
    room for most of a deck. On the store the whole mechanism exists for -- the
    ceiling reached, nothing fitting -- the outstanding set after every load is the
    entire deck, so a thirty-clip course posted thirty full blobs on every load,
    indefinitely. Each of those is not a cheap refusal: the bytes are uploaded, the
    server hashes the whole payload, and only then takes a per-principal lock and
    sums every entry that principal owns before saying no.

    The bound needs no flag, no key and nothing carried between loads. The store
    asks whether used + addedBytes exceeds the ceiling, and used only grows
    while a run is uploading, so a clip refused for want of room implies every clip
    at least that large is refused for the rest of that run. The run keeps the
    smallest size it has been refused and skips anything no smaller without
    uploading it; a smaller clip is still attempted, because it may fit. A deck the
    store refuses entirely now costs one upload per successive size minimum instead
    of one per clip, and a deck it has room for costs nothing extra, because
    nothing is refused. Only a refusal for room lowers the bar: a dropped
    connection says nothing about how much room there is.

    The deck-wide certainty premise the failure module retracted last round still
    stood verbatim at the site that implements the stand-down. Both copies now say
    the same thing: the check is per write, and the pass stops the deck as a
    judgement about cost rather than about certainty.

    The comment on adoption's marker clear now names its price. Narration of a few
    hundred bytes fits in headroom an image does not, so a proven write can let the
    next pass buy one more image that is refused again -- bounded at one, and the
    price of the alternative being a course whose media never generates again.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

    • docs(media): state the adoption bound exactly, and stop three comments describing the old rule

    The comment introducing the in-load bound gave its cost as "at most a handful,
    and the first load pays the most". Neither clause is a property of the rule. A
    clip is skipped only when something no larger was already refused, so a
    fully-refused deck costs one upload per successive size minimum in document
    order: one when the clips grow, about ln N for an arbitrary order, and one per
    clip when they only shrink -- a long opener followed by terser lines is exactly
    that shape. And no load is cheaper than the first, because the bound resets per
    run and a refused clip stays outstanding. The comment now says that, and points
    at what would make it exactly one for any ordering: the store returning its
    remaining headroom in the refusal's existing details channel, which the server
    leaves empty today.

    Two other comments still described the previous rule -- "attempts every clip it
    holds, every load" -- one of them twenty lines above the paragraph that
    introduces the bound, in the same block. Both now say what the code does.

    The bound's soundness is worth stating where a maintainer will look for it:
    quota is charged at full length with no discount for a duplicate, the sum it is
    checked against joins entries to blobs so the collector cannot lower it, the
    check takes a per-principal lock before summing, and replace and delete are
    refused to every browser. Nothing a run can do makes room appear inside it.

    One test installed a row implementation and replaced it wholesale a few lines
    later, so the first was dead and the survivor dropped the text the first clip's
    import-shaped key needs for the ownership rule -- it passed on the coincidence
    that the fixture's default text is the action's. Merged into one.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

    • fix(media): keep a Retry from re-buying parked media, and state the store seam once

    Five findings from an inline review.

    The standalone classroom route asked the ownership sidecar once per load and
    recorded only stage ownership when that ask failed. Every non-answer fails
    closed, so one transient 5xx left the genuine author with no resume, no Retry
    affordance and no legacy narration converted for the rest of the load, with
    nothing to change it short of a reload. The failure now records the fail-closed
    answer explicitly -- an answer an earlier load established must not outlive the
    failure that replaced it -- and an unresolved answer is asked for again, a few
    times over a few seconds. A real answer, however unwelcome, is final.

    A Retry could pay a provider for media the pool already held. When the bytes
    are stored and only the write-back fails in a way that keeps the allocation, it
    is parked and no local row exists, because that row is written only after a
    successful write-back. Retry now reads the parked queue exactly as the pass
    does and re-attempts the write-back: it re-keys the task done when the document
    takes it, leaves the entry parked when the slide still does not exist, and
    stays failed and retryable when the document refuses again.

    Object URLs a parked allocation owns are revoked when the entry is dropped. The
    commit path leaves them alone while the entry is parked, because it is then the
    only thing holding bytes this tab can render, so a course switch or a stage
    deletion was pinning the whole blob for the life of the tab. An entry a slide
    has already taken is left alone: the task table is displaying those URLs.

    The fallback lookup for cached bytes is a stage-scoped scan, and the keyed
    lookup misses for every row the commit path writes, so a pass was materializing
    and sorting the course's whole media table once per element. One scan per pass
    now, built on the first miss. It is sound and not merely cheaper: an element
    asks only for its own placeholder, and every row a pass writes carries the
    placeholder of the element that wrote it.

    "The store accepted a write, so it is not out of room" was enforced at three
    call sites under slightly different conditions, which made it a convention the
    next pool write path could silently break. It is stated once, in putAsset, for
    the course whose bytes it just stored.

    Co-Authored-By: Claude Opus 5 noreply@anthropic.com


    Co-authored-by: Claude Fable 5.1 noreply@anthropic.com
    Co-authored-by: 杨慎 117187635+cosarah@users.noreply.github.com

    下载附件