Commit Graph
54 Commits
Author SHA1 Message Date
wyucandClaude Opus 5.5 5312c2b4b4 feat(config): openmaic.yml, capability slots and server-side model configuration (RFC #1701) (#1765)
* ci: run CI for the provider-config integration branch

Development of the provider configuration RFC (#1701) lands on
integration/provider-config; build it on push and run CI for pull
requests into it, as with earlier integration branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(config): capability slot registry and stage-to-slot mapping (#1726)

* feat(config): capability slot registry and stage-to-slot mapping

First P0 step of the provider configuration RFC (#1701, tracked in
#1725): define the capability slot forest and map every LLM stage key
to exactly one slot. Pure data with no callers yet, so there is no
behavior change.

Requirements are checked on the slot that declares them and are not
passed down to child slots, so agent.title can use a model without
tool calling even though agent requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): pin every stage destination and the capability roots

Review found that the station and root assertions derived their
expectations from the module under test: re-pointing a single-stage
station or dropping a capability root still passed. The tests now
compare against an independently written stage table and root list,
and pin agent.title as config-only. Also document that browserless
outlines still run on the generate-classroom model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): pin slotForStage and full slot lineages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): provider presets and the openmaic.yml schema (#1727)

* feat(config): provider presets and the openmaic.yml schema

Second P0 step of the provider configuration RFC (#1701, tracked in
#1725).

- lib/config/provider-presets.ts: one preset per built-in registry
  entry, plus the token plans as multi-capability presets whose stage
  recommendations become slot recommendations. Registry ids that
  collide across capabilities get explicit preset ids.
- lib/server/model-config/openmaic-yml.ts: parse and validate the
  operator's openmaic.yml (or the file named by OPENMAIC_CONFIG):
  ${VAR} interpolation, strict schema, and cross-checks that every
  assignment names a declared provider whose preset offers the slot's
  capability. Every problem is reported with its path.
- instrumentation.ts: an invalid file refuses to start. Without a file
  nothing changes, and nothing resolves models through the file yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): harden openmaic.yml validation after review

- Look providers up by own key only, so "constructor:m" is not taken
  for a declared provider.
- Refuse non-mapping objects YAML produces (an unquoted timestamp
  becomes a Date that the schema would accept as an empty object), and
  refuse a YAML alias that refers back to itself instead of overflowing
  the stack.
- Accept lowercase variable names; refuse a "${" with no closing
  brace, checked on the text as written, never on substituted secrets.
- Cross-check the entries that are valid on their own even when the
  schema rejects others, so every problem is reported at once without
  duplicate errors for declared-but-invalid providers.
- Name the three valid assignment shapes when a value has none of them.
- Test the boot path: an invalid file exits with code 1 before any
  schedule starts; a valid one boots.
- Ignore a root openmaic.yml in git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep secrets out of openmaic.yml diagnostics

Review round 2:

- Read only variables the environment itself has, as non-empty
  strings: ${constructor} or an inherited value is not a variable.
- Never print a value that came from ${VAR}, and report YAML syntax
  errors by reason and position without js-yaml's source excerpt,
  which can quote a literal key.
- Keep the base URL requirement on presets: SearXNG from its registry
  entry, and Azure OpenAI and self-hosted MinerU, which have no usable
  default endpoint.
- Check slot names and valid model references even inside an entry
  that fails the schema, and report a failed placeholder once rather
  than again as an empty value.
- Boot test for the no-file case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(config): validate openmaic.yml in two phases

Rounds 1-3 of review kept finding edge cases in one feature: running
the cross-checks on the half-valid parts of a file the schema had
rejected, so that every problem showed at once. It hid problems inside
a rejected entry, lost a __proto__ slot key, and built misleading
provider errors from failed placeholders. That feature is gone.

Validation now runs in two phases. Phase 1 is the document itself:
placeholders, key names (checked on the document as written, since the
schema drops a __proto__ key), and the schema. Any problem there stops
before phase 2, the references between entries, which therefore only
ever sees a fully valid file. Each phase reports all of its problems,
one per path.

YAML syntax errors now report only their position: js-yaml's reason
can quote source text too (an alias or tag name), not just its excerpt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(api)!: generate-classroom takes requirement + uploaded materials; capabilities follow server config (#1728)

Narrows POST /api/generate-classroom to { requirement, materialIds? }; capabilities follow server configuration; materials go through the owner material library (upload, reference by id, delete); new GET /api/generate-classroom/capabilities; skill docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): resolve a slot through the configuration layers (#1729)

* feat(config): resolve a slot through the configuration layers

Third P0 step of the provider configuration RFC (#1701, tracked in
#1725). resolveSlot walks from a slot to its capability root and
returns the first assignment it meets, consulting the deployment layer
(openmaic.yml, locked) before the workspace layer at every node. An
explicit null disables the subtree; nothing assigned up to the root is
unassigned, with no fallback to any vendor. The result carries the
provider, preset, registry entry, effective base URL, key, model, call
options, fallback, where it was resolved and whether it is locked, and
the requested slot's own requirements checked against the model
catalogue (met, unmet or unknown).

Pure and uncalled for now, so there is no behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): lock only written slots and trust the catalogue only where it applies

Review round 1:

- locked now means the requested slot itself is written in the
  deployment layer. Inheriting a deployment value does not lock a slot,
  since the workspace may still assign it (source and resolvedAt still
  report where the value came from).
- A custom OpenAI-compatible endpoint borrows the OpenAI registry for
  transport only, so its preset no longer trusts that model catalogue:
  requirements there resolve to unknown.
- The fallback is checked against the slot's requirements too, and the
  result exposes fallbackRequirements so retries can refuse it.
- A malformed reference fails with a path-qualified SlotResolutionError
  that does not echo the value, which is often a misplaced key.
- Requirement tests use named catalogue models instead of picking one
  from the catalogue at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): order layers deployment-first and keep references out of errors

Review round 2:

- resolveSlot no longer depends on the order its caller passes the
  layers in: deployment layers always come first, for assignments and
  provider lookup alike.
- Resolution errors name the path and the preset, never a value taken
  from the reference, so a key pasted into the provider position does
  not end up in a log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): cover catalogue aliases and fallback capability errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(classroom)!: save server-generated classrooms through server persistence (#1730)

Server-side classroom generation saves the finished course create-only into the request owner's library with media in the owner's asset pool; jobs move to PostgreSQL with owner-scoped polling; /api/classroom is removed; legacy data/classrooms files are imported once in the background. Adds @openmaic/storage 0.36.0 PgAssetStore.releasePending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): translate the legacy configuration into a deployment layer (#1731)

* feat(config): translate the legacy configuration into a deployment layer

Deployments without openmaic.yml keep working: provider variables,
server-providers.yml, DEFAULT_MODEL, MODEL_ROUTES and MODEL_FALLBACK are
translated into the openmaic.yml shape and serve as the deployment layer
for resolveSlot.

- Providers: each configured entry becomes a provider under its preset
  id; force-disabled entries are left out; AliDocMind's key pair goes
  into credentials.
- DEFAULT_MODEL goes on the llm root. Every other slot that serves stages
  gets what those stages used before (their route, or else the default
  model), written only where inheritance would give something else, so
  an unrouted stage under a routed parent stays on the default model.
- Stages that now share a slot but had different routes, models whose
  provider has no server configuration, and stages that used the
  browser's model but would now inherit a server one become startup
  notices, never failures.
- MODEL_FALLBACK attaches to every chat assignment without its own.
- When openmaic.yml exists it wins, with a notice if legacy variables
  are also set.

Nothing reads the layer yet; routes switch over in P1 (#1725).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): follow the agent driver's rules and keep notices value-free

Review round 1:

- The agent never used DEFAULT_MODEL: it needs a valid maic-agent-driver
  route and is off otherwise, so the translation writes agent: null where
  it would inherit a server model (and a notice for a route that does
  not work today). Unrouted conversation titles reuse the driver's model
  with thinking off, as the title generator does.
- Notices name registry ids only; a credential pasted into a model
  variable is never repeated.
- Provider entries and route options are checked against the new schema
  and left out with a notice (field names only) instead of producing a
  file that would not parse; driver-only options are dropped elsewhere.
- A route with its own fallback never falls back to MODEL_FALLBACK, even
  when that fallback cannot carry over; the retry model is part of the
  comparison with the inherited value.
- Force-off switches without a configured entry are reported, a legacy
  configuration made only of them is detected, and translating one adds
  a deprecation notice.
- loadDeploymentLayer reads the process environment and working
  directory like the legacy loaders, instead of taking parameters it
  could only partly honor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): compare routes by behavior, validate references, name only known ids

Review round 2:

- Stages sharing a slot are compared by what the route changes for them
  (model, thinking, fallback), with bare ids read as openai models and a
  route to DEFAULT_MODEL counted as no route; api and contextWindow are
  inert outside the driver.
- A model reference carries over only when it names a provider from the
  providers section and forms a valid reference.
- Section keys and force-off ids are named in notices only when they are
  registry ids with a preset.
- Retries now follow the slot; where a call site picked its retry model
  by another label (scene types under scene-content, browserless
  generation), the change is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): chat references need a chat entry; one notice for retries

Review round 3:

- A model reference carries over only when its provider was translated
  from the providers section, not merely declared by another section
  under the same id.
- Routes are compared by the retry model that takes effect, so leaving
  out a fallback equals repeating MODEL_FALLBACK.
- The per-call-site retry notices kept missing cases (streaming and
  calls outside server-managed routing never retry today). They are
  replaced by one notice, given whenever a retry model is configured,
  that retries now follow the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(config)!: translate only providers and the default model (#1732)

* refactor(config)!: translate only providers and the default model

MODEL_ROUTES does not map one to one onto slots: several stages share a
slot, the agent driver and conversation titles have rules of their own,
and retries are picked by call-site labels. Emulating that took most of
the translation and still ended in notices that are easy to miss.

The translation now carries over only the unambiguous part: providers,
DEFAULT_MODEL as the llm root and MODEL_FALLBACK as its fallback (the
agent stays null, as it never used DEFAULT_MODEL). A deployment that
sets MODEL_ROUTES without openmaic.yml gets LegacyRoutesError asking it
to write the per-stage models as slots. loadDeploymentLayer is not wired
into startup yet; that happens when routes switch to resolveSlot (P1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): say what a dropped MODEL_FALLBACK loses

Without DEFAULT_MODEL there is no assignment to hold MODEL_FALLBACK, and
calls that retried on it (server providers picked in the browser) stop
retrying; the notice says so. thinkingSchema is module-private again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): workspace model configuration with keys encrypted at rest (#1733)

* feat(persistence): workspace model configuration with keys encrypted at rest

The store behind the web settings of RFC #1701 (#1725, P1): one row per
workspace (owner) in workspace_model_config, the same shape as
openmaic.yml without policy.

- Provider secrets (apiKey, credentials) are sealed per provider with
  AES-256-GCM under OPENMAIC_SECRET_KEY, bound to the provider id, and
  never stored in the config column. Without the variable, a secret is
  created once in data/instance-secret.key (the Docker volume).
- A secret sealed under another instance secret is reported as
  unreadable ("enter again"), and a save that brings no new secret for
  that provider keeps it, so a misconfigured secret cannot destroy keys.
- Saves replace the document with a compare-and-swap on the revision,
  after the owner's identity lock; two concurrent first saves cannot
  both land (PostgreSQL contract test).
- A claim moves the anonymous configuration to the account unless the
  account has its own (core participant, order 900).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(persistence): publish the instance secret atomically; force the races in tests

Review round 1:

- The generated secret is written and flushed under a private name,
  then published with an exclusive link, so no process can read it half
  written and exactly one of two starting processes creates it. A file
  that is not a complete generated secret (empty, truncated) is refused
  instead of deriving a key anyone could compute.
- The PostgreSQL test holds both first saves at their insert until both
  have read the missing row, and holds the row lock for the update case
  until both saves queue behind it (checked by backend pid, not by any
  lock waiter in the database).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(persistence): verify the written secret; require full GCM tags

Review round 2:

- The generated secret is written in full, read back and checked before
  it is published; the temporary file is removed on any failure.
- Sealed values open only with a 12-byte IV and a full 16-byte tag
  (authTagLength), so a shortened tag cannot weaken authentication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): resolve slots at request time over deployment, workspace and defaults (#1734)

* feat(config): resolve slots at request time over deployment, workspace and defaults

The runtime half of RFC #1701 resolution (#1725, P1):

- deploymentConfig(): the deployment layer, loaded once per process.
  The legacy translation now splits into the deployment's providers and
  a separate default layer (DEFAULT_MODEL, MODEL_FALLBACK) that locks
  nothing and ranks below the workspace, as DEFAULT_MODEL ranked below
  the model a user picked.
- workspaceLayer(owner) reads the web settings; requestWorkspaceId(req)
  names the request's owner (none for an owner minted by the request).
- lookupSlot walks deployment and workspace over the whole tree first;
  the defaults are a second walk, so a default on a child never outranks
  a workspace choice higher up.
- resolveStageModel builds the language model: the configured slot, else
  what the request still names the old way (deprecated), else the
  defaults, else a loud error; a slot turned off fails whatever the
  request names. A workspace provider's endpoint is checked like a
  caller-supplied one and gets the transport that refuses redirects.
- resolveSlot gains the default source, and reports which layer declared
  the provider, its proxy and credentials.

Nothing calls it yet; call sites switch next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep request identity and workspace endpoints inside their bounds

Review round 1:

- A refused owner credential throws InvalidOwnerCredentialError (401)
  instead of resolving with the deployment's models and keys.
- A retired request owner gets no workspace: canonicalizing it would
  hand an old anonymous cookie the claiming account's settings. Only
  background work, which names a stored owner, is forwarded.
- A workspace provider may not be Amazon Bedrock (which falls back to
  the server's AWS credential chain) or set a proxy (which routes around
  the checked, redirect-refusing transport); the deployment still may.
- Test seams for the deployment config and the workspace loader, and
  tests for identity, forwarding and caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): read only the named owner's settings; refuse unmet requirements

Review round 2:

- workspaceLayer reads exactly the owner it is given. Forwarding through
  a claim let a request whose owner was claimed between its check and the
  read see the account's settings; background work passes the owner it
  works for now instead.
- A model the catalogue says does not meet the slot's requirement is
  refused before anything is built (SlotRequirementError), without
  consulting the request.
- Tests: a database failure fails the call without falling back, a
  workspace provider without a key never gets the deployment's, and the
  transport each provider source gets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(identity): exempt the model settings lookup from cookie forwarding

requestWorkspaceId resolves the owner only to pick whose model settings a
generation call uses, and returns no response of its own, like the
vision prompt helper already listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config)!: resolve every LLM call through its capability slot (#1735)

* feat(config)!: resolve every LLM call through its capability slot

The LLM call sites of RFC #1701 switch to slot resolution (#1725, P1):

- resolveModel resolves a stage through its slot for the request's or
  job's workspace: the deployment and workspace configuration first; the
  model and key a request names (x-model, x-model-routes, body fields)
  only for a slot left unassigned, deprecated; then the defaults an
  older deployment set with DEFAULT_MODEL. Routes that read headers get
  this through resolveModelFromRequest; the chat routes pass their
  workspace; background work (agent runs, titles, generation jobs)
  passes the owner it works for now.
- Retries follow the slot: a slot-resolved model carries its slot's
  fallback (lib/ai/model-fallbacks.ts), which callLLM and the outline
  stream use; only a model from the request path still retries on
  MODEL_FALLBACK. A fallback that cannot meet the slot is not used.
- The agent driver resolves the agent slot: tool calling required,
  api defaults to openai-completions, thinking.effort still refused.
  Conversation titles resolve agent.title with thinking off unless the
  title slot sets it.
- /api/generate-classroom resolves each step through its slot for the
  job's owner, its outline through course.outline.
- MODEL_ROUTES is gone: loadDeploymentLayer runs at startup and a server
  that still sets it without openmaic.yml refuses to start. The
  pbl-chat and maic-agent stage keys, which nothing resolved, are removed.

BREAKING CHANGE: MODEL_ROUTES is no longer read; write per-stage models
as slots in openmaic.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): a slot fallback needs no serverManaged stamp; follow claims per stage

Review round 1:

- callLLM arms a model's attached slot fallback whether or not the caller
  passes serverManaged (the PBL agents pass none); the stamp still gates
  MODEL_FALLBACK on the request path.
- /api/generate-classroom resolves the owner the job works for now at each
  stage, so stages after a claim follow the moved settings.

Streaming calls (agent driver, classroom chat, PBL streams) still do not
retry on a fallback model, as before this change; that is the #1725 item
on retries at every call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): resolve media and tool capabilities through their slots (#1737)

* feat(config): a provider-only reference names the provider's default model

Search and document providers mostly have no model to pick, so a slot
may name just the provider (`webSearch: tavily`); the target then has no
modelId and the adapter uses its default. Chat slots still need
providerId:modelId, checked in openmaic.yml and at resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(config): resolve media and tool capabilities through their slots

The capability routes and server paths of RFC #1701 (#1725, P1): text
to speech, speech recognition, images, video, web search and document
extraction resolve through their slots, like language models.

- lib/server/model-config/media.ts: the configured slot (deployment,
  then workspace); else the provider a request names the old way
  (deprecated); else the legacy defaults; else a loud error. A slot turned
  off fails whatever the request names, and while the legacy
  <CAP>_<VENDOR>_ENABLED=false switches are in effect a switched-off
  provider stays off whoever assigns it. A workspace endpoint is checked
  like a caller-supplied one and may not use a proxy.
- The legacy translation assigns the media roots the provider the server
  picked when a request named none (first configured; web search by its
  old priority; DEFAULT_IMAGE_PROVIDER for images), with the first pinned
  model.
- Routes: /api/generate/{image,video,tts,voice}, /api/transcription,
  /api/web-search, /api/parse-pdf and /api/extract-document. A TTS voice
  applies only to the provider it was chosen for; voices register on the
  tts slot's provider.
- Server paths: agent image and video generation, scene narration, the
  voice catalog and registration, web search and fetch_url, material
  extraction (the document slot's service, the asr slot for local
  transcription), browserless classroom media, and the generation
  capabilities reported by /api/health and the classroom capabilities
  endpoint.
- A provider-only reference (`webSearch: tavily`) names the provider's
  default model; chat slots still need providerId:modelId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep slots authoritative on every media path

- Workspace providers reach media, search and document services only at
  their preset's endpoints; a custom endpoint or proxy is deployment-only
  (403 INVALID_URL).
- A configured or turned-off document slot decides the extraction service:
  deprecated request fields may only pick a self-contained extractor, and
  legacy operator credentials are no longer reached.
- Request paths resolve their own workspace without following a claim;
  background extraction still does.
- A configured TTS slot keeps its own model; legacy pins apply only on the
  deprecated and default paths.
- Local transcription and agent voice registration keep the connection's
  network policy flags.
- The agent's web search forwards the whole resolved configuration.
- An unusable DEFAULT_IMAGE_PROVIDER leaves the image slot unassigned with a
  notice instead of switching vendors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): public presets only for workspaces, plan default models

- A workspace preset whose default endpoint is on the server's own network
  (self-hosted TTS, ASR, image) is deployment-only, and a workspace
  provider's endpoint runs under the public-only policy.
- A provider-only reference to a token plan means the plan's own default
  model for that capability.
- On the legacy default provider, the request's image, video and ASR model
  still applies through its allowlist, and image/video without a model is
  still MISSING_MODEL.
- An asr assignment a workspace may not use no longer blocks document
  extraction, and a refused document service answers 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep legacy search and voice choices on their old paths

- Classroom search honours the provider and key a request names while the
  webSearch slot is unassigned; only /api/web-search prefers the operator's
  configured backend, as before.
- A TTS request's voice applies unless the request chose it for another
  provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): legacy search model and capability discovery errors

- On the legacy default search provider, the request's search model still
  applies through the server's pins.
- Capability discovery answers a refused credential (401) or a workspace
  service it may not use (403) instead of 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): classroom submission refuses a workspace service with 403

A classroom submission whose materials would go to a document or speech
service the workspace may not use answers 403 INVALID_URL instead of 500, and
its material check resolves the request's own workspace without following a
claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): workspace image and video providers run public-only

A workspace-configured image or video provider now uses the strict public
transport for its requests and for the redirects its clip download follows,
whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the
deprecated per-request path keep their policies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* Revert "fix(config): workspace image and video providers run public-only"

This reverts commit 68021ded. A workspace provider cannot choose an endpoint
for media, search or document services at all: a custom base URL or proxy is
refused, and so is a preset whose default endpoint is on the server's own
network. What remains is a preset's fixed public endpoint, the same one a
deployment default uses, so these calls keep the operator's transport policy
like every other preset endpoint, and the connection no longer claims a
user-typed endpoint (userEndpoint is false).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): /api/web-search without a provider searches as before

A deprecated web search request that names no provider again means the
server's configured provider, else the default one with the request's key,
while the webSearch slot is unassigned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): browser speech recognition does not count for extraction

An asr slot assigned to speech recognition that runs in the browser gives
server-side extraction no transcription service, so audio uploads are not
advertised or accepted for extraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): /api/parse-pdf honours an explicit local parser

A request that asks for a self-contained extractor (local parsing) parses
the PDF locally and sends it to no document service, whatever the document
slot names, as /api/extract-document already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(classroom): give the persistence contract its media slots

The generated-classroom PostgreSQL contract configured its image and TTS
providers through the legacy provider mocks, which slot resolution no
longer reads; it now sets the deployment's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(llm): streaming calls fall back on their slot's fallback (#1739)

* feat(llm): streaming calls fall back on their slot's fallback

A stream resolved through a slot that fails before any content (the request
is refused, or the first part is a retryable error) runs once on the slot's
fallback model. A failure after content has started still reaches the caller.
The outline stream keeps its own retry and fallback handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(llm): the stream fallback is the call's last attempt, before any content

- The primary's own SDK retries run first; the fallback runs once, as the
  last attempt, and a failing fallback is not retried.
- No fallback once content reached the caller in any step (a tool that ran
  must not run again); after a fallback took over, later steps stay on it.
- The fallback gets the thinking options built for its own provider and
  model, not the primary's.
- Usage of a stream with a slot fallback is recorded per step, against the
  model that served the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(llm): stream fallback recognises transient error payloads

Providers send a stream's error part as a plain { type, message } payload
rather than an error; a transient type (overloaded, rate limited, server
error) now counts as retryable for the slot fallback, while authentication
and request errors still do not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): model settings API for workspaces (#1740)

* feat(config): model settings API for workspaces

GET/PUT /api/model-config read and edit a workspace's slots and providers:
every slot with its own assignment, effective model, source and lock state;
deployment providers read-only and without credential details; workspace
keys write-only and masked. Edits are checked against the whole
configuration and a revision. The view carries the preset catalogue a
workspace may add providers from, and each provider's models per capability,
so the settings UI needs no registry code of its own.

Workspaces cannot add Bedrock, self-hosted media/search/document presets or
custom endpoints for anything but chat. POST /api/model-config/import merges
settings a browser kept, item by item under the same checks, never replacing
what the workspace or deployment already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(config): preset ids as client-safe data

The preset id tables move to lib/config/preset-ids.ts, which imports no
registry, so browser code (the settings import) can name presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): tighten the settings API's views and checks

- Effective targets name an endpoint only for the workspace's own
  providers; a deployment's endpoints never reach a response.
- A workspace provider of a local model server preset (whose default
  endpoint is the server's own network) must name its own endpoint.
- Every change is checked against the stored shape, so an import skips a
  malformed item instead of failing the batch, and PUT validates its whole
  body (400, never 500).
- Clearing a key deletes it even when the instance can no longer open it.
- A workspace provider with its own endpoint offers chat models only, and
  an OpenAI-compatible provider offers only the models it lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): no credentials in endpoints; key-pair presets stay in the yml

- A workspace base URL cannot carry a username or password (they would be
  stored and shown in the clear), and views never show credentials a stored
  endpoint carries.
- Presets that authenticate with a key pair (AliDocMind) are not offered to
  workspaces: the settings take one key per provider.
- An import checks each proposed provider on its own, so one malformed
  provider is skipped instead of failing the batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): settings neither offer nor accept switched-off providers

A provider the operator switched off for a capability (the legacy
<CAP>_<VENDOR>_ENABLED=false switches) is left out of the capability lists,
refused as an assignment, and shown as invalid where a deployment assigns it,
as the calls themselves refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): recommendations name only capabilities a preset still offers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): a provider id follows the reference grammar before it is used

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): the catalogue lists the models a search provider offers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): refuse a fallback on a slot whose calls never use one (#1743)

* fix(config): refuse a fallback on a slot whose calls never use one

Only language-model calls retry on a slot's fallback; a fallback on a
speech, image, video, search or document slot was accepted and silently did
nothing. Resolution now refuses it by path, so openmaic.yml fails at startup
and the model settings refuse it on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): openmaic.yml refuses a non-chat fallback at startup

The boot check parses openmaic.yml without resolving slots, so the refusal
also lives in the file's cross-checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): Models section with the course model map (#1741)

* feat(settings): client for the workspace model settings

A small client for /api/model-config: it caches the view per page, applies
changes against the revision it read, reloads on a stale revision (409
CONFLICT) or a slot the deployment has since locked, and treats a server
without persistence (404) as settings managed by the server.

Pure helpers behind the settings UI live beside it: slot changes from the
card picker (follow, off, a model, a fallback, the media switches), the
provider form's change (keys stay write-only: keep, replace or remove),
the first-run setup that adds a provider and fills only the empty,
unlocked slots with its preset's recommendations, and the layout of the
course model map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings): Models section with the course model map

A new "Models" section, first in the settings dialog and the one it opens
on. It reads and writes everything through /api/model-config:

- The model map: a pannable, zoomable canvas with the course pipeline as a
  two-row serpentine flow, the default language model above the stations
  that inherit from it, and the page types under the content station. Each
  card shows the effective model, where it comes from and a lock when the
  server sets it. Solid edges follow the parent, dashed ones mark a
  setting of the slot's own.
- Editing happens in place: a picker at the card to follow the parent,
  pick a model a provider offers (or a provider, for search and document
  slots), turn the slot off, or set a fallback for chat slots. Media
  switches turn a slot off and back on.
- While no language model is configured, the default model's card offers
  a first-run setup: pick a service, give its key, and its recommended
  models fill every empty slot.
- A Providers tab lists the server's providers (read-only) and the
  workspace's own, which can be added, edited and removed as the server's
  policy allows.

The generation toolbar's "configure provider" prompt opens this section.
Below the sm breakpoint the settings nav becomes a strip above the panel.
Strings are translated for all 12 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keep what a change does not mean to touch

- A provider edit carries only the fields the form shows: a hidden model
  list or endpoint is left out, so the server keeps it; a shown field that
  is emptied is removed. A pinned model list keeps its field on edit.
- A key the server cannot read starts as "replace" (it can also be removed),
  so a key typed for it is sent instead of the broken one being kept.
- Switching a media slot off remembers what it held; switching it back on
  restores that assignment (or nothing of its own), and when that is not
  known the caller asks instead of clearing the slot to the default.
- The first-run assignments are checked against the provider as the server
  answered it: a provider with its own endpoint serves chat only, a model
  list the user gave wins over the recommended chat models, and `llm` is
  one of the provider's own chat models. The filling can be retried against
  a reloaded view, and a refusal keeps its reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): recoverable first-run, root clear action, keyboard on the map

- A first-run setup that added its provider but could not assign it (a
  stale revision, say) reports to the section, which keeps a notice with
  the provider and a way on (assign its models again, or add them under
  Providers) through any reload, instead of losing it with the form.
- The picker of a root slot with a setting of its own offers to clear it,
  leaving the server's value or default.
- A card's switch restores what the slot held; when that is unknown it
  opens the picker.
- Keyboard focus on a card outside the view pans the map to it, and the
  arrow keys pan the map while it has focus.
- The provider form offers only replace or remove for an unreadable key,
  and says that a provider with its own endpoint serves chat only.

Component tests drive the provider form, the switches, the root picker,
arrow-key panning and a first-run setup that meets a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keep picker row labels whole; one unreadable-key notice

A long note no longer truncates the row's label in the slot picker, and the
provider form stops repeating the unreadable-key warning the row already
shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a lost answer to a write is an outcome, not a throw

When the server takes a change but its answer cannot be read (a truncated
body, a dropped connection), apply() no longer rejects: it reloads the view
to reconcile a change that may have been saved and returns an `unconfirmed`
outcome with the reloaded view. The first-run setup goes on when the
reloaded view has the provider it added, counts a slot write whose answer
was lost as done when the reload shows the default model set, and says so
otherwise. Busy states in the provider form, provider removal, first-run
setup and its notice are reset in `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keyboard roving in the slot picker

The picker's rows are plain buttons (pressed for the current choice) in a
group, not listbox options without the keyboard model those promise. The
list is one Tab stop, the current choice or else the first row; ArrowUp and
ArrowDown step, Home and End jump, Enter and Space choose. The picker opens
on that row. The picker's and the card switches' busy states are reset in
`finally`, and the component tests cover the lost-answer first-run paths
and the picker keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a write lost in transport is unconfirmed, and first run can resume

A write whose request fails in transport may still have been saved, like
one whose answer is lost: apply() now reloads the settings and returns an
`unconfirmed` outcome in both cases, with the reloaded view when that read
worked. A first-run setup whose provider add cannot be confirmed (the
reload failed too) keeps a notice that says so, and "Check again" reads the
settings and either assigns the provider's models or says it was not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): retry an unconfirmed provider add under the same id

The add-provider form keeps the id it tried. When the answer was lost it
closes if the reloaded settings show that provider, and otherwise retries
under the same id, so a save that did land is updated rather than joined by
a second provider; a genuinely new add still gets a fresh id. Component
tests cover this and the first-run paths where the provider add fails in
transport after the server saved it, or cannot be confirmed at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): 5xx answers to writes are unconfirmed; reads never go back

A server error or a gateway timeout can come after a write was saved, so a
5xx answer to a write is treated like a lost answer: the settings are read
again and the outcome is `unconfirmed` with the reloaded view. A 4xx is
still a refusal, without a reload.

Reads and adopted write answers are numbered: a read's answer is dropped
when a later read started or a write's answer was adopted after it began,
or when its revision is older than the view held. The reads that must see a
write just made (after an ambiguous write, a conflict, "Check again") start
a fresh request instead of joining one begun before the write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): first run is done only once the default model is set

A slot write that succeeds without setting `llm` (another session turned it
off meanwhile, so only media slots were filled) no longer counts as a done
setup: the notice stays and says the default model is still missing, for
the user to pick on its card. Test views now carry `llm` resolved the way
the server answers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): the revision decides which settings view is newer

A view with a lower revision than the one held never replaces it, whether
it comes from a read or a write's answer, and one with a higher revision
always does, whenever it arrives; the order in which reads and adoptions
were numbered only breaks ties at an equal revision (and decides while no
view is held, so a read begun before the view was forgotten does not bring
it back). A write whose answer is older than a view read meanwhile returns
the view that is current. `adopt` takes a view or null (forget it) and is
part of the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): send each change against the view it was worked out from

apply() takes the view a change was computed from and sends that view's
revision, rather than the revision of whatever view is held when the write
goes out. A retry of the first-run assignments that waits for a reload in
flight is therefore refused (409) when the reload shows the settings
changed, instead of overwriting a slot set meanwhile; the reload then feeds
the next attempt. The slot picker, the card switches, the provider form,
provider removal and the first-run setup all pass the view they showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a provider edit sends only what changed, and rebases on a conflict

The provider form keeps the basis of an edit: the provider as the edit
began and the view it came from. A save sends only the fields changed
against that basis (a key-only edit sends the key), against the basis
view's revision. When the provider changed elsewhere meanwhile (409), the
edit moves onto the reloaded provider, keeping only what the user changed,
and the form says the provider changed before it is saved again. A 409 now
returns the reloaded view to work the change out again from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a refused provider add keeps its draft and picks a free id

Only an add whose outcome is unknown (a lost answer, a transport failure, a
5xx) is reconciled by looking for its id in the reloaded settings. A
confirmed refusal (a 409, another tab having added the same preset under
the same id meanwhile) saved nothing of ours: the form keeps the draft,
shows the conflict, and the retry uses an id still free in the reloaded
view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(settings)!: server-side model settings only; import browser settings once (#1744)

* feat(settings): one-time import of browser model settings

The settings store's migration to version 5 builds an import proposal
from the provider state earlier builds kept in the browser (keys, custom
endpoints, the chosen model, token plan enrollment, per-capability
selections) and keeps it under its own localStorage key. Once the store
has hydrated, the proposal is posted to /api/model-config/import: a 2xx
answer removes it (and the keys) from the browser, 400 drops it, and
anything else keeps it for a later load. Per-stage routes are not
imported. Clear Local Cache keeps a proposal still waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings): stop sending provider data from the browser

Every request now leaves model and provider choice to the server, which
resolves it through the workspace's capability slots: no model, key or
base URL headers (x-model, x-api-key, x-model-routes, x-image-*,
x-video-*), no provider, key or thinking fields in chat, TTS, voice
registration, transcription, web search or document extraction bodies.
The server keeps accepting them until they are retired.

What the client still needs to know is read from the /api/model-config
view (lib/model-settings/capabilities.ts): whether a language model is
set up, which provider the tts slot names (browser speech plays locally;
voice lists follow that provider), whether speech input, image, video
and web search are available, and the course model the toolbar shows or
edits through the settings API. The scene concurrency comes from
/api/health. The unused TTS config popover and getCurrent*Config
helpers are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings)!: remove the browser provider state and its settings UI

The settings store keeps only the user's own preferences (playback,
narration voice and speed, speech input language, outline review,
agents, layout). Providers, keys, base URLs, the model choice, thinking
settings, per-stage routes, token plan enrollment and seeds, the
per-capability provider configs and selections and their on/off
switches are gone; the version 5 migration sets them aside for the
one-time import and drops them. The narration voice now records the
provider it was picked for and applies while the tts slot names it.

The Token Plan, Model Services and Course Model sections and their
components are removed, with apply-token-plan, the server provider sync
(ServerProvidersInit, fetchServerProviders) and helpers only they used.
A Voice section keeps the per-user parts of the old speech settings:
speed, a narration test, the VoxCPM and Qwen voices, and the speech
input language, showing which services the workspace uses. The home
toolbar picks the course model through the settings API, or shows it
read-only when the deployment locks it.

e2e fixtures answer /api/model-config instead of seeding browser
providers; the managed-provider spec, which covered the removed panel,
is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings): browser speech recognition while the asr slot is unassigned

Speech input worked out of the box through the browser's own recognition,
which needs no server provider; it stays available while the asr slot is
unassigned, and turning the slot off turns speech input off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): bind the model settings import to its owner and never lose staged keys

The import now asks for the browser's legacy import binding and sends
X-OpenMAIC-Legacy-Import, so owner resolution refuses it for any owner
that does not hold the browser (409 LEGACY_IMPORT_NOT_BOUND keeps the
proposal for a later load); the import route joins FENCED_ENDPOINTS.
Its completion is the proposal's own key, apart from the course ledger.

Staging reports whether it succeeded. When the proposal cannot be
written (a full storage, an unreadable proposal waiting), the migration
keeps the old settings in legacyModelSettings and every load retries,
writing the store back without them once staged. Nothing logged quotes
the proposal or an error message: fixed text, item ids, error names.

Older shapes are normalised before the proposal is built (version 0
default model, the single TTS model setting, global TTS/ASR model ids,
a TTS provider's model field, the flat web search key). Capabilities
the user turned off are proposed as off (null), and selected
server-configured media providers are named by their preset id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): retry a failed read of the model settings

A failed read of /api/model-config with nothing to show is read again
with a backoff (2 s up to a minute) until one succeeds, instead of
leaving the page without capabilities for the rest of its life.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): send no provider or model with voice registration

Voice registration, deletion and auto-registration no longer send
providerId or ttsModelId: the server registers with the provider and
model the tts slot resolves to. The model still derives the voice id
and keys the session memo. The unused OpenRouter model list hook, which
called the vendor with a browser key, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): carry only an explicit speech input off into the import

Only `asrEnabled: false` becomes an off slot (`asr: null`): without an
asr slot the browser's own speech recognition would take over, which the
user had turned off. The TTS, image, video and web search switches were
per-browser toggles that availability following the slots replaces on
purpose; turning them into lasting workspace nulls would override the
deployment's defaults from then on, so they are not carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): gate chat and generation on the slots they resolve

Chat and discussion resolve the classroom slot, and course generation
its outline, actions and content slots; each is now allowed whenever
those resolve to a model, even with the llm root unassigned or off
(a child assigned on its own). Settings not read yet still block
nothing. The toolbar offers "Set up model" only when a course cannot
be generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import models the user added to built-in providers

A built-in chat provider whose model list held models the user added
(ids not in its catalogue) is proposed with those models, listed after
the catalogue's: a provider's model list names the chat models it
serves, so listing only the added ones would hide the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): wait for the commit explicitly in the media overlap test

The overlap test counted 50 microtasks for the mocked commit to be
reached; with the capability read before each pass that is no longer
enough, so the test failed alone and left a pass running into the next
test. It now awaits a signal from inside the commit, flushes every
pending microtask before asserting, and releases and awaits both passes
in `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import models the user added to an enrolled token plan

An enrolled plan's chat provider whose model list held models the user
added (ids not in the plan's own list) is proposed with them, listed
after the plan's, by the same rule as built-in providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): scope voice registration memos to the tts slot's provider

The session memo and in-flight map of auto-voice registration were keyed
on the voice and model only, so after the tts slot moved to another
backend serving the same model, registration was skipped and synthesis
named a voice that backend never registered. They are now scoped to the
provider the slot resolves to (provider id, registry entry, endpoint)
and the settings revision, captured once per registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): show the view the model settings import produced

After a successful import the page read the settings again, but that
read could join the page's first read, still in flight from before the
import, and leave the unassigned view in place. The import now hands
over the view the import route answered, and adoptNewerView lets any
read in flight finish before keeping whichever view is newer by
revision, so an older answer cannot land over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): offer and resolve voices against the tts slot's model

With the tts slot on a model that cannot speak some voices (an OpenAI
slot on tts-1 and Marin or Cedar, which need gpt-4o-mini-tts), the
picker still offered them and synthesis on the slot's model failed.
The slot's model (its own, else the provider's default) is now the
model voices are offered under and checked against: the voice lists
hold only voices it can speak, a persisted voice it cannot speak falls
back to a compatible default, and narrator and agent bindings to such
a voice are not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): drop translations only the removed provider panels used

208 keys that only the removed provider settings (model services, token
plan, course model config, their dialogs and the TTS popover) used are
removed from all 12 locales: API key and base URL fields, provider and
model editors, connection tests, per-capability switches and the like.
Each was checked to have no remaining reference, literal or through a
key prefix built at run time. The TTS enablement locale test keeps the
key still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): provider options in openmaic.yml, passed to the TTS adapters

A provider in openmaic.yml may carry `options`: non-secret,
provider-specific settings (a map of names to strings, numbers or
booleans; `${VAR}` interpolation applies). They reach the resolved
slot target, the media connection and the settings view, and the
server's TTS paths (the TTS and voice routes, scene narration, classroom
media generation, the voice-clone tools) hand them to the adapter as
its providerOptions: over a request's options for a configured slot,
under them while the slot is unassigned (the deprecated path). Voice
registration follows them too (a VoxCPM backend without runtime
registration is refused). Workspace providers cannot set options: they
are the deployment's. The legacy configuration had no equivalent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): read the VoxCPM backend from the tts slot's options

The voice settings, the voice lists and auto-voice registration assumed
the default VoxCPM backend once the browser no longer chose one. They
now read the `backend` option of the provider the tts slot resolves to
(shown in the settings view), and the registration memo is scoped to
the provider's options as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): label the preset choice when adding a provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): media planning from the slots; stop when settings are unread

The outline route decided whether to plan images and video from the
client's x-image-/x-video-generation-enabled headers, and the client
sent them from settings it might not have read: a course could be
generated silently without media. The route now reads the workspace's
image and video slots itself (an explicit `false` header still lets an
API client opt out; `true` turns on nothing), and the client no longer
sends them.

When the model settings cannot be read even after another try,
generation stops with a message instead of leaving out narration (the
scene fails and generation pauses; the preview's first scene errors),
and a media pass or retry stands down without marking anything as
disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): pick the course model when none is set

Without a default model the toolbar showed no picker, so the course
model could only be set in Settings. It now offers the picker whenever
the workspace may set the llm slot and chat models exist, with nothing
selected until one is picked (which sets the slot; the server assigns
nothing on its own). The legacy translation's notice for a model whose
provider the server does not configure now says each workspace chooses
its model in Settings → Models, or to configure the provider and set
DEFAULT_MODEL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import keyless search choices and the Claude search model

A keyless search service (Brave) that the user selected with research
switched on is proposed as a provider of its preset with the webSearch
slot on it; untouched defaults and self-hosted services (SearXNG, whose
endpoint only the deployment may set) are not. The model picked for
Claude web search is carried as `claude:<model>` instead of being
dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): refuse credential-like provider option names

A provider's `options` are shown in the settings view, so a name that
matches key, secret, token or password is refused with a pointer to
apiKey and credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): pin which course slots the generation gate needs

courseGenerationUsable requires the outline, actions and a content slot.
The agents and research slots are not required, and a test pins it: with
course.agents off the roster falls back to the preset agents, and with
course.research off web search runs on the raw requirement, so neither
request is refused by the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(settings): what the import leaves behind; extraction is a slot

The importer README lists what is not carried over and where it is set
now: per-stage routes, the per-browser media and research switches,
Baidu sub-sources, thinking settings and the VoxCPM backend (options in
openmaic.yml). The document extraction README no longer documents the
removed store fields and request-level provider fields: extraction is
the workspace's document slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): release the recorder lock when a start is refused

startRecording returned early without clearing its lock when speech input
was not set up (the asr slot off, or still unknown while the settings
loaded) or the browser lacked speech recognition, so every later click
did nothing until the component remounted. Each early return now
releases the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): decide research from a successful settings read

The home page saved `webSearch: undefined` into the generation session
when the model settings could not be read, and the preview researched
only on that saved flag, so a transient failure skipped research for the
whole generation. Saving the session now needs a successful read (read
again after a failure, else it stops with the "settings could not be
read" message), and starting or resuming generation decides research
again from the current webSearch slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(settings): type the research decision session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): a policy without workspace providers also stops using them (#1750)

* fix(config): a policy without workspace providers also stops using them

`policy.allowWorkspaceProviders: false` only stopped workspaces from adding
or editing providers; ones added before kept serving their assignments.
Resolution now leaves them out, with the workspace assignments that name
them (an assignment whose fallback alone names one keeps its model), so the
policy takes effect for existing workspaces too and the settings view shows
what the calls use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): policy keeps shared provider ids and refuses new references

- A workspace provider id the deployment also declares resolves to the
  deployment's provider, so its references are kept under the policy.
- The model settings refuse a new assignment to a workspace provider the
  policy no longer allows, while other edits leave dormant ones as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): settings validate a workspace as the policy lets the calls see it

Assignments the policy leaves dormant are not checked on unrelated edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs(config): openmaic.yml, capability slots and the model settings (#1742)

* docs(config): document openmaic.yml, slots and the model settings

The configuration docs are rewritten around openmaic.yml: providers, the
slot tree and its inheritance, assignment forms, provider-only references,
fallbacks, turning a capability off, locks and the workspace model settings,
policy, OPENMAIC_SECRET_KEY, what workspaces may add, the deprecated request
fields, and migration from provider variables, server-providers.yml,
DEFAULT_MODEL/MODEL_FALLBACK and MODEL_ROUTES (with the stage-to-slot table).
The environment-variable reference stays as the legacy configuration.

Deployment, supported models (a preset catalogue), getting started and
VoxCPM2 point to openmaic.yml; the READMEs' quick configuration and the agent
runtime example use it; the openmaic skill tells agents to configure models
through openmaic.yml or the model settings. openmaic.example.yml is a starting
point, and a test parses it and every documented openmaic.yml example with the
real schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): translate the openmaic.yml documentation

Brings the zh-CN, zh-TW, Japanese, Russian and Arabic pages in line with the
English configuration, deployment, supported models, getting started and
VoxCPM2 pages: the same sections, examples and tables, with the legacy
environment-variable reference kept as before under its own heading. Also
fixes the zh-CN VoxCPM2 link to the TTS section, which pointed at the English
anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): match the settings UI names, set a model in the quick start

- Configuration: the settings tabs are "Model map" and "Providers", the lock
  reads "Set by the server", and the first-run setup is "Connect a service".
  Browser import: keys are removed from the browser once imported (a failed
  import keeps them), and speech input that was switched off becomes
  asr: null.
- Getting started: the browser no longer sends a model, so the quick start
  sets one, with a minimal openmaic.yml or DEFAULT_MODEL next to the key, and
  names Connect a service in Settings > Models as the path without a file.
- openmaic.yml is in .dockerignore so a file with inline keys is never baked
  into an image; the deployment page says to mount it at run time.

All six locales are updated where the text changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): scope fallbacks, trim the example, translate volume repair

- Fallbacks apply to language-model slots (llm and the slots below it); a
  fallback on a media, search or document slot is refused at startup or on
  save.
- openmaic.example.yml is copied as-is by the READMEs, getting started and
  the skill, and startup refuses any unset ${VAR}: it now has one active
  provider (OPENAI_API_KEY) and the llm slot, with every other provider and
  slot as a commented optional block. The copy instructions say so.
- The translated deployment pages gain the root-owned volume repair
  paragraph and its chown command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): configure self-hosted and custom media endpoints in openmaic.yml

Workspaces cannot add custom non-chat endpoints or self-hosted media presets,
so the docs no longer send users to the browser settings for them:

- Configuration (legacy reference): custom OpenAI-compatible TTS and ASR
  endpoints and ComfyUI are declared in openmaic.yml with their baseUrl and
  assigned to tts / asr / image; custom chat endpoints are an
  openai-compatible provider in openmaic.yml or the Providers tab. The claim
  that ASR configuration stays in client settings is gone.
- VoxCPM2 (page and READMEs) is configured in openmaic.yml, with the legacy
  variable as the fallback; the per-browser Base URL option is removed.
- MinerU in the READMEs is a document provider in openmaic.yml.
- Deployment and ComfyUI: providers declared in openmaic.yml are
  server-managed and may use private endpoints without ALLOW_LOCAL_NETWORKS,
  which only matters for endpoints a user types (chat base URLs in the model
  settings, deprecated request fields).

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): document provider options and the VoxCPM2 backend option

- Configuration: the provider fields table documents `options`: non-secret,
  provider-specific settings (string, number or boolean values, ${VAR}
  allowed), shown in the model settings so never a key, and deployment-only.
- VoxCPM2 (page and READMEs): the backend is chosen with options.backend on
  the provider in openmaic.yml (vllm-omni by default, python-api, nano-vllm),
  not in the browser settings; voice registration works only on vllm-omni,
  and the troubleshooting row points at options.backend.
- The example test skips openmaic.yml blocks that set provider options until
  the schema on this branch accepts them (marked TODO).

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(docs): check the VoxCPM2 examples now that provider options exist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): describe the browser import, Voice settings and web search as released

- Browser import: the proposal and its keys are removed after any 2xx (even
  with skipped items, which are kept nowhere), and after a 400 or an
  unreadable proposal; it stays for another attempt only on 401, 404, 409,
  5xx or a network error. Lists what has to be set up again by hand
  (per-stage models, thinking settings, custom speech and transcription
  providers, AliDocMind's key pair, the VoxCPM backend, Baidu sub-sources)
  and that the per-browser capability switches do not carry over.
- VoxCPM2 voices (page and READMEs): assign VoxCPM to tts, then Settings >
  Voice > VoxCPM voices, which appears only when tts resolves to voxcpm-tts.
- Web search has no per-generation switch: it runs when the workspace's
  webSearch slot resolves, and turning it off affects later generations.
- The READMEs' persistence section no longer says keys go in .env.local only.

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): startup errors name the field, or the line for broken YAML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(settings)!: main's settings UI on the server-side model configuration (#1767)

* feat(config): test a saved provider from the settings by its id

The settings test buttons (and the model list fetch) name a provider the
workspace has configured instead of sending its key and endpoint: the
server resolves it from the deployment or the workspace's own
configuration, under the same rules as a slot (the policy, self-hosted
media presets, custom endpoints).

- verify-model, verify-image-provider, verify-video-provider and
  verify-pdf-provider take `provider` (and `model`); probe-models takes
  `provider` for one of the workspace's own chat providers.
- generate/tts and transcription take `previewProvider` (and
  `previewModel`) for a settings preview of a provider other than the
  slot's.
- The settings view's catalogue models carry what the registry knows of
  them (capabilities, thinking controls, context window) and the
  registry entry that serves each capability, for the settings to show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings)!: bring back the Token Plan, Model Services and Course Model sections on the server's configuration

The settings dialog gets its earlier information architecture and layout
back: Token Plan, Model Services (one tab per capability: language
models, image, video, text to speech, speech recognition, document
parsing, web search, each with its list of services and their panels),
Course Model Config (the pipeline with a model per stage), Skills and
General. The model map with its provider list and the separate Voice
section are removed.

The panels read and write the workspace's model configuration on the
server instead of the browser store:

- A service's key, endpoint (chat services only) and model list are its
  workspace provider (id = the service's preset id); keys are write-only
  (a mask is shown; replace or remove). A newly added service fills the
  root slots of what it serves that have nothing set yet.
- Services the deployment configures are shown read-only; key-pair,
  self-hosted and (under a policy without workspace providers) all other
  services say that only the server's configuration can set them up.
- The Course Model main model is the `llm` slot (with its thinking
  settings); each stage is its slot (follow the main model = no
  assignment of its own); the media switches set their root slot to null
  and restore what it held; locked slots are shown disabled.
- Token Plan: connecting adds the plan's provider and fills the empty,
  unlocked slots it recommends; disconnecting removes it.
- Test buttons name the saved service; no key leaves the browser.
- The narration speed and test are back in the text-to-speech panel and
  the recognition language in the speech recognition panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(toolbar): the course model picker and extractor as before, on the server's slots

The home toolbar's model picker gets its thinking control back (the
`llm` slot's thinking settings) and its groups in the plan-first order
the settings use, and the course material popover gets its extractor
select back: it sets the `document` slot (a built-in parser that needs
no key is added on first use). The set-up prompt opens Model Services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(settings): name the Token Plan, Model Services and Course Model sections

The docs, READMEs and messages pointed at the Models section (model map,
providers tab, "Connect a service") and the Voice section, which are
gone. They now name the sections the settings have again: a plan's key
in Token Plan, a service's key in Model Services, per-stage models and
capability switches in Course Model Config, VoxCPM voices in Model
Services → Text-to-Speech. The server-side explanations (locks,
write-only keys, deployment-only services, the one-time import) stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): web research dims only when search is off, as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): list a saved provider's models without naming a model

Fetching the models of a saved chat provider passed its bare id to the
chat resolver, which requires `provider:model`, so every request was
refused. The provider's connection is now resolved without a model (still
only the workspace's own providers, and the endpoint is still checked).
The settings view test also covers an OpenAI-compatible deployment
provider's listed chat models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings)!: Course Model Config is the course model map

Course Model Config shows the course model map again in place of the
earlier pipeline panel: the default model card on top, the pipeline as a
two-row serpentine with the content station expanding to its page
types, editing on the card (follow the parent, a model or off, and a
fallback for chat slots), "Set by the server" locks, solid and dashed
lines, zoom, pan and keyboard. Its provider tab and first-run card stay
out: services are set up in Model Services and Token Plan, and the map
links to Model Services where it needs one.

- One switch implementation (flipSwitch): off sets the slot to null, on
  restores what it held, else takes the first service that serves it
  (speech input returns to the browser's recognition), else opens the
  picker. What it turned off is remembered, and what it restored
  forgotten, only once the server confirmed the change.
- Speech input shows its switch while it runs in the browser (unset),
  and a service can be picked for it then.
- The server's providers count wherever chat models are offered (the
  map, the toolbar, Model Services); the map's "no model" note shows only
  when nothing at all offers one.
- Model Services names a provider by its preset and id
  ("OpenAI-compatible · gateway", with a generic logo), and shows one
  named after a built-in service as that service's entry.
- A typed key stays in the field when the server refuses it; it is
  cleared once saved (Doubao's paired fields too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): provider logos in the model pickers; thinking, keyless services and model discovery on the map

Logos: the home toolbar's model picker shows the provider's logo again, on
the pill and on every group and model, as it did before the settings
restructure; so do the course model map's card picker and its read-only
pill. A provider looks the same everywhere: its plan's or built-in
service's logo (a provider named after one included), its preset's, or
the generic service icon for a custom endpoint, as in Model Services. The
naming and logo helpers move to a data-only module the toolbar can load.

Review fixes:
- The map's card picker sets the thinking settings of a chat slot's own
  model (the main model and each stage), against the view it shows; locked
  slots have no picker.
- A service that needs no key (the browser's own speech) is offered in a
  media slot's picker, and the text-to-speech panel can make it the
  narration: the provider is added, then the slot assigned against the
  view the add answered.
- Fetching a provider's models reports them as added only once the server
  saved the list; a refused or lost write leaves a failure to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): removing a provider's key frees the slots that needed it

Removing the stored key of a workspace provider left the slots assigned to
it pointing at a provider that can no longer be called, so the default
model (or a stage) failed at generation time while the settings still
showed it in use. Removing the key now drops those assignments, as
removing the provider does, and the slots follow their parents again. Only
the capabilities whose provider needs a key are affected: a local model
server or a keyless search keeps what it serves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): connecting a token plan fills an empty web search slot

A token plan's web search has no models to pick, so the plan's preset
recommended nothing for the webSearch slot and connecting a plan (TokenDance,
MiniMax) left search unassigned, where the browser-side Token Plan used to
select the plan's search. The preset now recommends the plan's search for
it, and the first-run fill assigns the provider by itself to the slot when
it is still empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): show each TTS service's full request URL

The server-backed TTS panel showed only the preset base URL as the request
URL. Append the path each built-in service calls, as main did, so Gemini
shows /interactions and Doubao /unidirectional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* ci: stop triggering on integration/provider-config before it merges to main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): cache provider logos so switching service tabs does not blank them (#1777)

Next serves public/ files with `Cache-Control: public, max-age=0`, so each
logo a newly mounted list draws is revalidated before it paints. Switching
tabs in Model Services mounts a fresh list, and its logos stayed blank until
every revalidation came back. Cache /logos/* for a day with a week of
stale-while-revalidate; the files are not content-hashed, so not immutable.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): hide the Skills section when the agent runtime is unavailable (#1778)

Without the agent runtime, GET /api/agent/skills returns 404 by design, so
the Settings Skills section could only ever show a load error with a retry
that cannot succeed.

Probe GET /api/agent/runtime once per tab and list the Skills item only when
it reports `enabled` (the flag plus DATABASE_URL, the same check the skills
route gates on). The item stays hidden while the answer is unknown, and a
request to open the dialog on `skills` without the runtime lands on the first
section.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): show the selected extractor's formats in the material upload hint (#1779)

The attach popover's dropzone hint claimed documents, slides, spreadsheets
and images for every extractor, so with unpdf (PDF only, plus the built-in
plain-text extractor) users could pick a .docx via "All Files" and only then
hit a generic "unsupported" error.

- The hint now lists exactly the formats the active extractors accept
  (getFormatLabelsForProviders), with the per-file size limit.
- The unsupported-file error names the extractor and its supported formats;
  it covers the file picker, drag-and-drop onto the dropzone, and the
  cleanup that drops attached files after switching extractors.
- Format labels are file-type names shared by all locales; the list
  separator is localized. Strings updated in all 12 locales.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): connecting a token plan applies its recommended configuration (#1780)

Since model configuration moved to the server, connecting a token plan only
filled the slots that were still empty, so a workspace that had already
picked models never switched to the plan's setup. The browser-side Token Plan
used to make the plan's default model, its recommended course stages and its
image, video, speech and search services the active selection.

Connecting a plan (or saving a new key for a connected one) now applies the
plan's recommendation as one slots change: the default model, each course
stage the plan names, and its media and search services. When that would
replace assignments the workspace made itself, the panel asks first: use the
plan's recommended setup, or keep the current one and fill only the empty
slots. Slots the deployment locks are never offered or changed, and a plan
yields the slots a connected plan of higher priority recommends, whatever
the connect order. A replaced language-model assignment keeps its fallback;
thinking settings go with the model they were set for. Disconnecting still
removes the provider, which frees the slots that named it.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(home): the toolbar picker sets the default model and shows stages set separately (#1781)

* feat(home): label the toolbar model as the default and show stages set separately

The home toolbar picker only sets the `llm` root, but read as if it chose
the model for everything. It now says what it changes:

- the pill reads "Default · <model>" (the prefix is hidden on phones);
- a hint after it counts the stages under `llm` whose own setting
  resolves to something other than the default (inheriting or matching
  stages do not count; deployment-set stages do), lists them in a tooltip
  with the map's stage names, and opens Course Model Config on a click;
- the dropdown says picking here leaves stages set separately alone;
- when every stage a course is always generated with (outline, each page
  type's content, actions) has its own setting, the pill summarises the
  per-stage setup (naming the Token Plan when one plan provider serves
  them all) and opens Course Model Config instead of offering a switch.

Picking a model still changes only `llm`; the locked and first-run states
are unchanged. New strings are added to all 12 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(logos): size the DeepSeek logo to its own view box

The SVG declared width 182 and height 29 around a 34x29 view box, so any
square icon slot scaled it as a wide strip and the whale drew as a dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent): don't inherit a thinking effort the agent can't use; recover sessions after a failed first run (#1782)

* fix(agent-runtime): drop the thinking effort the agent slot inherits

The agent slot follows llm, so a thinking level picked for the default
model (the home toolbar writes it on llm) reached the agent driver, which
refuses any thinking effort because its tool calls cannot carry one. Every
agent run failed for a user who had picked a thinking level.

The agent slot now declares that it carries no thinking effort:

- Resolution drops an effort the agent inherits from an ancestor and keeps
  the rest of the thinking settings; an inherited effort of none stays
  "thinking off".
- An effort set on the agent slot itself is refused when it is saved:
  openmaic.yml at load and a workspace change through the settings API.
  The driver's own check stays as a backstop for settings stored earlier.
- The model map's thinking control for the Agent card offers no effort
  levels: an effort model that can be switched off shows on/off, and one
  that cannot shows nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(agent-runtime): keep a session usable after a run that failed while starting

A run that fails before it completes any message (for example, while
resolving its model) writes nothing to the entry tree, but its lifecycle
frames are in the event log. The runner treated any lifecycle frame as proof
that the tree should hold history, so every later run of that conversation
failed with "tree is empty after a prior run".

The empty-tree check now asks the event log whether a prior run completed a
message. The runner appends every completed message to the tree right after
its message_end event, so an empty tree is refused only when a message_end
exists: the tree lost history it held. An empty tree after runs that never
completed anything is legal, and the next run starts the conversation over:

- it resumes the conversation instead of opening it again, so the opening
  prompt is not painted twice;
- a durable message is taken as the session's opening message only when it
  was posted before the first run; a message posted after a failed start is
  delivered as a follow-up, not consumed in place of the session's prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): workspace-provider policy covers request headers; startup secret warnings; upgrade notes (#1783)

* fix(model-config): ignore request-named providers when workspace providers are off

With `policy.allowWorkspaceProviders: false`, users may only use the
providers openmaic.yml declares. The deprecated request paths still let a
request run on its own model, key and endpoint while a slot was unassigned.

Under that policy the language model and media resolvers now skip the
provider a request names, document extraction keeps only a self-contained
extractor from the request fields, and the header form of the provider
test routes (verify-model, verify-image-provider, verify-video-provider)
is refused. Behaviour is unchanged when the policy is true or unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(model-config): pin the media defaults an upgrade keeps

Before the server-side settings, the browser switched image, video and
narration on at its first sync with the server whenever the server had a
provider for them, and the classroom chat and the agent searched the web
whenever a search provider was configured. The translated legacy defaults
keep those capabilities on, unlocked, so a workspace can switch each off;
an explicit openmaic.yml assignment stays as written and locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(secrets): warn at startup when stored keys will not open

Keys saved in the model settings are sealed under OPENMAIC_SECRET_KEY, or
under a secret generated in data/instance-secret.key when it is unset. On
a host whose data directory does not survive a restart, or with several
replicas, a new secret is generated and the stored keys stop opening; on
a read-only data directory no secret can be created and saving a key fails.

At boot, with one query in the background, the server now warns when:
- OPENMAIC_SECRET_KEY is unset and the data directory cannot hold a new
  secret file;
- the secret file is missing (so a new one would be generated) while the
  database holds keys sealed under an earlier secret;
- stored keys were sealed under a different secret than the current one.

Each warning says the keys have to be entered again and recommends
setting OPENMAIC_SECRET_KEY. The server still starts in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs: upgrade notes for server-side model configuration

Add breaking changes and upgrade notes to CHANGELOG.md for the move of
model configuration to the server: openmaic.yml and capability slots,
MODEL_ROUTES refusing to start without openmaic.yml (with the
route-to-slot mapping, including maic-agent-driver -> agent), the
narrowed /api/generate-classroom body, deprecated request fields and the
workspace-provider policy, the media capability defaults after an
upgrade, the one-time browser settings import and what it cannot carry,
and OPENMAIC_SECRET_KEY persistence across restarts and replicas.

The configuration and deployment docs (all languages) and .env.example
now cover replicas, ephemeral and read-only data directories, the new
startup warnings, and request fields being ignored under the policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): keep settings the server could not take; Azure regional endpoints; keyless compatible providers (#1784)

* fix(model-config): keep what the browser import could not move, and accept Azure Speech regional endpoints

The one-time import of browser model settings deleted the staged proposal,
keys included, on any 2xx answer, even when the server skipped items. The
version 5 store migration had already dropped the originals, so a skipped
provider lost its key everywhere. An Azure TTS/STT user with a regional
endpoint lost theirs this way: the server refused the endpoint as a custom
media endpoint.

Import:
- Only what the answer shows the workspace holding leaves the browser.
  Every other item (skipped as invalid, an id the deployment declares, or
  unconfirmed because the answer could not be read) is kept in the browser
  as staged, with its keys, under its own key. Skips that leave nothing
  behind (EXISTS, a locked slot) are not kept. If the items cannot be kept,
  the proposal stays for a later load.
- What the builder cannot propose is kept the same way at migration time:
  custom speech/transcription providers, AliDocMind's key pair, custom chat
  providers without an endpoint or of an unsupported type.
- Kept items are never sent again and do not re-trigger the import. A toast
  tells the user once; Settings -> Model Services lists them with the reason
  and a copy-key button until they are discarded. Setting one up again (a
  new workspace provider of its preset, or the slot) clears it. Clearing
  the local cache keeps them.
- The import answer now carries a `code` per skipped item, and a provider
  id the deployment declares is reported as PROVIDER_RESERVED.

Azure Speech:
- Workspace azure-tts / azure-asr providers take their official regional
  endpoint (https://<region>.tts.speech.microsoft.com,
  https://<region>.api.cognitive.microsoft.com or .stt.speech.microsoft.com),
  region [a-z0-9]+, https only, no userinfo, port, path, query or fragment.
  It is checked at save time and at resolution (resolve-slot marks anything
  else as a custom endpoint, which media resolution refuses), and stored
  normalised. Their registry default only names the region as a placeholder,
  so they now require it. The TTS/ASR panels get a regional endpoint field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(model-config): let a keyless OpenAI-compatible provider run

A self-hosted OpenAI-compatible server (Ollama, vLLM) declared in
openmaic.yml as `preset: openai-compatible` without `apiKey` resolved, but
building its model threw "API key required for provider: openai": the
preset rides on the OpenAI registry entry, which requires a key.

- The openai-compatible preset marks its key optional, and the slot model
  passes that to getModel (a new `requiresApiKey` override on ModelConfig).
  Every other preset keeps the registry's rule, so OpenAI itself still
  needs a key and fails with the same clear error.
- A request without a key no longer carries an empty `Authorization:
  Bearer ` header; with a key it is sent as before.

On main, a custom OpenAI-compatible provider sent from the browser was
built the same way (providerType openai, registry default requiresApiKey
true) and also needed a key on the server; keyless worked only for registry
entries marked keyless (Ollama, Lemonade). This lets the documented keyless
openmaic.yml provider work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(model-config): carry narration, image and video switched off over to the workspace

The browser import carried a capability's "on" choice but not an explicit
"off" for narration, images and video. A user who had switched one off got
it back on after upgrading, because the deployment's defaults now turn
those capabilities on.

When the old store has `ttsEnabled`, `imageGenerationEnabled` or
`videoGenerationEnabled` stored as false while a usable provider for the
capability was there, the import proposes `tts: null` / `image: null` /
`video: null`, as `asrEnabled: false` already becomes `asr: null`.

Earlier builds defaulted these switches to off and turned them on by
themselves once a provider was usable (a server provider on the first
load, a key the user entered), and off when none was. So `false` with a
usable provider (server-configured and not switched off by the operator,
or with the user's own key; browser speech synthesis does not count) is the
user's choice, and `false` without one is only the default, which is not
carried over. A server that gained a provider after the browser's first
load cannot be told apart and is read as off: that is visible in the
settings and costs nothing, while reading it as on could start paid
generation the user refused.

Web search off is not carried over: it only stopped course research, while
chat and the agent kept searching through the same provider. A slot the
deployment locks is skipped like any other import (SLOT_LOCKED).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): drop a browser-kept key only when the server holds the same one (#1785)

* fix(import): drop a browser-kept setting only when the server holds the same one

The one-time import of browser model settings could still lose a key:

- A provider id the workspace already had was answered `EXISTS`, and the
  browser deleted its copy, even when the workspace held another key. The
  server now compares the proposed provider with the stored one (preset,
  endpoint in its normalised form, models, and the key, decrypted and
  compared in constant time) and answers `EXISTS_SAME` or
  `EXISTS_DIFFERENT`. Only `EXISTS_SAME` lets the browser drop its copy; a
  key this instance cannot open is never confirmed. Nothing else about the
  stored provider is returned.
- Provider ids and slot ids shared one namespace in the answer, so a slot
  `tts` that was imported confirmed a refused provider `tts` and its key
  was deleted. The answer now names each item's kind
  (`{ kind: 'provider' | 'slot', id }`), and kept items are keyed by kind
  and id.
- The notice in Settings cleared a kept provider, key included, as soon as
  any new workspace provider of its preset appeared, even one without a
  key. A kept key now leaves only when such a provider holds it as far as
  the view shows (key set, readable, same mask); otherwise it stays, with
  its copy button, until the user discards it. Key pairs and keys too short
  to show in a mask are never cleared this way. The key mask moves to a
  module the server and the browser share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(changelog): the narration, image and video off switches are carried over

The changelog still said the image, video and narration off switches were
not carried over. They become `tts`/`image`/`video: null` when a usable
provider was set up, as the configuration docs and the import README
already say; only the research switch, and a switch off only by default,
are not carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(import): never clear a kept key by itself

A kept provider that holds a key (or a key pair) used to leave the
browser once a new workspace provider of its preset held a key whose mask
matched. A last-four-characters match does not confirm the workspace
holds that key, so such an item now stays, with its copy button, until the
user discards it. Kept items without a key still leave by themselves once
the view shows them set up again. The key mask goes back to the server
module, its only user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): keep browser settings on a refused or self-contradicting answer (#1787)

Two more ways the one-time import of browser model settings could lose a
key:

- A 400 removed the proposal outright. The server refuses the whole
  proposal then (an unexpected top-level field is enough), so sending it
  again cannot succeed, but its keys existed nowhere else. Every item is
  now kept in the browser first, keys included, as refused with the
  server's message, and only then is the proposal removed; if they cannot
  be kept, the proposal stays. Kept items are never sent again. 401, 404,
  409, 5xx and network failures still keep the proposal for a later load.
- An answer that listed an item as both imported and skipped, or skipped
  it with different codes, let `imported` (or the last code) win, which
  could delete a key the server did not hold. Such an item is now kept as
  unconfirmed.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:12:43 +08:00
wyucandClaude Opus 5.5 4320f986cf ci: list the live integration branches in the CI triggers (#1724)
* ci: stop triggering on the landed persistence integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* ci: list the live integration branches in the CI triggers

Add integration/provider-config and integration/material-library to the
push and pull_request branch lists, and drop the feat/maic-editor-v0 and
feat/maic-editor-v1 entries, which no longer exist on the remote. Branches
cut from main now inherit triggers for every open integration branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: leave integration/provider-config to its own branch

It merges into main next, so main need not trigger on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:48:35 +08:00
fa47efe1b3 feat(persistence)!: server-backed persistence only, with a one-way legacy browser import (#1710)
* ci: run CI for the persistence-default integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(docker): start PostgreSQL by default and add a single-user owner

* feat(docker): start PostgreSQL by default and add a single-user owner

`docker compose up` now runs a server-backed app against a bundled
PostgreSQL, started only once PostgreSQL is healthy and published on
127.0.0.1 only, with a new built-in singleUser owner auth method: every
request resolves to one fixed owner that may publish, and no anonymous
cookie is minted.

Single-user mode (OWNER_SINGLE_USER=true, OWNER_SINGLE_USER_ID default
"local") runs with or without ACCESS_CODE. Without one, the server logs
one prominent startup warning that anyone who can reach it shares, edits
and can delete the single library; it never inspects request peers or
forwarding headers. It excludes PERSISTENCE_SHARED_OWNER_ID and follows
the sharedTeam registration rules. Its principal gets a claim candidate,
so a browser's earlier anonymous work is claimed into the single owner
(OWNER_CLAIM_TRIGGER=auto in the Compose defaults).

Compose defaults live in docker-compose.defaults.env, read before
.env.local so they can be overridden. The app warns when its declared
published address (OPENMAIC_PUBLISH_ADDRESS, passed by Compose) is not
loopback while PostgreSQL uses the default password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(docker): keep claims explicit and let .env.local set DATABASE_URL

- Compose no longer defaults OWNER_CLAIM_TRIGGER to auto: an irreversible
  merge of every browser's anonymous library into the single owner must
  not happen on upgrade. The single-user principal still gets a claim
  candidate, so POST /api/identity/claim works; the docs explain it and
  warn about auto on a deployment several people used.
- The bundled DATABASE_URL moves to docker-compose.defaults.env, read
  before .env.local, so an external database or a rotated password set
  there keeps working. The password is inserted unencoded, so it must be
  letters and digits (documented).
- Upgrade docs no longer claim browser-stored courses are copied as they
  are opened: they stay in the browser, are not deleted, and are moved by
  the automatic browser-to-server migration shipped with this work.
- Single-user mode without ACCESS_CODE now logs one warning instead of
  two; the docs note that the single owner id is permanent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence)!: server-backed persistence is the only persistence (#1707)

* feat(persistence)!: make server-backed persistence the only persistence

Courses, folders and folder membership, chat history and learner runtime,
and generated media are now always stored on the server through the
embedded /api/persistence endpoint. The browser keeps no durable data of
its own.

- Remove the build-time NEXT_PUBLIC_PERSISTENCE switch: the client
  bootstrap always configures the HTTP document, runtime and asset seams,
  and the Dockerfile, docker-compose.yml, the Pi route and the whiteboard
  runtime no longer read it. Unconfigured seams refuse to resolve instead
  of falling back to IndexedDB, and the learner key is always the
  server-derived one.
- Remove the browser write paths: the browser document, runtime and asset
  stores as backends, the browser-mode branches of stage, folder, chat,
  media, narration and import storage, the Dexie backup export/import and
  the whole-database clear. The library lists through /api/stages, folders
  through /api/folders, and membership now goes through
  /api/folders/members. Regeneration always forks to a fresh asset.
- Device-local state moves to its own IndexedDB database
  (lib/device-storage, maic-device-cache): the local media and narration
  cache and refused-bytes retention, staged PDF images, undo history,
  browser voice profiles (carried over once from the old database) and
  the auto-voice cache. "Clear Local Cache" clears exactly that and no
  longer claims to delete classrooms or chat history.
- Keep the pre-server data readable for the one-way importer: the Dexie
  schema and read-only accessors for its tables and for the browser
  document, runtime and asset databases live in lib/legacy-browser-storage,
  which nothing on the regular paths reads and which has no write path.
- Require a database: the server exits at boot without DATABASE_URL, with
  a message naming `pnpm db:up` and `docker compose up`. `pnpm db:up` /
  `pnpm db:down` start and stop the Compose postgres service alone,
  published on 127.0.0.1 through docker-compose.db.yml; .env.example
  carries the matching local DATABASE_URL.
- E2E specs seed their courses through the persistence endpoint, each
  under a fresh id, instead of writing IndexedDB.
- Guard tests pin the boot requirement, the absence of any
  NEXT_PUBLIC_PERSISTENCE read, and the legacy-storage boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run the E2E suite against PostgreSQL

The app refuses to start without DATABASE_URL, so the E2E job gets a
postgres:16 service and a job-level DATABASE_URL. The unit, storage and
render-service jobs are unchanged and need no database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: document always-on server persistence and the database requirement

README (English and Chinese), the deployment guide in every locale, the
extension cookbook and the changelog now describe PostgreSQL as required:
`pnpm db:up` for local development, `docker compose up` for a deployment,
and an external database for Vercel and other serverless hosts (the
Deploy button prompts for DATABASE_URL). They list what stays in the
browser, drop the NEXT_PUBLIC_PERSISTENCE instructions, and say that
courses an earlier browser-only build stored stay in the browser until the
one-way importer moves them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pbl): use the server-derived learner key for PBL v2 runtime

PBL v2 synchronization, hydration and drain resolved the learner key from
their default watermark KV store, which reads or mints a device key in
localStorage instead of asking runtime configuration. The runtime store is
the server one, which refuses any key but the owner's, so saving a course
with a PBL v2 scene failed before the document write, and the minted key
landed in the slot the one-way importer reads as the pre-server runtime
partition.

The learner key now comes from getLearnerKey(args.kv): the configured
server-derived key, or an explicitly injected store. The default KV store
still holds the drain watermarks and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(persistence): pin that clearing the cache keeps legacy databases

- Seed the four pre-server databases (MAIC-Database, maic-documents,
  maic-runtime, maic-asset-pool) with real rows, run Clear Local Cache, and
  assert every database and row survives; assert the device database name
  differs from every legacy name.
- The legacy-module import rule now also catches relative static,
  side-effect, dynamic and require imports at any depth.
- New rule: the runtime learner key is never read from a KV store the
  caller made up, only from configuration or an injected store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(dev-db): run the development database as its own Compose project

`pnpm db:up` / `db:down` shared the checkout's default Compose project with
`docker compose up`, so they recreated or stopped a running stack's
database. docker-compose.db.yml now declares its own project
(`openmaic-dev-db`), which gives the development database its own
container and data volume; it no longer touches the stack, and the two do
not share data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: start the development database before `pnpm dev`

CONTRIBUTING, the getting-started guide in every locale and the startup
modes reference now run `pnpm db:up` and set DATABASE_URL before
`pnpm dev`. README, the deployment guides, the cookbook and the changelog
describe `pnpm db:up` as a separate development database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(persistence): sharpen the legacy-storage and learner-key guards

- The Clear Local Cache test closes its seeding connections to
  maic-documents and maic-runtime, so a regression deleting either fails
  on the assertion that names the database instead of by timeout.
- The legacy-module and Dexie import rules accept comments inside
  import() / require(), so a webpackIgnore hint no longer hides one.
- The learner-key rule also flags an injected-looking name that is bound
  to a default local store in the same file; its remaining limit is
  documented, with the behavioural PBL test as the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(dev-db): pin the development database's Compose project

`pnpm db:up` / `db:down` now pass `-p openmaic-dev-db`, which takes
precedence over COMPOSE_PROJECT_NAME from the shell or a .env file; the
file's `name:` alone could be overridden and point the scripts at a
running stack's database. The compose test pins the flag. The docs (all
locales) and the file header say the development database is shared by
every checkout on the machine, and how to run a separate one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): import legacy browser data into the server, once (#1708)

* refactor(persistence): prepare the seams the legacy browser importer uses

- Legacy module: read-only accessors for the auto-voice cache, the course
  ids the old media and narration tables name, the pre-server learner key,
  and asset bytes from the browser asset pool.
- Narration adoption exports its row ownership rule so the importer applies
  the same one to rows of the old database.
- The quiz legacy migration is split into a non-deleting core
  (importLegacyQuizSnapshot) and the regular path, which still deletes the
  keys it migrated.
- The lazy document migration exports its snapshot canonicalizer.
- Clear Local Cache keeps the importer's per-owner ledgers, as it keeps the
  pre-server learner key.
- A library-changed window event lets an open library list courses that
  arrive in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* feat(persistence): import legacy browser data into the server, once

On the first load after the upgrade, the client moves what earlier builds
kept in this browser to the server, for the owner the server resolves:
courses from the browser document store and the original tables (with
chat, learner runtime, playback position, roster, folders and membership,
pre-runtime quiz state), their media bytes through commitToPool and the
existing write-back funnels, the bytes of server courses from earlier
opt-in server builds that only the old tables hold, and device-only rows
(failure records, refused bytes, auto-voice clips) into the device cache.

It is automatic and silent, runs after the page is idle, never writes to a
legacy store, and records every step in a per-owner ledger so it resumes
after a crash and never imports twice. Runs are serialized across tabs
with Web Locks. A course the owner already has stays authoritative; one
whose id another owner holds is imported under a derived fresh id; one the
owner deleted on the server stays deleted. Transient failures retry on a
later load with backoff; permanent ones are recorded per item.

The module is temporary and self-contained; its README lists the removal
steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): pin that each owner keeps its own import ledger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): run the legacy import against the app routes and a browser

A PostgreSQL suite routes every request the importer and the app seams
send to the real persistence, library and folder handlers, owner resolved
from the anonymous cookie: a full import, a course another owner holds, a
course the owner deleted, and an existing course whose media is filled in.
An end-to-end spec seeds an old pre-server database in Chromium, loads the
app, and checks that the course reaches the library with its narration
uploaded, stays in the browser untouched, and is visible from a fresh
browser context with the same owner cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: describe the one-way import of legacy browser data

The README (and its Chinese version), the deployment guide in every locale
and the changelog now say what the importer does instead of announcing it:
courses an earlier browser-only build kept in the browser move to the
server automatically on the first load after the upgrade, and the browser
copy stays untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): probe server assets through the shared asset-URL owner

The importer asked the pool directly whether an id exists, which the
asset-URL ownership boundary forbids outside use-asset-url. It now uses
assetRefExists, the existing metadata-only probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): guard the importer's pool probe like every other entry point

Only a reference the pool could have issued is probed, as the lease guard
requires; the importer is added to the list of guarded entry points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): import legacy browser data once per browser

Review round 1 of the legacy browser importer.

- Once per browser, not once per owner: the data belongs to whoever used
  the browser before the upgrade, so the first owner the import runs for
  claims it and any other owner gets nothing. The ledger is one key,
  maic:legacy-import:v2, recording the owner only as a SHA-256 digest
  (an anonymous owner id is a bearer credential) and a random salt.
- Handoff: when the claiming owner is retired (403 OWNER_RETIRED), or the
  current owner holds courses or folders the importer created (a claim
  moved them), the current owner continues the unfinished items; courses
  already done are never imported again, so a claim neither duplicates nor
  resurrects them, and a half-copied course keeps its runtime.
- Fresh ids come from the salt and the legacy id through SHA-256, not from
  the owner: unlinkable, unpredictable, and the same in every tab.
- 401 pauses with backoff instead of closing the import; 403
  FORBIDDEN_LEARNER stops the run and leaves the item pending.
- Without Web Locks: ledger writes merge with the stored copy, and the
  library is listed again right before a course is placed, so a second tab
  does not import a course the first one just created.
- Media the server refuses for good gets the app's failed-media record
  instead of a dangling reference; the legacy bytes stay.
- A legacy whiteboard or PBL session is not created when the server
  already has an active one of that kind.
- A folder that is gone leaves the course unfiled instead of failed, and a
  membership naming a folder the old database lacks counts as unfiled.
- Narration rows from before the course column are adopted when exactly
  one legacy course names their key; unconvertible chat rows are noted; an
  unreadable document-store copy falls back to the original tables; PBL v2
  documents go through the save-path strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover the importer's handoff, tabs, refusals and review gaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: say the legacy import runs once per browser

The README (and its Chinese version), the deployment guide in every locale,
the changelog and the module README now say that the first owner to load
the upgraded app in a browser claims its legacy data, what the handoff to
an account does, that the ledger holds no owner id, and how refused media
and the new pause and skip cases behave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): note runtime the importer cannot find without the old learner key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* feat(identity): let an owner confirm it absorbed a given owner through a claim

GET /api/identity/merged-from?salt=<hex>&digest=<hex> answers whether a
claim merged into the requesting owner an owner whose id hashes to
SHA-256(salt, id). It reads only the requester's own owner_merges rows,
never lists or names an owner, resolves the owner like every other
owner-scoped route, and is uncacheable. The one-way import of pre-server
browser data uses it to decide whether a different owner may continue an
import the first owner started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): hand the legacy import to another owner only on a confirmed claim

Review round 2 of the legacy browser importer.

- The only way the browser's legacy data moves from the owner that
  claimed it to a different owner is the server confirming, through
  GET /api/identity/merged-from, that the current owner absorbed that owner
  through a claim. The inferred signals (an owner that never wrote, a
  library listing that holds imported courses) and the retired flag are
  gone; a 403 OWNER_RETIRED only stops the run and leaves items pending.
- The first owner is bound only once the run's first authenticated request
  of its own (the library listing) succeeded, so a run that dies before
  that leaves the ledger unclaimed.
- The owner is recorded as SHA-256 of the per-browser salt and the owner
  id; the server computes the same value from the salt the browser sends.
- A "not absorbed" answer is reused for an hour instead of asking on every
  load, and the claiming owner stored first survives another tab's save
  unless that tab handed the import over; completion survives too.
- Run-level failures (401, OWNER_RETIRED, FORBIDDEN_LEARNER, OWNER_BUSY)
  from any call stop the run and leave the course pending, including the
  "is the id taken" read and the library re-list, which could previously
  mark the course failed and close the import.
- Refused media without a generation request (the user's own) gets a new
  non-retryable ASSET_REFUSED code, so it shows as failed without a Retry
  that could only fail; a skipped singleton runtime session leaves a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): pin the importer ledger's owner merge and the singleton-skip note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: the legacy import moves to another owner only on a confirmed claim

The README (and its Chinese version), the deployment guide in every locale,
the changelog and the module README now say that the import runs once per
browser and moves to another owner only when that owner claimed the
original one, confirmed by GET /api/identity/merged-from; that the owner
is recorded as a salted digest; and how refused user media shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): never let a transient failure settle a legacy import item

Review round 3 of the legacy browser importer.

- A read of the old browser stores that fails in storage (an aborted
  IndexedDB transaction, a closed database) now pauses the run and leaves
  the course pending. Only a record that cannot be migrated, parsed or
  validated is skipped. This covers the course read (which no longer falls
  back to the older table copy on a storage failure), and the quiz-scene
  and speech indexes, which used to read a failure as "no scenes" or "no
  holder" and drop quiz state or narration for good.
- After a session-id collision, a failed read of the session propagates
  and is retried; only a successful read that shows the id taken skips it.
- Without Web Locks, two tabs of different owners can both find the
  ledger unbound. Every ledger write keeps the owner stored first, and the
  run now re-reads the stored ledger after binding, before folders, before
  each course and before each course document is created, and stops if
  another owner holds it.
- The "not yours" answer is reused for ten minutes instead of an hour, a
  stored wait further ahead than the longest the importer sets (a clock
  that ran ahead) counts as passed, and expired answers are pruned.
- Generated media refused without an error code keeps its Retry; only
  media nothing can regenerate gets ASSET_REFUSED. A poster that fails
  transiently leaves the video pending instead of being dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover transient reads, racing owners, the not-yours cache and the claim client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(persistence): say what the ledger's salt protects, and the new pauses

The salt defeats precomputed and cross-browser tables, not a targeted
guess against one browser's ledger; the module README and the digest's
comment now say so. The README also covers storage read failures, which
pause instead of skipping, the two-owner race without Web Locks, and the
ten-minute recheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* feat(identity): bind a browser's legacy data to one owner on the server

The one-way import of pre-server browser data now asks the server whose
data it is, instead of deciding in the browser.

- legacy_import_bindings (browser_id primary key, owner_id, timestamps),
  provisioned with owner_merges on fresh and upgraded databases.
- POST /api/identity/legacy-import-binding {browserId}: one atomic insert
  (ON CONFLICT DO NOTHING) and a read-back; answers only whether the
  requesting owner holds the browser. Same-origin JSON, resolved and
  write-fenced like every owner write, 400 for a malformed id.
- A claim participant re-keys the claimed owner's bindings to the account
  inside the claim transaction, so the account simply holds the browser.
- Owner resolution refuses any request carrying X-OpenMAIC-Legacy-Import
  with 409 LEGACY_IMPORT_NOT_BOUND unless the owner it resolves to holds
  that browser: one central fence every owner-scoped route goes through.
  Requests without the header are unaffected.
- GET /api/identity/merged-from is removed; the binding replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): import through fenced clients bound by the server

Review round 4 of the legacy browser importer.

- The ledger holds a random browser id and no owner information; the
  owner digest, the "not yours" cache and the client-side handoff are
  gone. A run first asks the server to bind the browser and imports only
  when the requesting owner holds it.
- Every importer request goes through its own clients that carry the
  browser id, so the server refuses any write under an owner that does not
  hold the browser: after a cookie switch in another tab, from a page whose
  memoized owner is stale, or from a tab that lost the binding race. The
  runtime learner key is asked for during each run, never taken from the
  page. 409 LEGACY_IMPORT_NOT_BOUND stops the run with items pending.
- The write-back funnels, commitToPool and the PBL save-path strip take an
  optional store, put or runtime, so the importer passes its fenced ones.
- A failed read of one legacy course holds up only what it could affect:
  the quiz-scene and speech indexes leave the course out as unindexed, and
  only quiz state or narration it could own waits. Read failures are
  counted per course; after five failing runs over at least a day, a
  course whose document-store copy cannot be read falls back to the table
  copy, or is skipped with the reason when there is none.
- Runtime ids are rewritten by replacing only their course segment, so a
  short course id cannot corrupt the prefix or the learner segment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover the server binding, its fence, bounded reads and short ids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: the server binds a browser's legacy data to its first owner

The README (and its Chinese version), the deployment guide in every locale,
the changelog and the module README now say: once per browser; the server
binds the browser to the first owner; a claim carries the binding to the
account; the importer's requests are refused for any other owner. They
also describe the bounded pause for a course the old storage cannot read,
and the removal steps for the server side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): keep a dropped asset request pending in the legacy import

The asset client reports a fetch that got no answer (a dropped request, an
aborted upload, the existence probe's deadline) as status 0 with
HTTP_REQUEST_FAILED, which the importer read as a final refusal: the media
was marked failed and the course could complete without it. Read that
code as transient, keep the client's local validation failures final, and
treat a 2xx/3xx answer a client could not use as transient too. Unit tests
drive every client the importer uses with a rejected fetch and a non-JSON
answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): ask a refused binding again only every ten minutes

A browser whose legacy data another owner holds sent the binding request
(a write) on every page load. Record a ten-minute recheck in the ledger
(clamped like every other deferral), so a claim that moves the binding is
still picked up, and open the old databases only once the browser is
bound. The read budget now counts consecutive failing runs (a run that
reads the course resets it) and counts a first failure dated in the
future from the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): answer 503 with the cookie when the import fence cannot read

The legacy import fence now reads the header name from the shared
constant, and a database error while reading the binding answers the
usual JSON error (503 PERSISTENCE_UNAVAILABLE) with the resolution's
Set-Cookie values instead of escaping as a bare 500. Route tests cover
that, a bind by an owner a claim retired (403 OWNER_RETIRED, no row), and
one header name on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover dropped asset requests, the recheck and the read budget

With the real asset client's error for an upload and an existence probe,
the media stays pending and a later run imports it. A non-holder's five
loads send one binding request and never open the old databases, and a
claim still hands the import to the account after the delay. The read
budget's run count and time span each hold on their own, a clock that ran
ahead does not hold a course open, and a good read resets the count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(persistence): complete the legacy import removal steps

List every file the removal touches (the claim scenarios test, the claim
participant table and list, every doc), keep the bindings table for one
release after the code goes because of rolling deploys and give the DROP
statement for that release. Add the claim's eighth participant to the
README lists, fix the fresh-id wording in the changelog, and describe the
recheck delay, the transient status-0 asset failures and the consecutive
read budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): keep legacy quiz state through Clear Local Cache until the import completes

Pre-runtime quiz drafts, answers, results and attempt ids exist only in
localStorage, and the one-way importer copies them to the server with their
course. Clear Local Cache deleted them even while the import was still
pending (it waits for an idle page and can stay pending after a failure), so
that quiz progress was lost for good.

Clear Local Cache now keeps the four quiz key families unless the importer's
ledger records the import as complete, read through a new
`legacyImportIsComplete` helper in the ledger module. The ledger key moves
there too, so the two modules do not import each other. Once the import is
complete, the keys are cleared as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: show DATABASE_URL set in the local development setup

The getting-started guide showed the required DATABASE_URL line commented
out, so copying the block left the server unable to start. Each locale now
shows `pnpm db:up` and, separately, the `.env.local` line to set.

The `.env.example` header said every variable is optional; it now says that
provider variables are, and that DATABASE_URL is required except under
docker-compose.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(changelog): drop two upgrade notes the final code contradicts

The Compose entry said its default DATABASE_URL overrides .env.local; the
defaults file is read first, so a value in .env.local wins, as the same entry
says further on. The runtime-session entry offered turning server
persistence off as an upgrade path; that switch no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(env): restore the ACCESS_CODE warning notes in .env.example

A later change to the example file dropped the lines saying that an unset
ACCESS_CODE fails open, that the server then logs a one-time startup warning
and GET /api/health reports accessCodeConfigured: false. They are back, and
say that single-user mode logs its own warning in place of the generic one,
as the startup code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* ci: run every app PostgreSQL suite in the contract job

The app-domain step named its suites one by one, and seven had been left
off, among them the legacy importer's route suite. No other job gives the
app suites a database, so they skipped everywhere. The step now lists every
app `*.pg.test.ts`; all of them work in a schema or database of their own,
or truncate only their own tables, and pass before and after the storage
package suite in the same database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): establish the anonymous owner before the first API request (#1721)

* fix(identity): establish the anonymous owner on the page response

On a browser's first load the page sent several API requests without an
owner cookie; each minted its own anonymous owner and set its own cookie,
and the last answer to arrive won. Work done in between (the legacy import
binding, a first course write) then belonged to an owner the browser no
longer presented.

The middleware now mints the anonymous cookie on document navigations that
carry no valid one, with the route handlers' own value format and
attributes (the cookie module is Edge-safe now: Web Crypto instead of
node:crypto), and forwards it on the request so the page render resolves
the same owner. API, RSC, prefetch and Server Action requests never mint
there, and a valid cookie is never replaced. It mints only when neither
OWNER_SINGLE_USER nor PERSISTENCE_SHARED_OWNER_ID is set; host
registrations are invisible to an Edge middleware, so the README tells
hosts where to skip it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(legacy-import): bind only an owner the browser already presents

A binding request whose owner was minted by that very request answers
409 OWNER_NOT_ESTABLISHED with the minted cookie and binds nothing: other
cookieless requests may still be minting owners of their own, and a
binding to this one could be left with an owner nobody presents. The
importer treats it as transient and retries on a later load. It also says
plainly when a request after its own successful bind is refused as
another owner (the owner cookie changed mid-run); items stay pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(legacy-import): cover a first visit whose first answers arrive out of order

Against the real routes and PostgreSQL: a browser without a cookie loads a
page, sends two requests at once, and the first answer reaches it only
after the importer bound the browser; the import completes under the one
owner the page established. A second case binds nothing for an owner the
bind itself minted and imports on the next run. The E2E drives the same
ordering in Chromium by holding the first owner-scoped answer until the
binding answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(legacy-import): drop an unused initial assignment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(changelog): note the page response now sets the anonymous owner cookie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): let hosts turn page-response minting off

The middleware cannot see owner auth methods a host registers, so a host
with anonymousFallback: false still got an anonymous cookie on a cookieless
page load. Beside the host credential it is a claim candidate; with
OWNER_CLAIM_TRIGGER=auto it is claimed and cleared on the next request,
again after every such page load.

OWNER_ANONYMOUS_PREMINT (default on; true/1/false/0, anything else fails
startup) is read by the middleware before minting. At startup the server
warns once when a registration turns the anonymous fallback off while
pre-minting is still on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(identity): document OWNER_ANONYMOUS_PREMINT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): keep the anonymous identity for 400 days and renew it in use

The anonymous cookie had a fixed 30-day lifetime from its mint and was
never renewed, so every anonymous visitor lost their whole library 30 days
after the first visit however active they were; with persistence on the
server only, that affects every anonymous deployment.

It now lasts 400 days (the browser cap, one shared constant), and every
route handler and Server Action response that resolves to a valid
anonymous cookie re-sends the same value with a fresh Max-Age (best-effort
where next/headers refuses writes). Page responses never renew, so pages
stay cacheable, and a host principal never renews the anonymous cookie
beside it.

A response that clears the cookie (a claim, a retired owner) must not
renew it, whatever order a route merged its headers in:
withRequestOwner, and the owner-events retired answer, keep only the
clearing value for a cookie a value clears. A renewal that lands after a
claim cleared the cookie is pinned by a test: the next write with it,
alone or beside the account, writes nothing, and its answer clears it
again without renewing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(identity): 400-day renewed anonymous cookie; when to turn pre-minting off

Hosts set OWNER_ANONYMOUS_PREMINT=false only when their registration sets
anonymousFallback: false; hosts that keep anonymous visitors need
pre-minting and skip it per request in middleware for requests their
methods authenticate. The CHANGELOG notes the new lifetime and renewal,
and that a lost cookie means a new owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): forward owner cookies from routes that resolve the owner directly

The Pi chat and whiteboard-visibility routes and asset-id document
extraction resolved the owner themselves and never sent its Set-Cookie
values back, so an anonymous identity used only through them was never
renewed (and a minted one never stored).

attachOwnerCookies attaches a resolution's cookies to a response a route
built itself, with the clear-wins rule, before a stream's body starts. Both
Pi routes answer through it, success and error alike; resolveServerAsset
hands the cookies back with every answer and the extraction route attaches
them.

A guard test scans for every module outside the identity seam that calls
the owner resolution (or the asset helper) directly and requires it to
forward the cookies; the two callers that cannot are listed with the
reason. Behaviour tests cover the three paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wyuc <dhq1204@yahoo.com>
2026-09-29 17:51:03 +08:00
wyucandClaude Opus 5.5 a686481e73 feat(auth): owner identity seam — composable auth methods, owner-keyed persistence, host hooks, claims
* ci: run CI for the owner identity seam integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 1 — pluggable authenticator with today's behavior

* feat(auth): add the owner identity seam with built-in authenticators

Introduce lib/server/identity/: an OwnerAuthenticator contract
(SubjectKind, OwnerPrincipal, AuthOutcome), a single-shot, boot-validated
configureOwnerAuthenticator() registry, and the anonymousCookie and
sharedTeam built-ins that reproduce today's owner resolution exactly
(same anonymous_id cookie, owner id strings, statuses and env semantics).

Every owner-scoped route handler and the workspace Server Action now
resolve their owner through one memoized per-request resolution instead
of the three previous copies (agent-runtime/owner.ts, with-owner.ts and
the Server Action re-implementation). Publish and unpublish decide from
the principal's course:publish role instead of an anon: id prefix.

An invalid credential from an authenticator is answered with 401 on
every surface and never falls back to an anonymous owner. A source scan
keeps the anonymous owner cookie and anon: prefix checks out of code
outside the identity module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(auth): describe the owner identity seam and host registration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): tighten the identity boundary guard and Server Action cookies

- Scan workspace package sources too, flag imports of the concrete
  built-in authenticators outside lib/server/identity, and police the
  slice/substring/indexOf/includes spellings of an anon: id-shape check.
  Stop re-exporting the built-ins from the host-facing index.
- Refuse setCookies returned by authenticateFromContext, as the
  authenticate() fallback already did, and document that it must write
  cookies itself through next/headers.
- Let the route-test owner stub carry explicit kind/roles, and cover the
  403 forbidden branch of publish and unpublish for a signed-in owner
  without course:publish.
- README: registration conflicts fail the boot; an invalid principal is
  rejected per request with a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 2 — runtime and assets keyed by the owner

* feat(auth): owner identity seam, phase 2 — runtime and assets keyed by the owner

Runtime sessions and assets on /api/persistence now take their identity
from the same memoized owner resolution as documents, instead of a
client-chosen x-learner-key behind a development bearer token that
shipped in the public bundle.

- Runtime: the storage handler's learner key is principal.ownerId. A
  path or body naming another learner answers 403 FORBIDDEN_LEARNER and
  another learner's session answers 404, whatever x-learner-key says; the
  header is no longer read. The browser learns its key from the new
  GET /api/persistence/learner-key and sends no credential of its own.
  The Pi whiteboard routes derive the same key from the owner.
  PERSISTENCE_DEV_TOKEN, NEXT_PUBLIC_PERSISTENCE_TOKEN and
  PERSISTENCE_ALLOW_INSECURE_DEV_AUTH are gone from the server path and
  the client, and server-auth.ts is deleted. Learner merge stays refused.
- Assets: allocations land in a per-owner partition (owner:<ownerId>),
  so quota is per owner and replace/delete reach only the owner's
  entries. Reads stay capability-by-id where a course viewer needs them:
  another owner's committed entry is readable while a live course
  references it. Legacy entries in the old shared partition stay
  readable by id, and may be replaced or deleted only by an owner who
  owns every course referencing them. Server-generated media is stored
  under the run owner's partition; asset-id extraction and vision image
  resolution use the same rule.
- Tombstones: runtime of a deleted course reads as absent (404, empty
  lists) and takes no new sessions or records, through a guard in the
  app's runtime composition; no package schema change.

The identity boundary scan now also fails on any source that reads
x-learner-key or the retired development token variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(persistence): describe owner-keyed runtime and assets, drop the dev token

Remove the development persistence token from the server-persistence
recipe, .env.example, the Docker build arguments and the deployment
docs; describe runtime learner keys, per-owner asset partitions and
quota, the legacy shared-partition rule and the tombstone behavior; and
record the breaking change for existing server-persistence runtime data
under Unreleased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(storage): scope asset references to principals, typed stage refusal, uniform create collision

@openmaic/storage 0.32.0.

- PgDocumentStore takes assetReferencePrincipals: with reference
  tracking on, a write references and commits only entries held by the
  listed principals. An id naming another principal's entry records no
  row and changes no lifecycle column, like an unknown id, and the write
  is never refused. Without the option any held entry is referenced, as
  before. AssetCollector takes a matching per-document-owner function so
  the one-time backfill scopes each document the same way. This keeps a
  document naming a leaked id from committing, exposing or pinning
  another owner's allocation.
- RuntimeStageNotFoundError: a store may refuse createSession for a
  stage its host considers absent; the runtime handler answers
  404 STAGE_NOT_FOUND, recognizing the error by class or by code.
- A session create over a taken id answers 409 SESSION_ALREADY_EXISTS
  whoever holds it. It used to answer 403 for another learner's session
  and 409 for one's own, which told a caller whose session an id was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): close owner-seam boundary gaps in references, tombstones and legacy assets

- Document writes reference and commit only the writer's own asset
  entries and legacy shared ones (owner-bound document store and the
  collector backfill), so another owner's course naming a pending id
  can no longer commit, expose or pin it.
- The foreign-read rule additionally requires the referencing live
  course to belong to the entry's owner, as defense in depth for
  reference rows that did not come from a scoped write.
- The tombstone refusal for new runtime sessions moved from a raw path
  comparison in the route into the guarded store's createSession
  (RuntimeStageNotFoundError, 404 STAGE_NOT_FOUND), so no encoded
  spelling of the path routes around it.
- Replacing or deleting a legacy shared entry checks reference
  ownership and mutates in one transaction under the entry row lock,
  which a concurrent reference insert must wait for.
- Tests build the uncommitted-but-referenced, tombstoned-with-rows and
  foreign-reference states directly so each half of the read rule is
  observable, and a PostgreSQL suite holds a competing reference open
  across a legacy delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(persistence): upgrade note for dev-token-gated deployments, owner-scoped references

Tell operators whose only access gate was the development token to put
the deployment behind an access code or gateway, register an owner
authenticator, or turn server persistence off before upgrading, and
describe that a course references only its owner's media.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(storage): per-owner reference principals and a typed taken-id conflict

- PgDocumentStore's assetReferencePrincipals is now a function of the
  store's bound owner, evaluated per write, instead of a fixed list. A
  store re-bound with forOwner therefore scopes to the new owner rather
  than carrying the previous owner's principals onto its writes. It has
  the same shape as AssetCollector's backfill option, so one function
  serves both.
- PgRuntimeStore and BrowserRuntimeStore raise RuntimeSessionExistsError
  for a taken session id, and the runtime handler classifies it as
  409 SESSION_ALREADY_EXISTS even when its re-read cannot see the holder
  (for example a session a host-side wrapper hides). Recognized by class
  or by code.

Still the unreleased 0.32.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): guard the Pi whiteboard runtime and answer hidden collisions with 409

- Every server-side runtime writer now uses the tombstone-guarded store:
  the Pi native whiteboard service gets it through the same helper as
  /api/persistence/runtime/*, so a deleted course takes no new
  whiteboard runtime either.
- Creating a session whose id is held by a session a tombstone hides
  answers the uniform 409 SESSION_ALREADY_EXISTS instead of a 500,
  covered on PGlite and on real PostgreSQL.
- The owner-bound document store passes its reference-principal
  function, so the scoping follows the bound owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 3 — accounts through an identity gateway (trustedProxyHeader)

* feat(auth): owner identity seam, phase 3 — trusted-proxy-header authenticator

Add the trustedProxyHeader built-in: real accounts through an identity
gateway (oauth2-proxy, Authelia, a Keycloak-based proxy, an institutional
reverse proxy) that signs the user in and forwards the verified user in a
request header. OpenMAIC never handles passwords or tokens.

- Selected with OWNER_AUTHENTICATOR=trusted-proxy and validated in
  instrumentation register(). TRUSTED_PROXY_SECRET (32-1024 printable
  ASCII characters) is required; header names default to
  x-openmaic-proxy-secret and x-forwarded-user, and an optional groups
  header with TRUSTED_PROXY_ADMIN_GROUPS grants admin. An unknown
  selector, a TRUSTED_PROXY_* variable without the selector, a shared
  owner id or a host-registered authenticator alongside it, and malformed,
  reserved or repeated header names fail the boot.
- Trust boundary: Next.js exposes no TCP peer address to route handlers,
  middleware or Server Actions, and fills x-forwarded-for from the socket
  only when the client sent none, so no address allowlist is offered. The
  gateway secret is compared in constant time (hashed, timingSafeEqual).
- Per request: a missing or wrong secret, a missing or blank user, a user
  header with a comma (duplicate lines arrive comma-joined), or a user
  whose proxy:<user> id fails the owner id guard is 401
  INVALID_CREDENTIAL, never an anonymous owner. The guard failure is a
  401 rather than a 500 because the value comes with the request. The
  user is trimmed and keeps its case. Every gateway user holds
  course:publish; kind user, assurance verified, channel proxy; no
  cookie. Route handlers and Server Actions share one code path.
- The unset-ACCESS_CODE boot warning is skipped in this mode; the gateway
  is the access gate and ACCESS_CODE stays independent.
- The identity boundary scan now also fails on gateway identity headers
  (x-forwarded-user/-groups/-email, x-auth-request-*, remote-user, the
  secret header) and TRUSTED_PROXY_* read outside lib/server/identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(auth): document accounts through an identity gateway

Describe the trustedProxyHeader built-in in the README owner identity
section (and its Chinese counterpart): configuration, request semantics,
the trust boundary and why it is a shared secret, the interaction with
ACCESS_CODE, boot-time validation, and an oauth2-proxy example that
injects the user, groups and secret headers. Add the .env.example block,
a SECURITY.md note and an Unreleased changelog entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): harden the trusted-proxy authenticator after review

- Reserve the header names HTTP, Next.js and forwarding proxies set
  themselves: configuring the user, groups or secret header to rsc,
  next-action, forwarded, x-forwarded-for/-host/-proto/-port, x-real-ip,
  or anything starting with x-middleware-, x-invoke-, x-nextjs- or
  next-router- now fails the boot.
- Log a one-time boot warning when TRUSTED_PROXY_ADMIN_GROUPS is set: the
  admin role is granted from the groups header, which is trusted on the
  strength of the shared secret alone, so the gateway must overwrite or
  strip it.
- POST /api/chat/pi resolves the owner up front and answers 401 when the
  authenticator rejects the credential, like every other owner-resolving
  route, instead of continuing without an owner. The anonymous default
  always resolves, so it is unaffected.
- The identity boundary scan also covers the Authentik, Azure App Service
  authentication, WebAuth, Cloudflare Access, Google IAP and AWS ALB OIDC
  header families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(auth): admin-groups warning and a corrected oauth2-proxy example

- Warn next to TRUSTED_PROXY_ADMIN_GROUPS (README, README-zh,
  .env.example) that the groups header is trusted on the strength of the
  shared secret alone, and list the reserved header names.
- The oauth2-proxy example now states it targets v7.14+ (nested
  claimSource/secretSource), uses the canonical bindAddress, sets
  preserveRequestValue: false on all three headers and
  insecureSkipNonce: false, forwards the subject with `claim: user`, and
  notes that the secret variable holds the literal secret. It adds the
  legacy-option migration caveat, a warning not to exempt app routes via
  skip-auth or trusted-ip options, and notes that the IdP must release the
  groups claim and group names must not contain commas.
- Changelog: the new boot checks and the /api/chat/pi 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): owner identity seam, phase 4 — host extension hooks

* feat(persistence): owner identity seam, phase 4 — host extension hooks

Let a host add product behavior at four points without forking a route,
registered once from instrumentation.ts register() like the owner
authenticator and sealed on first use. Nothing changes when no hook is
registered.

- configurePersistenceHooks({ name, authorizeCreate, onCreate, library,
  beforeAssetAllocate }):
  - authorizeCreate / onCreate run once per created course, inside the
    create transaction, in the transaction that inserts the course's
    ownership row. Saves of a course the owner already holds, and a create
    that loses a race to a concurrent create of the same id, are updates and
    run no create hook. A refusal answers 403 CREATE_REFUSED; a throw rolls
    the course, its ownership row and the host's writes back together. The
    hooks receive the request principal, or only the owner id for a
    background agent run.
  - library chooses which stage ids GET /api/stages lists; the route builds
    the usual summaries in the provider's order and drops every id the read
    path would refuse (deleted or unclaimed). folderId is shown only on the
    principal's own courses.
  - beforeAssetAllocate runs inside the storage handler's asset
    authorization step for POST /assets, after the owner is resolved and
    before the body is read; a returned Response is sent instead, with
    nothing stored and no quota counted.
- configureAssetByteStore({ name, create, signsReadUrls }) replaces the
  ASSET_S3_BUCKET switch for both the persistence provider and the asset
  collector. A host store must keep bytes outside the registry database.
  Under ASSET_BYTE_EGRESS=redirect a store that does not declare
  signsReadUrls stops the server at boot; the built-in layers keep their
  behavior.
- @openmaic/storage 0.33.0: DocumentWriteRefusedError lets a store refuse a
  write as policy; the document HTTP handler answers 403 with its code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): harden the host extension hooks after review

Correctness:
- Registration reads every hook once and stores it bound to the object the
  host passed, so hooks defined on a class prototype or as non-enumerable
  properties are kept. A spread copy dropped them silently, including the
  authorizeCreate and beforeAssetAllocate gates. Same for
  configureAssetByteStore. The unknown-key check now applies to plain
  objects only, since class instances carry their own state fields.
- The owner-bound document store carries each call's operation in its own
  async context (AsyncLocalStorage) instead of a field on the instance. One
  store is shared by every tool of an agent run; a concurrent call could
  overwrite the field before the transaction read it, so a create was gated
  as a read (no ownership row, no create hooks) or claimed another stage.
  create_stage also runs sequentially within a tool batch.

Hardening:
- isDocumentWriteRefusedError recognizes a refusal from another copy of the
  storage package by name and shape (not by code alone). The document
  handler and POST /api/stages use it.
- beforeAssetAllocate also gates PUT /assets/{id}/content. The request
  carries operation ('create' | 'replace') and the decoded assetId from the
  handler's own routing.
- Agent runs: a refusal on a background write carries a fixed message, and
  create_stage answers it with a stable tool result. The host's message never
  reaches the model.
- A byte store declaring signsReadUrls without a signer degrades to direct
  bytes with a warning instead of failing reads, and the collector no longer
  requires signing.
- DocumentActor is discriminated by source ('request' with the principal,
  'background' without one).
- Library ids the read path cannot address are dropped, and a provider
  answer is capped at 5000 ids.
- The boot-time ownership backfill logs how many courses it adopted without
  hooks.
- The server persistence provider no longer exposes an unscoped document
  store.
- The writesOutsideRegistryDatabase trust boundary and the scope of upload
  admission (HTTP uploads, not server-generated media) are documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): refuse misspelled hooks on host classes too

A class instance skipped the unknown-key check, so a misspelled hook such
as authorizeCreat registered cleanly and never ran, leaving creation
ungated. Both configurePersistenceHooks and configureAssetByteStore now
reject any function-valued property of a class instance (own or on its
prototype chain up to Object.prototype, constructor excluded) that is not a
known key, and any unknown key of a plain object. The error names the key
and suggests the known key within edit distance 2. Host classes keep their
helpers private (#helper) or register a plain object; the README says so.

Also:
- isDocumentWriteRefusedError requires a string message from a cross-copy
  candidate, and documents that the name is the effective discriminator.
- CHANGELOG: ServerPersistenceProvider no longer exposes an unscoped
  documentStore; use the owner-bound store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): owner identity seam, phase 5 — tenancy lives in stage_meta only

* feat(persistence): owner identity seam, phase 5 — tenancy lives in stage_meta only

Course ownership was recorded twice: in stage_meta, which already answers
every access decision, and in document_stages.owner_id, which the storage
package scoped by. Two tenancy records double what a later claim has to
re-key, and a host that keeps ownership in its own table had to rewrite the
package's SQL to strip the column.

@openmaic/storage 0.34.0:
- PgDocumentStore never reads or writes document_stages.owner_id. An
  owner-bound store scopes listings, writes, deletes, the freshness manifest
  and folder membership through the host's ownership relation, named with
  the new documentOwnership option ({ table, stageIdColumn, ownerIdColumn,
  tombstoneColumn, claimOnCreate }, or false for no document scoping).
  Identifiers are validated, never quoted into SQL from arbitrary input.
- forOwner / ownerId without documentOwnership throws at construction,
  so a direct consumer cannot upgrade into a store that silently scopes
  nothing. An unbound store is tenant-agnostic.
- claimOnCreate inserts the ownership row in the create transaction; a
  concurrent create of the same id by another owner rolls back.
- folders: false lets a host whose document_stages has no folder_id use
  the store; folder ids are unique per owner, so membership goes through
  the ownership relation too.
- AssetCollector reads each document's owner from documentOwnership for
  assetReferencePrincipals, and refuses the function without it.
- Schema: fresh installs get no owner_id column and none of its indexes;
  an existing column is kept for one release, made nullable with its
  default dropped (each ALTER guarded by a catalog check, so a replay takes
  no lock), and its two indexes are dropped. document_stages_folder_idx
  replaces the owner/folder index.

Application:
- The owner-bound store binds the package to stage_meta
  (STAGE_META_OWNERSHIP) and lists through it in one query; the asset
  collector schedule passes the same relation.
- The boot backfill copies column-only owners into stage_meta before any
  store is built, only when the legacy column exists, idempotently, and
  logs how many it adopted and how many disagree (stage_meta stands).

Tests cover a fresh install and an upgrade from the previous schema on
PGlite and PostgreSQL 16, a host table with neither ownership nor folder
columns, folder isolation across owners sharing a folder id, and the
concurrent-create race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): serialize schema bootstrap; guard unscoped owner-bound stores

Review follow-ups for the tenancy change.

- Concurrent starts: two instances starting against one database raced on
  the schema DDL (a duplicate pg_class / pg_type entry, or "tuple
  concurrently updated"), failing one instance's first request. Every
  schema bootstrap the application runs -- the persistence provider's
  sequence (runtime, documents, stage_meta and its ownership backfill,
  owner materials, assets), the agent-session, session-material and
  user-skill stores, and the asset collector's -- now runs under one
  PostgreSQL advisory lock held on a dedicated connection and released in
  finally. It lives in the application because what needs serializing is
  the whole sequence, including tables the package does not own. A
  PostgreSQL test boots eight instances at once, four rounds each, on a
  fresh and on an upgraded database; without the lock it fails every run.
- documentOwnership: false on an owner-bound PgDocumentStore now also
  requires allowCrossOwnerDocumentAccess: true, so binding an owner for
  folders or asset principals cannot silently expose every owner's
  documents.
- Documented that an ownership relation must cascade with the document
  rows (a leftover row keeps the id reserved for its owner, which is also
  how a host keeps retired ids from being reused), with tests for both.
  Leftover rows are deliberately not made claimable: a host cannot be
  told apart from one that keeps them as retirement markers.
- Upgrade notes: the one-time table locks on the first start, and the
  out-of-band CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY path
  that leaves the start with nothing to build or drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(persistence): leave the schema bootstrap lock out of the held-lock check

The create-hooks suite asserts that its host lock is released with the
create transaction by counting granted advisory locks database-wide. A suite
booting a provider against the same database at that moment now holds the
schema bootstrap lock, which is not the lock under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 6 — claiming anonymous work on sign-in

* feat(storage): owner merge primitives for claiming anonymous work (0.35.0)

Add the per-store pieces a host needs to move one owner's data to another
inside a single transaction of its own:

- reassignDocumentFolders(tx, { fromOwnerId, toOwnerId, documentOwnership })
  moves folders and filing: a same-named folder merges into the target's, a
  colliding id is renumbered, and filing is rewritten in one statement from
  the old ids.
- PgAssetStore.reassignPrincipal(fromKey, toKey) moves a principal's entries
  under both principals' write locks, in key order; quota is not re-checked.
- PgUserSkillStore.mergeOwner(from, to) moves skills, renaming a live handle
  the target already uses with the first free numeric suffix.
- PgUserSkillStore takes a resolveFinalOwner hook, run as the create
  transaction's first statement, like the agent-session store's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(auth): owner identity seam, phase 6 — claiming anonymous work on sign-in

A visitor who works anonymously and then signs in can move that work to their
account in one transaction, and the anonymous id is retired afterwards.

- Principal: pendingClaim { fromOwnerId, assurance }. The trusted-proxy
  built-in sets it when a gateway request also carries a valid anonymous
  cookie; the anonymous and shared-team built-ins never do. Authenticators may
  implement describeStoredOwner, canonicalize and clearPendingClaim;
  principalFromStoredOwner describes a stored id for work without a request.
- Claims: claimOwner / claimPendingOwner and registerClaimParticipant (sealed
  on first claim). One transaction, both owners' identity locks taken
  exclusively in key order, participants in a fixed order (folders, courses,
  materials, agent sessions, skills, runtime, assets), recorded in the new
  owner_merges table. Idempotent per pair; refuses a non-anonymous source, an
  anonymous target, a source already claimed elsewhere, and chains. Same-named
  folders merge, colliding folder ids are renumbered, colliding skill handles
  get a suffix, and quotas are not applied to what moves.
- Identity lock: every owner write (documents, folders, asset allocations,
  material registration, agent sessions, skills) takes the owner's advisory
  lock in shared mode first, so a write racing a claim is moved or refused,
  never orphaned or deadlocked.
- Retired ids: request writes are refused with 403 OWNER_RETIRED; agent runs
  and media generation that started before the claim follow the id to the
  account (forwarding document store, canonicalized stage probes, forwarded
  generated assets and skills).
- Trigger: POST /api/identity/claim (same-origin JSON only; clears the
  anonymous cookie), or OWNER_CLAIM_TRIGGER=auto on the first request carrying
  a pending claim. The runtime contract's learner merge now performs the same
  claim, allowed only for the request's own pending claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(persistence): count only the suite's own advisory locks after concurrent creates

The check that a host's transaction-scoped lock is released counted every
granted advisory lock in the database. Suites that run against the same
database at the same time hold advisory locks of their own (a schema
bootstrap, the owner identity locks a claim takes), which made it fail
depending on timing. Count only locks held by this suite's connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(auth): use a neutral example table for host claim participants

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(storage): owner merge fixes and store policy errors over HTTP (review round 1)

- PgUserSkillStore.mergeOwner reserves every handle either owner holds before
  renaming: a claimed skill whose handle the target uses could be renamed into
  a handle another claimed skill still held, which violated the per-owner
  unique index and made that merge fail every time.
- PgUserSkillStore.list orders ties by id.
- PgRuntimeStore takes resolveFinalLearner(tx, learnerKey), run as the
  create transaction's first statement, so a host can fence session creates
  against its owner merges; reassignLearner(from, to) re-keys sessions without
  re-validating them, so one session a newer version wrote cannot block a
  merge.
- Every HTTP handler (runtime, documents, assets) answers a
  DocumentWriteRefusedError as 403 with its code, and the new StorageBusyError
  (code, message, retryAfterSeconds) as 503 with Retry-After.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): claims review round 1 — fenced runtime creates, bounded locks, retired-cookie recovery

- Runtime session creates take the owner's identity lock as the create
  transaction's first statement and refuse a retired owner, closing the window
  in which a create racing a claim was left under the retired id.
- Lock waits are bounded: a claim waits OWNER_CLAIM_LOCK_WAIT_MS (default 5 s)
  for its identity locks, a write OWNER_WRITE_LOCK_WAIT_MS (default 30 s);
  running out, or losing a deadlock or serialization race, answers 503
  OWNER_BUSY with Retry-After on the claim routes and on every fenced write.
- Every 403 OWNER_RETIRED carries the Set-Cookie values that drop the retired
  anonymous credential (the anonymous built-in now implements
  clearPendingClaim), so a browser whose claim response was lost recovers.
  Writes by id to rows that moved (skill delete, session messages) answer
  OWNER_RETIRED instead of not-found or a bare 403.
- OWNER_CLAIM_TRIGGER=auto no longer claims ahead of the explicit claim
  routes, which now report their own claim instead of a false refusal.
- The owner-events stream tells a retired owner only that it moved, never the
  claiming account's id.
- Creates of one course id take turns (a per-id advisory lock before the
  ownership probe), so a concurrent second create saves as an update instead
  of failing as reserved-document.
- A claim's source must be described as anonymous by the authenticator; the
  write fences skip the retirement lookup for every other owner. The host
  canonicalize hook is removed: forwarding is core's owner_merges alone.
- The folders participant locks the source's stage_meta rows first; runtime
  sessions are re-keyed without re-validation; the owner asset store fences
  every allocation and refuses to allocate without transactions; background
  skill reads and patches follow a mid-run claim; the forwarding document
  store forwards methods only.
- Docs: when a pending claim can arise with the shipped authenticators, the
  anonymous cookie as a bearer credential, bounded waits, the exact scope of
  OWNER_RETIRED, and claims narrowed to what the tests show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): claims review round 2 — merge-record guard, boot-checked lock waits, stream credential drop

- owner_merges records claims of anonymous owners only, since the write
  fences enforce retirement only for ids the authenticator describes as
  anonymous. Reading a row that retires any other owner now fails loudly
  instead of being followed but not enforced. The comment that pointed hosts
  at claimOwner for their own account merges is corrected: a host merging two
  signed-in accounts moves the rows itself and refuses the merged-away account
  in its authenticator. describeStoredOwner must classify ids stably.
- OWNER_WRITE_LOCK_WAIT_MS and OWNER_CLAIM_LOCK_WAIT_MS are validated at boot
  with the same parser the lock paths use, so a malformed value stops the
  server instead of failing every write.
- The owner-events stream answers a connect by an already retired identity
  with one owner_moved event and the Set-Cookie values that drop the retired
  credential, so a stale tab's reconnect loop ends after one round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* chore(auth): final polish before landing the owner identity seam

* chore(auth): final polish before landing the owner identity seam

- Pi routes refuse a rejected credential with the seam's INVALID_CREDENTIAL body.
- Drop the unused LegacyAssetMutations alias and resolveLockWaitMs re-export.
- Scope the document_stages.owner_id deprecation wording: the store no longer
  reads or writes it; the boot backfill reads it and a claim mirrors ownership
  into it for rollback.
- Document OWNER_CLAIM_TRIGGER and the two lock-wait variables in .env.example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(storage): 0.35.1 for the README wording fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(identity): one identity entry point with composable auth methods

* feat(identity): compose owner auth methods behind one entry point

Owner resolution now asks an ordered list of owner auth methods instead
of one configured authenticator. Each method answers `authenticated`,
`not-applicable` (no credential of its kind) or `invalid` (its credential
is present but bad). Core keeps the first `authenticated` answer, refuses
any `invalid` answer with 401 INVALID_CREDENTIAL without asking later
methods, and when no method applies falls back to the anonymous cookie
owner, or answers 401 when the host turned the fallback off. Route
handlers and Server Actions share the same order and per-request
memoization.

- configureOwnerAuthentication({ methods, anonymousFallback? }) replaces
  configureOwnerAuthenticator: single-shot, sealed on first use, and
  validated at registration and at boot.
- anonymousCookie becomes the built-in fallback method; sharedTeam
  becomes a method, selected by PERSISTENCE_SHARED_OWNER_ID as before
  when nothing is registered, and kept beside host methods only when the
  host lists sharedTeamAuthMethod() last. The variable set beside a
  registration that leaves it out fails the boot.
- Core attaches pendingClaim itself: a host method's non-anonymous
  principal on a request that also carries a valid anonymous cookie. A
  method may not set one. Claim cookie clearing and OWNER_RETIRED
  recovery go through the anonymous method's clearCredential.
- principalFromStoredOwner consults the anonymous method first, then the
  configured methods' describeStoredOwner in order.
- Remove the built-in trusted-proxy header authenticator with
  OWNER_AUTHENTICATOR and every TRUSTED_PROXY_* variable. The boundary
  test now also keeps the resolution internals inside
  lib/server/identity/ and asserts no source reads the removed variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(identity): document owner auth methods and a gateway JWT recipe

Rewrite the owner identity section around the ordered auth methods: the
three answers and what resolution does with each, the anonymous
fallback, registration and its boot checks, and the sharedTeam rules.
Replace the removed built-in gateway authenticator's documentation with
a short host-owned recipe that verifies a gateway-forwarded signed JWT
(oauth2-proxy, Cloudflare Access, IAP) against the identity provider's
keys with the jose library. Update the claim section for the core-owned
claim candidate, and the Chinese README, SECURITY.md, CHANGELOG and the
deployment docs in every locale to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(identity): tighten retired-owner cookies, retired config and the boundary

- On 403 OWNER_RETIRED, core now calls clearCredential only on the
  anonymous fallback and on methods that declare
  `issuesAnonymousOwners: true`, so an account method's session cookie is
  never cleared there. A clearCredential that throws is logged and
  skipped instead of turning the refusal into a 500.
- Setting any retired OWNER_AUTHENTICATOR / TRUSTED_PROXY_* variable fails
  the boot with a pointer to the auth-methods docs, instead of silently
  serving every request anonymously. Only the names are matched, in one
  module the boundary test checks never reads a value.
- Add lib/server/identity/host/ for host auth methods. The boundary test
  now scans core identity files for gateway identity headers too, polices
  reads of an incoming Authorization header everywhere but the host
  directory, and keeps resolution internals out of host code.
- Document on OwnerAuthMethodResult that a present but unusable
  credential must answer `invalid`, never `not-applicable`, and that a
  method that cannot decide throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(identity): fix the gateway JWT recipe's error split and add notes

- The recipe now answers `invalid` only for token failures (expired,
  claim validation, malformed JWS/JWT, bad signature, no or several
  matching keys, disallowed or unsupported algorithm) and rethrows
  everything else: jose reports a key endpoint that is down, answers
  non-200 or returns unparsable data as a generic JOSEError, which the
  previous version turned into a 401 for every user.
- The recipe file goes in lib/server/identity/host/, with an accurate
  description of what the boundary test enforces.
- Document invalid versus not-applicable for malformed credentials,
  issuesAnonymousOwners, the boot failure on retired variables, and that
  the claim candidate is an unsigned bearer cookie a sibling subdomain can
  shadow (serve on a registrable domain of its own; cookie hardening is
  deferred). Mirror the notes in the Chinese README, SECURITY.md and the
  changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:54:26 +08:00
LING DUAN 2a77a8a476 feat(chat): make Pi classroom runtime the default (#1628)
* feat(chat): make Pi classroom runtime the default

* chore(chat): align docs and E2E with Pi default
2026-09-22 11:41:13 +08:00
wyucandClaude Opus 5 6914e03478 fix(docker): build workspace packages in the builder stage, not during install (#1598)
* fix(docker): build workspace packages in the builder stage, not during install

The root postinstall builds nine workspace packages inside `pnpm install`.
In the Docker deps stage that step peaks above 1 GiB, almost entirely from
the importer's rollup + terser pass, which exhausts small Docker VMs and
hangs the build (a 1 GiB container fails with a JS heap OOM).

- deps: `pnpm install --frozen-lockfile --ignore-scripts`, so the stage only
  resolves and links dependencies (peak ~1051 MiB -> ~315 MiB).
- builder: run the same chain explicitly via the new `build:packages` script,
  with `--max-old-space-size=1024` so a runaway build fails with a clear heap
  error instead of taking down the VM; public/vendor is produced here now.
- `postinstall` delegates to `build:packages`, so local installs are unchanged.
- CI: build the deps + builder stages when an image input changes; the main
  Dockerfile was not built in CI before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYifP8wM4XJQ3Hc2qsF6zf

* ci(docker): build the main image for any non-docs change

The builder stage copies the whole build context, so application sources
and config such as next.config.ts are image inputs too. Only skip the
image build when every changed file is documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYifP8wM4XJQ3Hc2qsF6zf

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:53:06 +08:00
wyucandClaude Opus 5 ed22afe869 ci: disable setup-node package-manager cache in publish jobs (#1584)
setup-node v5 turns on package-manager caching automatically when
package.json declares `packageManager`, which requires pnpm on PATH. The
`publish` and `mark` jobs only use npm on prebuilt tarballs and never
install pnpm, so the first package publish after the Node 24 action
migration failed in setup-node with "Unable to locate executable file:
pnpm".


Claude-Session: https://claude.ai/code/session_01DYifP8wM4XJQ3Hc2qsF6zf

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:50:43 +08:00
LeoParkerOuandwyuc 042ad05965 ci: migrate publish actions to Node 24 (#1569)
* ci: migrate publish actions to Node 24

* ci: disable unused pnpm cache in ClawHub jobs

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 12:14:07 +08:00
wyuc b5605ef979 feat(agent-runtime): workbench generate_image / generate_video write through the asset pool (#1007 part 6) (#1524)
The workbench tools store generated bytes in the asset pool under the shared principal and write the allocated id into the document, the same discipline as the classic chain since #1392; the runner's putScene creates the reference rows and commits the allocations. The video completion patch rewrites every placeholder slot and retires anything that would shadow the new id; immediate render is preserved by leasing the id at the render boundary. A store-full refusal fails the tool with a model-readable error and writes nothing. Legacy /api/classroom-media documents keep rendering. Closes #1522.
2026-09-16 11:57:54 +08:00
wyuc c605e9e6ef feat(persistence): turn on the server-owned asset lifecycle and release assets on course deletion (#1007 amendment, part 2) (#1473)
App wiring for the @openmaic/storage 0.31.0 lifecycle: reference tracking and document references are paired unconditionally and declared at startup, course deletion withdraws references inside the tombstone transaction, ASSET_PENDING_TTL_MS configures the pending window, and the dead client-side reclamation code is removed.
2026-09-15 09:55:26 +02:00
Yanpeng Wang a03edc43e4 feat(ci): add opt-in issue triage agent (#1439)
* feat(ci): add opt-in issue triage agent

* feat(ci): support compatible triage Responses endpoints
2026-09-13 14:31:32 +02:00
LING DUANandwyuc ebf665f316 feat(playback): reference static GenUI components (#1281)
* feat(playback): reference static GenUI components

* feat(playback): gate courseware references

* fix(pi): bound Interactive source HTML before parsing

* fix(pi): bound interactive reference HTML processing

* fix(playback): close interactive reference review gaps

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-11 19:16:10 +02:00
Yunare Maia dde58944c1 chore(ci): migrate GitHub Actions off deprecated Node 20 runtime (#1343)
* chore(ci): migrate GitHub Actions off deprecated Node 20 runtime

- actions/checkout v4 → v5
- actions/setup-node v4 → v5
- pnpm/action-setup v4 → v5

Node 20 reached EOL; v4 actions still use node20. Verified via
driftcheck v0.1.8 (14 occurrences across 3 workflows, all node24).

* fix(ci): bump cache@v4→v6 and upload-artifact@v4→v7 in e2e job

Addresses review feedback from @YizukiAme on PR #1343.

- actions/cache@v4 → @v6 (lines 243, 314) — drop-in replacement
- actions/upload-artifact@v4 → @v7 (line 332) — Node 24 support landed in v6+,
  not v5; bump to v7 to ensure node20 runtime is truly eliminated.

The publish workflows (publish-packages.yml, publish-openmaic-skill.yml) remain
intentionally out of scope — tracked in #1345.

* chore(ci): trigger checks after rebase
2026-09-06 11:17:32 +08:00
Zhenghan Songandwyuc ca3d785221 build(node): enforce direct dependency engine floor (#1337)
Why:
- The root Node 20.9 contract predates direct runtime dependencies whose declared minimums now reach Node 22.19.
- README, contributor, localized docs, and the OpenMAIC extension skill repeated the stale supported-version claim.

What:
- Raise the root Node minimum to 22.19 and align every operator, contributor, locale, and skill prerequisite.
- Add a check that compares the root minimum with installed direct production dependency engine minimums.
- Run the new contract check in CI after the frozen dependency install.

Risk:
- This changes the declared minimum only; no upper bound is added and Node 24 compatibility remains a separate concern.
- The localized docs build was validated with the independent #1306 boundary fix from PR #1307, which is not included here.

Tests:
- RED on the base: engine check reported pi-agent-core, pi-ai, svg-pathdata, and undici floors
- GREEN: root minimum 22.19 satisfies 35 engine-constrained direct dependencies
- Node 20 lockfile-only install reports the root unsupported-engine warning
- Node 22 frozen install and postinstall
- Docs build with PR #1307 boundary: 34 pages and all locale postexport checks; docs types:check
- Root Prettier, ESLint, TypeScript, i18n, package-version, and internal-dependency gates
- Root pnpm test: 7137 passed, 81 skipped

Live Docs:
- GitHub issue #1304 tracks the Node contract; #1306 / PR #1307 tracks the separate docs-build prerequisite.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-02 04:29:52 -04:00
wyucandClaude Fable 5 04621578de release: OpenMAIC 1.0.0 — the agent workbench (#1228)
* feat(storage): add an agent-session store with PG backend and layered contracts (#1163)

* feat(storage): add agent-session store with PG backend and layered contracts

* test(storage): avoid BigInt literals for pre-ES2020 root typecheck

* fix(storage): close agent-session store review findings

* docs(storage): align hook ordering and contention-probe claims with the code

* ci: run on the agent-workbench integration branch

* chore(storage): bump to 0.5.0 for the agent-session store

* fix(storage): carry replay compaction across page boundaries

* feat(agent): add the driver model contract and stage route dialect (#1165)

* feat(agent): add the driver model contract and stage route dialect

* fix(agent): validate route context windows and clarify dialect precedence

* feat(agent): adapt the agent-session store and runtime foundations (#1167)

* feat(agent): adapt the agent-session store and runtime foundations

* feat(agent): resolve request owner identity via an anonymous cookie

* docs(agent): document the opt-in compaction default and harden edge cases

* feat(agent): add the background session runner (#1169)

* feat(agent): add the background session runner

* feat(agent): wire the runner into startup behind feature flags

* fix(agent): stop clean interruptions from consuming the attempt budget

* fix(storage): charge the attempt budget for abandoned leases but not clean parks

* docs(storage): document the attempt-charging contract and decouple its tests

* feat(agent): add agent session and owner event streams (#1170)

* feat(agent): add agent session and owner event streams

* fix(agent): close the session-existence oracle and document the owner seam

* feat(agent): add agent session lifecycle routes (#1171)

* feat(agent): add agent session lifecycle routes

* fix(agent): validate session-create input and preserve the owner cookie on errors

* refactor(storage): drop the unused active-stage API from the agent-session contract (#1174)

* refactor(storage): drop the unused active-stage API from the agent-session contract

Tools address stages explicitly on every call, so the store keeps no
mutable session-level stage pointer. Removes resolveActiveStage and
setActiveStage from the store interface, their PG implementations, the
active_stage_changed lifecycle event, the session_active_stage owner
event variant, and the contract tests pinning them. The active_stage_id
column and the DDL check constraint stay untouched for schema
compatibility.

* chore(storage): bump @openmaic/storage to 0.7.0 for the contract removal

* docs: document the agent runtime configuration surface (#1176)

* fix(agent): repair orphaned and late tool results across interruption boundaries (#1180)

* fix(agent): repair orphaned and late tool results across interruption boundaries

A crash, shutdown, or provider failure can leave the durable transcript with
tool calls that have no result, or with results ordered illegally for the
provider. Three failure modes were fixed:

- Orphaned tool calls: a run that died between an assistant tool-call frame
  and its result left a dangling call in the entry tree. Resume no longer
  synthesizes and persists receipts for it: interrupted results are a
  read-time provider view owned by a shared read-boundary repair, which
  returns the original array for a healthy transcript and never mutates the
  tree.

- Late parallel results: a parallel tool can finish while pi unwinds an
  aborted assistant frame, leaving result(A), assistant(aborted), result(B)
  in durable order. Strict providers reject non-contiguous results, so the
  read-boundary repair moves existing results next to their owning assistant
  frame (in call order), omits incomplete unwind frames, and synthesizes
  receipts only for genuinely missing calls.

- Interrupted calls at the write boundary: a call still in flight when the
  run winds down (shutdown, lease loss, cancellation, provider failure) had
  no receipt at all. The runner now tracks in-flight calls from their
  assistant frames and, before the terminal flush, appends an
  interrupted-result receipt for each still-orphaned call through the same
  attempt-fenced write chain, so a lease-stealing zombie never writes and
  the next claim sees a provider-safe transcript.

* test(agent): pin the runner wiring for interruption-boundary tool repair

* feat(agent): add neutral tool foundation libraries (#1184)

* feat(agent): register a web_search tool on the session runner (#1185)

* feat(storage): add a per-session URL trust gate (#1186)

* feat(agent): add the skills system (#1189)

* feat(agent): add the skills system (builtin directories and durable user skills)

* fix(storage): serialize the user-skill quota check-and-insert per owner

Two concurrent creates at the 50-skill boundary both counted 49 rows and
both inserted (READ COMMITTED, no lock), overshooting the quota contract.
The create transaction now takes a per-owner pg_advisory_xact_lock first,
and the same-name idempotency check runs before the count check so an
at-least-once retry of the create that committed as the owner's 50th row
still returns its durable receipt instead of a quota error. The 23505
backstop is retained for writes that do not take the lock.

* fix(agent): share unstorable-character validation and align skill lookup

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate (#1190)

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate

* fix(agent): harden session material fetching

* feat(storage): add an ownership scope to stage documents (#1191)

* feat(agent): add material read and search tools (#1192)

* feat(agent): add stage read and patch tools (#1194)

* feat(agent): add page generation and deck editing tools (#1198)

* test(storage): keep the PG contract suite order-independent (#1200)

* fix(agent): revoke deleted-session URL authority and reject private ISATAP endpoints (#1199)

* fix(storage): revoke deleted session URL authority

* fix(ssrf): reject private ISATAP endpoints in strict fetches

* chore(storage): bump to 0.11.1 for the session-URL authority fix

* feat(agent): add roster and voice registration tools (#1201)

* feat(agent): add folder organisation tools (#1202)

* feat(api): add stage and material HTTP routes (#1203)

* feat(workbench): add the client data layer (#1204)

* feat(workbench): add the client data layer

* docs(workbench): write the ported comments in English

* chore(edit): remove the in-editor agent panel (#1210)

* chore(edit): remove the in-editor agent panel

* style: apply prettier formatting

* fix(agent): report the runtime as unusable without a database (#1207)

* fix(agent): report the runtime as unusable without a database

* style: apply prettier formatting

* feat(agent): add image, video and pptx import tools (#1211)

* feat(workbench): add the agent chat surface (#1205)

* feat(workbench): add the agent chat surface

* docs(workbench): write the ported comments in English

* fix(workbench): label the folder and rename tools on the timeline

* fix(workbench): label the roster and voice tools on the timeline

The reconciliation test iterates every tool the runner registers and
requires a display label of its own. The roster and voice-clone tools
(list_voices, set_roster, clip_audio, register_voice) reached the
integration base with the roster/voice-registration tools but never
gained presentation rows, so they fell through to the default branch
and rendered their wire names. Port their rows from the reference
implementation (labels and i18n keys verbatim) and extend the
reconciliation allowlist with ROSTER_TOOL_NAMES and
VOICE_CLONE_TOOL_NAMES, so a future tool cannot enter the product
without a label.

* feat(agent): add the material extraction lifecycle (#1212)

* feat(storage): add material extraction lifecycle

* feat(agent): execute queued material extraction

* style: apply prettier formatting

* style: satisfy prefer-const in the extraction runner

* test: give material fixtures the extraction lifecycle fields

The media-tools slice and the extraction lifecycle slice were each green
in isolation but never compiled together: the lifecycle made derivedFrom
and extraction required on AgentSessionMaterial while the media-tool
fixtures predate them.

* chore: remove stray task notes

* fix(workbench): label the extraction lifecycle tools on the timeline

* feat(workbench): add the workspace shell (#1206)

* feat(workbench): add the workspace shell

* docs(workbench): write the ported comments in English

* i18n(workbench): align workspace keys across locales

* fix(workbench): adopt the landed data layer and label the extraction tools

- replace the sibling-slice seam stubs with the real data-layer modules
- drop ambient declarations now shadowed by landed files
- port timeline labels for the extraction lifecycle tools from the reference
- align the new i18n keys across all locales

* ci: retrigger

* feat(api): folder routes, stage-meta viewer surfaces, and the material upload contract (#1215)

* fix(storage): restore capability-based stage access

* fix(api): bind document access to request owner

* fix(agent): restore three-state stage access on the tool layer

Port probeStageAccess and the three-state StageAccess (owned / foreign /
missing / tombstoned) and gate every stageId-bearing stage tool on an owned
probe, mirroring the reference per tool:

- move_to_folder, rename_stage, read_stage_outline refuse a non-owned stage
  with the single not-yours message before touching the store.
- The course/DSL toolset and the roster toolset are wrapped by
  withOwnerStageAuthorization: read_stage, patch_stage, grep_stage and every
  writer refuse a foreign stage with the same message and refusal shape.
- Scene preview keeps its own probe and its own refusal text, and is
  registered beside the course toolset (never double-gated).
- The runner injects one probe factory at the three call sites.

Tests: the dsl cross-owner test premise (a foreign stage is readable by id)
encoded an invented capability-read policy that the reference does not have
at the tool layer; it now asserts foreign read/patch/grep are all refused
while the owner still reads. Curriculum cross-owner assertions were already
the reference's and now pass with the probes in place.

* docs: correct per-file test counts in the fidelity report

* test: fix type errors in stage-access fidelity test

* test: adapt media-tool and gate suites to the owner-scoped store seam

* feat(api): add owner-scoped course-folder HTTP routes

Port the reference implementation's /api/folders family (list, create,
rename, delete with ungroup/remove modes, and folder membership) onto the
owner-bound document store, replacing its provider-based auth with the
existing withRequestOwnerId / owner-scoped store seams.

The storage package's folder store grows the pieces the routes need:
DocumentFolder.order (schema column + max+1 assignment + ordering),
renameFolder, deleteFolder(mode) with captured member ids, and
setStageFolder(stageId, folderId | null) with idempotent un-filing.
FolderNameError moves into folder-name-validation.ts (stage-storage re-exports
it, keeping import sites intact).

Every route gates on the configured agent runtime (plain 404 when off or
unconfigured), keeps the reference's machine codes and envelopes, and is
covered by gate tests plus a behavior suite.

* feat(api): add stage-meta viewer surfaces for the classroom

Port the reference implementation's viewer-facing stage state — can-edit /
collected / published / generation-complete — on top of the stage-access
base (stage_meta + tombstones). stage_meta gains published_at and
generation_complete columns plus a stage_bookmarks table; the reference's
deployment-specific origin/claimed_at columns are stripped.

New gated routes: GET /api/stage-meta/[stageId] (per-viewer facts, 404 for
absent/tombstoned, never returns the owner id), GET /api/stages/[id]/status,
POST generation-complete / publish / unpublish (owner-only), POST
/api/bookmarks. The resolver lives in lib/server/stage-access.ts.

Wiring: a fetchStageMeta client with the reference's three-outcome contract,
stage-store isOwner/isBookmarked/readOnly fields (upstream single-user
defaults, no-op until the sidecar answers) plus setViewerAccess, the
classroom apply path computing readOnly = !(isOwner || isBookmarked), the
Stage editability gate, and a sidecar probe after each classroom load. A
sidecar 'absent' answer keeps the editable default here because the
classroom also serves local-only courses; server writes stay owner-enforced.

* feat(api): port the reference material upload contract

Rewrite POST /api/materials to the reference implementation's upload shape
so the workbench uploader (uploadWorkbenchMaterial, which posts no session
id and expects a flat 201 view) works unchanged: owner-scoped upload with
mime normalization/validation (415), per-class size caps checked on the
declared content-length and the streamed body (413), empty body (400),
quota (429), sha256 reserve->store->finalize lifecycle with abandon on
failure, flat { materialId, originalName, bytes, mime, extraction } 201,
and an x-request-id echo.

Adds the owner-scoped material library (owner_material table + quota +
24h lazy sweep, bytes in the host's asset registry as the neutral
replacement for the reference's object-storage byte path) and the material
cap configuration. The session-scoped GET list is left as-is; the
reference's owner-material extraction worker is not ported (the branch's
session-material extraction lifecycle already covers extraction).

Gate tests now cover all 23 persistence routes across the three runtime
env states; the materials behavior suite pins the new contract.

* feat(media): add an optional local ffmpeg media extractor (#1213)

Adds a local ffmpeg/ffprobe pipeline as a second media extraction
provider behind the extractor registry, ported faithfully from the
reference implementation: duration probing, keyframe-safe chunking,
per-chunk ASR with timeout and deadline budgets, and timestamped
transcript assembly.

- Availability probing feeds the registry's candidate selection: the
  provider simply is not a candidate when ffmpeg/ffprobe are absent.
- With neither ffmpeg nor a cloud provider configured, extraction fails
  with an actionable message naming both enablement paths.
- Media materials route through the same extraction lifecycle and lease
  fence as documents; no parallel queue.
- Tests inject the executable resolver so the missing-ffmpeg path is the
  default-tested one; the real pipeline test is skip-if-unavailable.
- @openmaic/storage 0.13.0 -> 0.14.0 (media routing in the material
  lifecycle surface).

* feat(storage): per-scene monotonic revisions via database triggers (#1214)

* feat(storage): per-scene monotonic revisions via database triggers

Restore the reference implementation's freshness granularity: a
per-scene monotonic revision maintained by database triggers, so every
writer (HTTP routes, agent tools, jobs, manual SQL) bumps it without
application cooperation.

- Companion revision tables + trigger functions in the storage package's
  idempotent schema bootstrap, with the lock-order invariant, pg_notify
  wakeup and the suppression switch for batch writers.
- ensureDocumentSchema gained a dollar-quote-aware statement splitter.
- The freshness and manifest routes serve per-scene revisions.
- Mutation-verified: dropping the triggers turns the revision tests red.
- @openmaic/storage 0.13.0 -> 0.14.0.

* fix: forward the freshness manifest through the owner-bound store

* feat(workbench): add the Pro entry points and preserve the mode-transition semantics (#1208)

* feat(workbench): add the Pro entry points

* feat(workbench): preserve Pro mode transition semantics

* fix(workbench): drop ambient declarations shadowed by landed slices

* fix(workbench): drop ambient declarations shadowed by the landed shell

* feat: port workspace shell sibling modules

Port the 16 leaf modules the Pro workspace shell imports but that were only
ambient-declared, replacing the compile-time bridge with real implementations
adapted from the sibling-slice reference: pure workbench helpers (session
title, rail tab, course-chat bootstrap, created-course tabs, course-tabs
memory, workspace navigation, pane navigation, pro-edit sizing,
existing-course minting, first-message session), the neutral brand context and
course-rename server API, the server-action session delete, the home
discovery hook, the classroom pane host with its load-policy leaf, the theme
toggle and floating-layer owner, plus the floating-layer-owner wiring the
dialog/dropdown/tooltip portals stamp.

Also add the workbench-shell locale copy for all 12 locales, port the
reference tests for the ported modules, and drop
types/workbench-sibling-slices.d.ts now that every declaration has a real
implementation.

* docs: keep ported comments in English and deployment-neutral

* docs: announce 1.0.0 and refresh the feature overview (#1216)

* docs: announce 1.0.0 and refresh the feature overview

* docs: finalize 1.0.0 README after feature merge

* fix(agent): control-plane routes answer 404, not 500, without a database

The agent control-plane routes gated only on the runtime flag, so an
enabled-but-unconfigured deployment (flag on, DATABASE_URL empty)
answered 500 from a store that cannot connect. Gate them on the
configured check instead, matching the stage/material routes: the
whole surface is cleanly absent until both the flag and the database
are present. The status probe keeps reporting both bits.

* test: mock both runtime gate exports in the control-plane route suites

* fix(agent): abort in-flight TTS on cancel and bound each provider request with a timeout (#1217)

The generate_tts / scene-tts path checked the runner's AbortSignal between
actions but never created the provider HTTP requests with it, so a session
cancel left a hung synthesis fetch in flight until a restart repaired the
tool result. Thread the signal end-to-end: TTSModelConfig carries an
optional signal, generateTTS combines it with a per-request timeout
(TTS_REQUEST_TIMEOUT_MS, default 30s, ported from the reference runtime's
TTS bounds) via AbortSignal.any, and every provider fetch (openai, azure,
glm, qwen incl. voice-clone + audio download, voxcpm, minimax, doubao,
elevenlabs, lemonade) is created with that signal.

A timeout now fails the tool call with TTSRequestTimeoutError (a clear
retryable error) instead of wedging the session; a caller cancel propagates
as the interruption so the runner settles the session as cancelled without
a restart.

Tests: hung-provider simulation rejects at the timeout with the retryable
error; abort mid-flight aborts the captured request signal and surfaces the
interrupted shape; removing the signal wiring makes the abort tests fail
(red), restoring them turns green.

* fix(workbench): PG-mode home listing via owner stages; keep the interrupted terminal course card (#1218)

Finding 1: with server persistence on, listStages resolved to the generic
GET /api/persistence/documents listing, which the capability model
deliberately answers 403 FORBIDDEN_DOCUMENTS for (reads by id, listings
owner-only). The home/workspace library now lists through the owner-scoped
GET /api/stages surface (same anonymous-owner cookie the workbench uses)
when server persistence is enabled; the server-side 403 is untouched.

Finding 2: a run interrupted (session_interrupted) and repaired
(session_resumed) that ends cancelled before agent_end stranded its pending
classroom sightings, so the timeline's terminal card lost the course the
answer produced. session_end (cancelled) now flushes the pending sightings
into the same course card set agent_end paints, before the stopped caption.

* chore(workbench): remove the bookmark concept and the saved-courses drawer (#1219)

* chore(classroom): remove the bookmark ('collected') concept entirely

The stage-meta viewer port introduced a bookmark surface (stage_bookmarks
table, POST /api/bookmarks, the isBookmarked sidecar field, and a
readOnly rule that let a saved course stay editable). The product has no
such concept, so remove it as a closure:

- delete the /api/bookmarks route and the stage_bookmarks table plus its
  query helpers from the persistence bootstrap
- drop isBookmarked from GET /api/stage-meta/[stageId]
- simplify the classroom read-only rule to readOnly = !isOwner across the
  sidecar client, ownership signal, classroom load, stage store and the
  classroom page
- keep publish/unpublish, generation-complete, isOwner and isPublic
  exactly as they were
- update the gate and stage-meta route suites and the README mentions

The workspace rail's Bookmark glyphs and comments describe the upstream
saved-courses (favorites) section, which is driven by isOwner and renders
no collect affordance; they are kept as unrelated homonyms.

* chore(workbench): remove the saved-courses drawer UI

The first pass removed the bookmark data model but kept the rail's
"Saved courses" drawer, judging it a separate surface driven by
`isOwner === false`. The home/workspace listing is owner-scoped, so that
flag can never occur: `allSaved` is permanently empty and the drawer
(plus the collapsed-rail Bookmark mini-button) is a dead affordance.
Remove it: the SavedDrawer component and its mount, the savedOpen /
savedSection state, the allSaved / matchedSaved derivations, the 'saved'
variant of the course-list renderers, the mini Bookmark glyph, the
drawer-only CSS, and the drawer's i18n keys from all 12 locales. The
courses tab is now exactly one folders tree.

The authored/favorites split in workspace-tree.ts goes with it; the tree
module no longer reads `isOwner`. The discovery course type keeps the
field — the shell still reads it for read-only gating.

Upstream has no collect concept; the drawer could only ever render empty
here. The reference implementation HAS this drawer (its favorites come
from its account system), so this removal is a deliberate upstream
product decision, not a fidelity bug.

* fix(workbench): restore the attach entry, add the rail settings entry, pin all three entry points (#1221)

* fix(workbench): restore the composer attach entry by gating it on the live runtime

The AttachButton's rollout probe read a `materialsEnabled` field that this
branch's /api/agent/runtime never answers (the materials routes gate on the
runtime itself, like the stages), so the gate could never pass and the attach
button never rendered — the Pro launch and chat composers showed only the
@-mention and enhance glyphs.

Substitute the field with the runtime's `enabled` value, which IS the upload
action's precondition: POST /api/materials answers 404 whenever it is false,
so the render condition now equals the action precondition (no dead button).

The button's label (`proMode.attach`) is a user-visible string that becomes
visible again; port the reference implementation's own translations verbatim
into the 11 locales that still carried the Chinese copy.

* feat(workbench): add the settings entry to the rail's bottom-left cluster

The reference's rail foot carries a cluster of utilities (its saved-courses
drawer, the language switcher, the display toggle). This branch removed the
drawer — it could only ever render empty here — and the product decision is
to fill that freed spot with the settings entry.

Add a settings trigger to the foot cluster (expanded rail, beside the
language and display toggles, and on the collapsed strip) and mount the
model/provider SettingsDialog in the rail, wired to the trigger. It is the
same dialog the classic home opens from its header pill; the workspace had no
settings entry of its own, so nothing is duplicated within a surface.

* test(workbench): pin the restored upload, attach, and settings entry points

Covers the three restored entry points:

- the courses-tab upload control: rendered beside the course name filter,
  wired to the discovery hook's ZIP import trigger, disabled while an import
  runs, and gated by the same condition as its action (the courses tab);
- the composer attach control: an actual render of AttachButton under both
  probe answers (visible when the runtime says the upload path is live,
  hidden otherwise), its mounts in the launch and chat composers, the
  branch's runtime-field substitution in the probe, and the reference's own
  `proMode.attach` copy in all 12 locales;
- the settings entry: the trigger in the rail's foot cluster (expanded and
  collapsed), beside the language and display toggles, opening the
  SettingsDialog the rail mounts.

* chore(config): the Pro workbench flag implies the MAIC Editor gate (#1223)

A workbench build without the editor toggle has no way to edit a course:
enabling NEXT_PUBLIC_PRO_WORKBENCH_ENABLED while forgetting
NEXT_PUBLIC_MAIC_EDITOR_ENABLED produced exactly that split-brain bundle.
The workbench IS Pro mode, so its flag now implies the editor gate; the
standalone flag remains for deployments that want the classroom editor
without the workbench. Documents both flags in .env.example.

* fix(agent): wake SSE tails and the runner on durable deltas (streaming fidelity) (#1222)

The Pro workbench chat did not stream: the session/owner SSE routes polled
the durable event log on a 5s/30s clock with no wakeup, so message_update
deltas (written at 150ms cadence) reached the browser in poll-sized blocks
and the thinking strip only mounted after the whole reasoning text had
accumulated.

Port the reference's LISTEN/NOTIFY delta path:

- storage: add in-transaction wake hooks (onSessionEventAppended,
  onOwnerEventAppended, onCancelRequested) so a host queues pg_notify in
  the same transaction as the durable append; align readEventsAfterForReplay
  to rank the bounded page so the first delta after the cursor is always
  kept (the live tail can never starve). Bump @openmaic/storage to 0.18.0.
- app: port the process-wide event-notify bus (dedicated LISTEN client,
  self-check probe, reconnect backoff; notify through the storage
  transaction surface), wire the store hooks, subscribe both SSE routes
  before the initial read with the reference's initializing gate, and give
  the runner one {kind:'session'} subscription whose wake runs the cancel
  check and the message drain. Polls stay as the lossy-NOTIFY backstop.
- lifecycle: start/stop the bus from instrumentation.

Tests: storage hook + compaction contract; route wakeup latency; runner
wakeup wiring with a fake agent; bus unit tests; PG contracts proving a
real append wakes the routes and a live SSE route forwards a message_update
on the wakeup, and that a rolled-back append never wakes. Also fix the
pre-existing park-attempt-budget PG test TRUNCATE (missing CASCADE against
newer FK tables).

* fix(storage): asset writes self-deadlocked against pooled PostgreSQL (#1225)

* fix(storage): refuse the non-transactional byte-write deadlock configuration

A byte store whose plain write() runs on its own pooled connection cannot be
invoked from inside a registry write transaction: after the transaction has
claimed the blob-row lock, that write blocks on the lock the transaction just
took while the transaction waits on the write - a self-deadlock PostgreSQL
cannot detect (one side is idle in transaction).

There is no lock-safe ordering for such a writer: bytes must be written after
the row claim (writing before it lets the collector delete the bytes while the
upsert waits), and any second-connection write after the claim is the
deadlock. The configuration is therefore detected and refused:

- AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the
  layer's plain byte operations cannot contend for the registry's row locks.
- PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite)
  when the byte store has no writeWith and does not declare the flag, throwing
  a clear configuration error before any row is claimed.
- The collector mirrors the guard on its delete path (deleteWith or a declared
  out-of-registry layer, else a configuration error).
- The object store declares the flag (its out-of-transaction write remains
  legitimate); the in-registry PostgreSQL byte column provides writeWith /
  deleteWith instead.
- Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so
  any future lock-contention variant fails loudly instead of hanging.

Bumps @openmaic/storage to 0.18.0.

* fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper

The no-bucket case of lazyAssetByteStore returned a bare { write, read,
delete } and dropped writeWith/readWith even though the underlying
PgAssetByteStore has them. The registry's hasTransactionalWriter duck check
then failed and put() fell back to the byte store's own pooled connection,
which blocks forever on the blob-row lock the registry transaction just took
when the bytes live in the same PostgreSQL - the production self-deadlock.

The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned
methods are forwarded eagerly (typed against the real signatures via
PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no
transactional writer exists there, the signed-URL method stays absent or lazy
exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase
so the registry may run the plain write inside its transaction.

New tests pin the wrapper's transactional capability red-to-green and assert
put()/resolve() route byte traffic through the transaction-pinned queryable.

* fix(home): cap the generate-prep ingest drain at 3s so Generate never waits the full server budget

The classic home flow's Generate click drained in-flight ingests for the full
15s server budget. Cap the wait at GENERATE_DRAIN_CAP_MS (3000ms, documented as
a UX bound) and reuse the existing timeout fallback: sources that miss the cap
proceed on the legacy byte path and each late-resolving id is released.

* chore(storage): bump to 0.19.0 over the concurrently landed 0.18.0

* fix(agent): bound every tool call with a timeout; never resurrect a cancelled session (#1226)

* fix(agent): bound every tool call with a global timeout and settle it on cancel

A tool await that neither resolves nor rejects wedges the session forever:
the lease keeps heartbeating and the driver never reaches its next cancel
checkpoint. Race every tool execution (in buildAgent) against a hard budget
(OPENMAIC_AGENT_TOOL_TIMEOUT_MS, default 10 min, per-tool overrides for
known long runners) and against the caller's AbortSignal, so even a
signal-ignoring await cannot keep a cancelled session running.

On timeout the call rejects with AgentToolTimeoutError; the agent loop turns
the rejection into a structured error tool-result the agent can retry or
proceed from, and the abort signal is delivered to the tool's in-flight work
through a derived controller. Zombie-tool updates after settlement are
dropped.

* fix(storage): never re-lease a cancel-requested session; settle it as cancelled on claim

The claim scan treated a session with cancel_requested_at set as a normal
claim candidate: after a restart it re-leased the same session for attempt
N+1 and resumed generating despite the pending cancel. claimNextSession now
settles such candidates as cancelled under the claim lock (status cancelled,
attempt reset, lease and cancel request cleared, terminal session_end event
and owner projection) instead of leasing them, then keeps scanning.

Bump @openmaic/storage to 0.18.0.

* docs: takeaway-style 1.0.0 announcement with bilingual guide links

The 1.0.0 head is now a short takeaway block — badge links to the
official user guides (English and Chinese), five one-line highlights,
and pointers into Features and the workbench setup section — instead of
six dense paragraphs. The detailed provider-neutrality and freshness
notes move into the Features workbench section, phrased database-
neutrally (the announcement no longer names a specific database).
Release date corrected to August 27.

* fix(workbench): restore editor chrome, mode transition, streaming, materials, mentions, folders (#1229)

* fix(workbench): wire workspace folder routes

* fix(editor): restore reference workbench chrome

* fix(workbench): persist composer materials and course refs

* fix(workbench): preserve live reasoning frames

* fix(persistence): back off failed streaming saves

* chore(workbench): retire stale slice seams

* test(editor): cover element pin layer

* chore(storage): bump to 0.21.0 for the user-message ref/material fields

* chore(editor): translate ported code comments to English

* fix(agent): fence durable tool writes and consume cancel requests atomically (#1230)

* fix(agent): enforce provider force-off in agent tools and scrub vendor identity from tool results (#1231)

* fix(materials): serialize per-owner quota reservations and make crashed uploads reclaimable (#1232)

* fix(editor): resolve dock-bar i18n keys, remove dock height drag, wire element referencing (#1233)

* fix(workbench): send the opening session message exactly once with refs intact (#1234)

* feat(editor): port timeline TTS preview single-flight and voice-all state latching (#1235)

* fix(media): restore the reference classic media chain (#1236)

* fix(import): adapt imported PPTX canvas size so decks render without overflow (#1237)

* fix(editor): complete element referencing — renderer DOM contract and GenUI picking aligned with the reference (#1238)

* test(providers): reconcile the provider-config vendor-token debt count after the main merge

The integration line's AK/SK fallback for the managed document provider adds
occurrences that main's allowlist snapshot predates. Same mixed-composition
debt category the group already documents; no new vendor behavior.

* test(providers): reconcile vendor-token debt counts with the integration line

The main-merge brought main's neutrality-guard snapshot next to integration
features it predates (media-extractor fallback chain, local voice-profile
deletion semantics, the enabled-TTS helper). Same debt categories the guard
already documents; counts updated to the guard's own tally and two grouped
entries added. No new vendor behavior.

* fix(agent): carry reasoning through the completions dialect so the thinking strip renders (#1239)

* feat(skills): add Feynman and spiral curriculum methods (#1240)

* feat(agent): port missing reference tools and skills (parity audit) (#1241)

* feat(media): retire asset-registry wiring; media and materials follow the reference byte model (#1242)

* fix(classroom): center adapted canvases in the stage and send back navigation home during generation (#1243)

* feat(settings): skill management with real list, download, delete, and upload (#1244)

* feat(settings): skill management section with real list, detail, and zip download

* feat(skills): owner skill delete and upload across storage, API, and settings

* fixup! feat(settings): skill management section with real list, detail, and zip download

chore: neutralize a reference note in the settings header comment

* fix(media): persist origin-independent classroom-media references from the agent runtime (#1245)

* feat(editor): float the insert toolbar in the outer frame with collapse (#1246)

The insert strip was bounded to the slide card, so it could only ever sit on
top of slide content: the card's overflow clipped it and it could not be parked
in the padding beside the slide. Move it into the studio frame the element
picker's panel already roams (CanvasOverlayPortal + the frame selector), so both
canvas overlays share one bounding container and their handles behave the same.
While picking, the strip rises over the picker and goes inert, which is the
z-order CANVAS_OVERLAY_Z already documents.

Add a fold beside the grip: the chevron collapses the strip to that grip row and
back, with the buttons unmounted rather than hidden. The fold is session-local
state owned by EditShell, next to the drag offset, so a surface swap keeps it;
nothing is persisted. Expanding a strip parked at the bottom edge re-clamps
through the same bounds rule the keyboard move uses.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): align the chat timeline's left edge with the composer (#1247)

* fix(agent): fence session claims while an ask_user question is outstanding (#1248)

* fix(agent): settle-time rescue tracks real delivery instead of a count offset (#1249)

* fix(persistence): migrate owner_material to oss_key and drop legacy asset_id (#1250)

* docs(readme): surface the 1.0.0 user guide badges at the top (#1253)

* fix(workbench): show newly created folders in the sidebar without reload (#1254)

* docs(readme): add the release version prefix and drop the opt-in framing

* fix(workbench): single-source the chat gutter so timeline and composer share a left edge (#1255)

The transcript and the composer each established their own column: their own
`px-*` gutter and their own `mx-auto w-full max-w-*` centering wrapper. Equal
padding values were never enough, because the two columns are centered inside
different containing blocks — the transcript's is a scroll container, whose
content box is narrower than the composer footer's by the scrollbar's width:

  transcript text left = pad + (pane - 2*pad - scrollbar - measure) / 2
  composer box  left   = pad + (pane - 2*pad             - measure) / 2

The padding cancels out of the difference and what remains is `-scrollbar/2` at
every padding value, so the transcript sat half a scrollbar to the left of the
composer and tuning the two paddings against each other could not move it.

The column is now established once, by the nearest common ancestor of both
(`chatColumn`), and the scroll viewport and the composer footer are siblings
inside it that add no horizontal inset of their own. The cap carries the gutter
on top of the 760px reading measure, so the text column keeps its width. The
handed-over question row drops the padding that indented it past the agent's
prose; framed rows keep their own inner padding, which is what a card's border
sitting on the column edge means.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): lock pane-embedded classroom to edit mode (#1256)

The workspace right pane painted the full learning chrome — speed control,
play button, learner avatars, mic bar — for a course the agent had just
created, then flipped to edit once the first scene landed.

resolveStageChromeMode treated playback as the DEFAULT branch for a hosted
classroom, so every shortfall fell into it: a course whose tab opens at
stage_link time has no scenes yet, so currentSceneId is null and
isHostedSceneEditable is false. A folded pane parked the playback root
behind the fold and cross-faded it out over the pane on unfold, and a
failed editor chunk dropped into playback permanently.

Lock it at the pane instead of defaulting per entry path:

- WorkbenchPanelProvider — the single element that mounts a classroom into
  the workspace — publishes editPinned (visible && !playback). Every entry
  path passes through it, so none of them decides.
- The hosted resolution can no longer degrade to playback. Start Learning
  (workbenchLearning, new input, split out from pane visibility) is the one
  door; everything else resolves between the neutral loading shell and edit.
- Stage's chrome dispatch is exhaustive on chromeMode, so the playback root
  is no longer the else-branch of a condition about the current scene.

No flicker: chromeMode is resolved during render, and preloadEditor now
answers synchronously (isEditorPreloaded) so a remount with the chunk
already registered paints edit on the first frame. A failed import is no
longer cached forever, so the lock cannot strand the pane.

Standalone classrooms keep their stored mode unchanged.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:26:25 +08:00
LING DUAN 146e164141 feat(whiteboard): add destructive runtime operations (#1173)
* feat(whiteboard): add destructive runtime operations

* fix(whiteboard): preserve additive exact replay results

* fix(whiteboard): support legacy code line ids
2026-08-27 10:06:44 +08:00
wyuc 086e7626ea ci: cut wall-clock time and bound Playwright browser installs (#1146)
Cache Playwright browsers and Next compile artifacts, retry a timed-out
Chromium download, build the production bundle before Playwright starts,
and run Prettier/ESLint/tsc/i18n in parallel. Unit tests stay sequential.
Skip Playwright apt deps on ubuntu-latest.

Closes #1145
2026-08-19 15:36:00 +08:00
杨慎andwyuc 019fe6e6ac feat(video-export): add explicit CPU resource profiles (#1105)
* feat(video-export): add explicit CPU resource profiles

* fix(video-export): preserve failure capture metrics

* fix(video-export): report observed failure capture mode

* docs(video-export): keep benchmark evidence out of service docs

* fix(render-service): build from dated Debian snapshot

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-12 18:10:50 +08:00
杨慎 4ac70cceeb feat(video-export): capture self-contained static interactive HTML (#1086)
* feat(video-export): capture static interactive HTML

* fix(video-export): harden interactive HTML capture diagnostics

* fix(video-export): initialize runtime diagnostics in manifest

* fix(video-export): close interactive capture review findings

* fix(video-export): preserve packaged interactive resources

* fix(video-export): address packaging review feedback

* fix(video-export): harden interactive HTML packaging

* fix(video-export): unify interactive asset packaging

* fix(video-export): close remaining interactive packaging gaps
2026-08-11 22:16:58 +08:00
xuyuanwei678 26d280c9b7 fix(ci): stabilize Hyperframes lint command (#1097) 2026-08-11 11:27:21 +08:00
xuyuanwei678 c38da84ef6 feat(renderer): decouple editor UI from the app (#1072)
* docs: define renderer editor task 1 integration

* docs: plan renderer editor task 1

* feat: gate renderer slide editor

* feat: adapt renderer editor intents

* feat: connect renderer editor to slide surface

* test: complete renderer intent shape fixture

* fix: restore renderer editor action previews

* fix: render editor alignment guides

* perf: smooth renderer element dragging

* feat: align renderer hidden element behavior

* feat: add renderer canvas host commands

* feat: add renderer canvas context interactions

* test: cover renderer context menu interaction

* fix: preserve renderer canvas interaction semantics

* docs: design renderer text editing

* docs: plan renderer text editing

* feat(renderer): add ProseMirror text core

* feat(renderer): add rich text editor controller

* feat(renderer): render editable text in place

* feat(renderer): enter text editing on click

* feat: connect renderer rich text editing

* feat: finalize renderer text edit lifecycle

* fix: stabilize renderer text edit commits

* fix(renderer): move elements from selected borders

* docs: design renderer editing UI text toolbar

* feat(renderer): publish editing UI contracts

* feat(renderer): add controlled text format toolbar

* style(renderer): format editing UI contracts

* fix(renderer): address Task 2 review findings

* fix(renderer): handle zero font sizes in toolbar

* feat(renderer): add editing UI color picker

* fix(renderer): address Task 3 color picker review

* fix(renderer): commit native color input changes

* fix(renderer): coordinate native color picker interaction

* feat(renderer): anchor text toolbar to canvas elements

* feat(renderer): compose editable canvas with text UI

* feat(editor): use renderer text toolbar UI

* fix(renderer): make editing UI tests type-safe

* fix(renderer): harden editing UI integration

* feat(renderer): add editable text UI

* feat(renderer): add image and shape editing

* feat(editor): extend renderer editing controls

* feat(editor): add renderer chart insertion

* docs: design renderer latex editor

* docs: plan renderer latex editor

* feat(renderer): add latex editor contract

* feat(renderer): add latex editor dialog

* feat(renderer): compose latex insert and edit UI

* feat(editor): integrate renderer latex editing

* fix(editor): consolidate latex toolbar actions

* fix(renderer): show toolbar hover labels

* test(renderer): cover localized latex tooltips

* chore(renderer): hide latex sample palette

* feat(renderer): add video editing controls

* docs: define renderer audio editing

* feat(renderer): render audio elements

* feat(renderer): add audio editing UI

* feat(renderer): add preview audio controls

* feat(editor): add renderer element clipboard

* feat(renderer): decouple editor UI from app

* chore(renderer): release 0.0.6

* fix(renderer): clear editor CI gates

* fix(editor): adapt renderer canvas to media resolution API

* fix(i18n): complete French editor labels

* fix(renderer): hide toolbar vertical overflow

* chore(renderer): address review cleanup

* chore(docs): remove planning artifacts from pr

* feat(editor): add transactional core package

* chore(editor): ignore local test cache

* build(editor): publish core entrypoint

* refactor(editor): route renderer changes through transactions

* refactor(editor): split renderer editing into editor package

* ci(editor): register editor package for release

* docs(editor): document package dependencies

* ci: rerun validation after editor release registration

* test(editor): validate deletion against existing element

* fix(editor): address transaction and package review findings

* fix(editor): validate transactions and restore editor styles

* fix(editor): protect required updates and react styles

* fix(editor): validate required transaction fields

* fix(editor): prevent insert popovers shifting canvas

* refactor(editor): own renderer editing capabilities

* fix(editor): harden editing transaction boundaries

* refactor(editor): modularize editor UI integration

* docs(editor): harden third-party integration

* fix(editor): preserve video resize aspect ratio

* fix(editor): discard text draft before deletion

* fix(editor): preserve renderer picking and line presets

* chore(editor): remove unused pick-layer import
2026-08-10 19:56:24 +08:00
wyuc 3b4567076f refactor(generation): consume the package everywhere and delete lib/generation (#1090)
Part D of #1057 — the switch. All consumers import @openmaic/generation; lib/generation deleted (net -11,900 lines). PBL wiring preserved (single-call -> classified fallback -> injected loop), streaming outline route deduplicated onto buildOutlinePrompt, serverExternalPackages wired, and the change verified by a real-model four-kind end-to-end run against the production build with a rendered-classroom screenshot check. Closes #1057.
2026-08-10 03:58:33 -04:00
wyuc b64e787749 feat(generation): move scene generation and PBL single-call planning into the package (#1087)
Part C of #1057. Scene content/actions/widget generation, retry, action parsing, interactive post-processing, buildCompleteScene with injectable sceneId, and PBL single-call planning re-seated on AICallFn (pure core + kernel + package-relative planner assets; loop planner stays app-side behind the injected pblLoopFallback with provider/abort classification). Purely additive — app source untouched until Part D. Package 0.2.0.
2026-08-10 00:58:35 -04:00
732c36364a fix(release): hand published versions to marker job (#1076)
Co-authored-by: Bryan Nathan <bryan@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-09 05:36:43 -04:00
wyuc 9556a035b1 feat(generation): move outline generation into the package (#1065)
Part B of #1057. Outline primitives, buildOutlinePrompt (single-source prompt construction with golden snapshots), logger injection, JSON repair, and the bare-Node second-consumer smoke script with CI coverage. Shim-first: no app-side behavior changes; duplicated prompt assets guarded by a CI parity check until Part D.
2026-08-05 23:29:36 -04:00
wyuc c0da724ec8 ci(skill): publish OpenMAIC skill to ClawHub (#1056)
* ci(skill): publish OpenMAIC skill to ClawHub

* ci(skill): support manual ClawHub versions

* ci(skill): validate manual ClawHub versions

* ci(skill): normalize manual ClawHub versions

* fix(skill): preserve build metadata in version checks

* fix(skill): reject manual build metadata versions

* refactor(skill): share ClawHub version validation

* fix(skill): validate shared version checker inputs

* test(skill): cover ClawHub version validation

* test(skill): harden ClawHub version fixtures

* test(skill): cover ClawHub checker edge cases

* test(skill): cover ClawHub metadata contracts

* ci(skill): harden ClawHub publish workflow

* test(skill): cover ClawHub publish shell

* test(skill): strengthen publish shell regressions

* ci(skill): verify publish path on Bash 3.2

* ci(skill): harden ClawHub release guards

* ci(skill): unify publish divergence handling

* test(skill): bind publish divergence reasons

* test(skill): bind stale tree inputs

* test(skill): enforce stale guard ordering

* test(skill): lock publish guard sequence

* test(skill): enforce publish guard counts

* test(skill): enforce publish job uniqueness
2026-08-05 22:35:38 -04:00
wyuc 335180ec9c feat(generation): scaffold @openmaic/generation with pipeline types and packaged prompt assets (#1063)
Part A of #1057. Package skeleton (ESM, tsc, dsl/storage pattern), pipeline type contracts, prompt loader with package-relative asset resolution, 13 templates + 7 snippets byte-identical with golden tests, allowlist boundary lint, full publish/CI wiring, installed-tarball smoke coverage.
2026-08-05 22:22:54 -04:00
de95350516 feat(video-export): deterministic Quiz/PBL cover cards (#985) (#995)
* feat(video-export): add deterministic Quiz/PBL cover cards

Replace unsupported placeholders with authored-metadata cover cards that
stay independent of learner progress, and pin dense-layout evidence for
720p/480p.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(video-export): add configurable localized cover CTA

Replace the inert Start Quiz affordance with a destination-configurable
CTA (NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION), including Unicode/bidi
display guards.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(ci): require Hyperframes video-export gates

Lock CI/Docker contracts so cover materialization and Hyperframes sample
lint stay required for export changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): move HF_E2E_DIR onto e2e steps

GitHub rejects runner.* in job-level env, which aborted the CI workflow
with zero jobs. Keep the sample dir on the materialize/lint steps only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(video-export): tidy CI invoke and panel-box export

Run the Hyperframes materialize step through pnpm exec vitest like the
cover-card guardrail above it; the e2e job already pins Node 22. Stop
re-exporting PBL_PANEL_DESIGN_BOX from the public barrel — only the
layout browser test needs it, and it can import the emitter module
directly.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-04 15:43:38 +08:00
wyuc 30dd236057 Publish validated package tarballs (#1040)
* Publish validated package tarballs

* Format artifact verification scripts

* Handle pnpm smoke-test arguments

* Harden validated package publication

* Make digest anchoring shell-portable

* Upload release artifacts before tests

* Anchor release integrity checks to GitHub SHA
2026-08-03 03:53:42 -04:00
wyuc 90f5f3942b fix(release): dedupe the published dsl, close two release-path blind spots (#1019)
* fix(release): dedupe the published dsl, close two release-path blind spots

Three independent defects on the release path.

1. `@openmaic/storage`, `@openmaic/renderer` and `@openmaic/importer`
   declared `"@openmaic/dsl": "workspace:*"`, which pnpm publishes as an
   EXACT pin. The tarballs on the registry today say storage@0.1.1 needs
   dsl 0.5.0, renderer@0.0.3 needs dsl 0.4.0 and importer@0.1.1 needs dsl
   0.4.0, so installing storage together with renderer yields two copies
   of the dsl. The dsl carries the schema, the validators and the version
   constants, so a document produced against one copy can be validated by
   the other's schema revision.

   `workspace:^` publishes as `^0.5.0`. Under 0.x that admits 0.5.x, so a
   dsl patch reaches dependents without republishing them while a dsl
   minor still requires a deliberate dependent release. All three
   packages take a patch version increase, both because
   check-package-version-bumps.mjs requires one and because the corrected
   range only reaches consumers through a new release.

   The tarball smoke test now asserts the packed range, so an exact pin
   cannot come back unnoticed.

2. The publish workflow's build-integrity guard ran `git diff --quiet --
   packages/@openmaic`, which compares the worktree to the INDEX. The
   steps before it run third-party package code, which needs no
   credential to rewrite a tracked file and `git add` it, after which the
   guard reported clean and the rewritten bytes were packed and
   published. Both copies of the guard now compare against HEAD, the
   commit actually being released.

3. `@openmaic/storage`'s PostgreSQL contract suites refuse to skip when
   STORAGE_PG_CONTRACT_REQUIRED=1, but that refusal is a `throw` inside
   the test modules, so it never fires if vitest stops collecting them.
   Collection is decided by `packages/@openmaic/storage/vitest.config.ts`,
   which is on the ignore list of publishable inputs, so excluding
   `*.pg.test.ts` there turns both PostgreSQL backends off with a green
   run and no version bump.

   scripts/assert-pg-contract-suites.mjs reads vitest's json results and
   requires both suite files to be present, passed and non-empty, keyed
   on the file names rather than on a count. It runs in the publish
   workflow's validate job and in the storage PostgreSQL contract
   workflow.

Merging this publishes @openmaic/storage@0.1.2, @openmaic/renderer@0.0.4
and @openmaic/importer@0.1.2. That republish is the fix for (1), not a
side effect of it: the corrected dependency range exists only in a
tarball that has not been cut yet.

* fix(release): audit the contract database, pin the format version to a minor

Follow-up on cross-vendor review of the three release-path fixes.

1. The PostgreSQL assertion proved far less than it claimed. Vitest's
   `assertionResults` are test CASES, not `expect()` calls, so one
   `test('x', () => {})` per required filename satisfied it — and the
   whole surface is reachable without a version bump, because `test/` is
   on the same ignore list as `vitest.config.ts`. A `vi.mock('pg', ...)`
   in the already-wired `test/setup.ts`, or a stubbed suite body, made it
   print "ran against a real database" having checked nothing of the
   sort.

   The script now runs in two phases. Phase 1 keeps the file/status
   checks. Phase 2 connects to PG_CONTRACT_URL from outside the vitest
   process and requires all five tables the two backends own to exist and
   to show inserts in pg_stat_user_tables. Nothing inside `test/` can
   forge that. Cumulative insert counters rather than surviving rows,
   because the suites clean up after themselves. The success message now
   states only what was checked.

2. Validation packed with lifecycle scripts while publishing runs
   `--ignore-scripts`, so committing `workspace:*` plus a `prepack` that
   rewrites it to `workspace:^` passed the tarball assertion and then
   published the exact pin. Both the smoke test and the workflow's
   dry-run pack now pass `--config.ignore-scripts=true`, so validation
   and publication have identical lifecycle semantics.

3. `workspace:^` unpins the serialized-format version that the exact pin
   was silently holding. DSL_VERSION and RUNTIME_DSL_VERSION are
   decoupled from the npm version by design, and storage compares them by
   value across the package boundary. Under `^0.5.0` a dsl PATCH could
   therefore hand a new format to an already-published storage: one
   install resolves it and stamps the new version into document_stages,
   another install pinned to the older patch reads that row and hard-
   fails. Two installs of one published storage version, data-
   incompatible.

   check-package-version-bumps.mjs now requires a change to either
   constant to carry at least a MINOR increase of the dsl package
   version, since a caret does not cross a 0.x minor. The rule is
   documented next to both constants. That doc edit is itself a
   publishable change, so dsl takes a patch bump to 0.5.1.

4. The range and build-integrity guards ran only in the publish workflow,
   so a pull request restoring `workspace:*` or leaving a stale generated
   file failed at release time, after a version number had been spent.
   ci.yml gains a cheap source-level range check
   (scripts/check-internal-dependency-ranges.mjs) and the
   `git diff --quiet HEAD` integrity check after its install step. The
   tarball assertion stays where it is as the release-time proof of
   actual published behaviour.

Merging this now publishes @openmaic/dsl@0.5.1 as well as
@openmaic/storage@0.1.2, @openmaic/renderer@0.0.4 and
@openmaic/importer@0.1.2.

* fix(release): fail the gates closed, and state what they actually prove

Confirmation round on the release-path fixes.

1. The format-version rule failed open if version.ts moved. It hard-coded
   one source path and returned successfully when either revision lacked
   it, so renaming the file, changing DSL_VERSION and bumping dsl by a
   patch passed both checks. It now reports an error whenever the file is
   absent at either revision while dsl has any publishable change. The
   constant regex is anchored to the start of a line, so a commented-out
   declaration cannot be read as the value, and a duplicate declaration is
   an error rather than a coin flip.

2. "At least a MINOR" was wrong past 1.0.0: `^1.0.0` admits minors, so a
   format change shipped as a minor would float into published dependents
   exactly as the rule exists to prevent. The rule is now stated as what
   it needs to be — an increase the dependents' caret range will NOT admit
   — computed from the pre-change version: a minor while dsl is 0.x, a
   major once it reaches 1.0.0. The error message and the docs in
   version.ts say the same thing.

3. Internal declarations outside `dependencies` bypassed both range
   checks. Keeping `dependencies` at `workspace:^` while adding an exact
   `peerDependencies` or `optionalDependencies` entry published a second,
   tighter constraint and passed. An owned package must now appear exactly
   once, in `dependencies`; entries in the peer and optional fields are
   rejected outright, in the source check and in the packed-manifest
   assertion. The global count test is replaced by naming the three
   dependents that must be present.

4. The format rule compared against the base tip, so an un-rebased branch
   produced an inverted message. It now uses the merge base, matching the
   `base...HEAD` form the rest of diff mode uses. Deliberately not applied
   to the version comparison, where the tip is the correct reference.

5. Phase 2 had no baseline, so cumulative counters from an earlier run
   satisfied it forever against a non-ephemeral database. It now captures
   the counters before the vitest step and requires a positive delta.
   Both workflows gained the capture step.

6. Phase 2 overclaimed. It proves rows were inserted into those five
   tables during the run, not that the built PgDocumentStore and
   PgRuntimeStore inserted them — `test/` is on the publishable-input
   ignore list, so test code could write directly. The success message now
   says exactly that, and the script documents the threat model: this
   catches accidental silencing, which is the defect it was written for;
   it is not a defence against someone who can merge changes to `test/`,
   who can alter production code just as easily.

Also: scripts/openmaic-packages.mjs is now the single source for the
owned package list, consumed by all three checking scripts, and it
cross-checks itself against both the packages directory and
publish-packages.yml so a package added in one place and forgotten in
another fails rather than going quietly exempt. The workflow's own YAML
lists cannot import it, which is why they are cross-checked instead.

* fix(release): let the format-version source move without opening the gate

Failing closed on a missing version.ts is right, but on its own it made a
rename impossible to merge: the new path does not exist at the base
revision either, so the check refused both before and after the move.

DSL_VERSION_SOURCES is now an ordered candidate list, resolved per
revision against the first path that exists there. A rename lands by
prepending the new path in the same change, and the comparison still
happens across the move. Absent everywhere at either revision, while dsl
has any publishable change, remains an error.

* fix(release): report package-list drift instead of crashing on it

Every check in check-internal-dependency-ranges.mjs reads the shared
package list, so a list naming a package that does not exist threw an
ENOENT out of readManifest before any finding was printed. It failed
closed, but unreadably. The list is now validated first and fatally, with
its own headline.

* fix(release): anchor the caret to the base branch, and check the gates' own inputs

Final confirmation round.

1. The caret escape was computed from the merge base, which is the wrong
   reference for it. With merge base dsl 0.5.1, an ordinary minor landing
   on main taking the tip to 0.6.0, and HEAD at 0.6.1 with a changed
   DSL_VERSION, the rule computed the escape from 0.5.1, got 0.6.0, and
   accepted 0.6.1 — but dependents released against 0.6.0 publish
   `^0.6.0`, which admits 0.6.1, so the new format reached them anyway.
   0.6.1 is also the minimum the ordinary version check allows, making
   that the default outcome rather than an unlucky one.

   The escape is now computed from the HIGHEST dsl version reachable on
   the base branch. Whether the format moved still comes from the merge
   base, because that is a question about the branch. The comment on
   mergeBaseWithHead now records that it is a no-op in both current CI
   invocations (checkout uses the merge ref on pull_request, and the
   before-SHA is an ancestor on push) rather than claiming a case that is
   not exercised there.

2. The workflow cross-check matched raw text, so a commented-out trigger
   path still satisfied it while silently disabling that package's
   release. Full-line comments are now stripped first; the `on.push.paths`
   list and every `for pkg in ...` loop are compared as exact sets, so an
   unexpected entry fails as well as a missing one; and `--filter` usage
   is checked more loosely, because individual steps legitimately filter
   subsets. The limits of a textual check are stated where it lives.

3. `locate()` returned the first candidate format-version source that
   existed, so during a half-finished rename the gate could compare a file
   that is no longer the exported one. It now resolves every candidate per
   revision and fails unless exactly one declares the constants.

4. INTERNAL_DEPENDENTS was checked in one direction only, so deleting an
   entry exempted that package from the source check and from the packed
   assertion that iterates the same map. It is now cross-checked both
   ways, an owned package in another owned package's devDependencies is
   rejected outright, and multiple owned dependencies no longer collapse
   to whichever was declared last.

5. assertPackageListIsComplete() now also runs in release mode, which is
   the gate that decides what gets published; and the PostgreSQL script
   consumes flag values properly instead of treating the first non-flag
   argument as the results path.

Also documents, in openmaic-packages.mjs, the threat model these gates
share: they catch mistakes, and anyone who can merge edits to scripts/
can edit production code directly, so deliberate subversion is out of
scope and cannot be closed by adding further checks.
2026-08-02 22:44:34 -04:00
wyuc 091989c48b feat(storage): promote KV HTTP contract + clients to main (Part A of #1000) (#1020)
* feat(storage): KV HTTP contract and clients (#1000 Part A) (#1002)

* feat(storage): KV + Asset HTTP contracts and clients

Adds the client half of the server backends for the two components in the
storage RFC that still had a browser backend only.

Asset contract: the ref is `sha256-<hex>` over the bytes, computed by one
shared rule both backends call, and it *is* the address bytes are written
to (`PUT /assets/{ref}`). The server re-hashes what it received and refuses
a mismatch, so content addressing is enforced end to end rather than
assumed, and uploads become idempotent. `resolve` returns a URL per call,
either an absolute signed URL passed through untouched or a root-relative
proxied path joined to the base origin — the document never stores a raw
URL either way.

KV contract: account scope only, with no scope path segment, query
parameter or body field anywhere on the wire. `HttpAccountKV` is the only
object that can reach it and none of its methods takes a scope, so a
device value is not a request the transport can express; `HttpKVStore`
routes `device` to a local backend it requires at construction. Tests
assert the invariant both ways — `@ts-expect-error` on every scope-passing
call (unused directives fail the build if a scope is ever added) and a
fetch spy proving device traffic does not exist.

Both clients pass the existing `runKVStoreContract` and
`runStorageProviderContract` suites against an in-memory conformance
server, the asset one in both the proxied and signed URL shapes.

* fix(storage): close the review findings on the KV + asset HTTP clients

Cross-review found the device-scope guarantee resting on its weakest layer,
and several places where the contract promised more than the code or the
conformance server could deliver.

deviceStore: the two type-level layers only documented intent — the barrier
that actually kept device values off the wire was the injected store, typed
as `KVStore`, which `HttpAccountKV` satisfies structurally (its methods are
the same methods minus an optional parameter). Injecting the transport as
the device backend type-checked and put device values on the network. The
device scope now requires a branded `LocalKVStore`, the transport declares
itself not to be one, and a runtime check refuses what a cast smuggles past.

resolve URLs: a media `src` carries cookies, never the client's headers
hook, so the proxied shape is only sound for cookie-authenticated
deployments — the contract now says so instead of claiming the content route
sits behind "the same authentication", and the conformance reader uses a
cookie rather than a header no browser could send. The client also validates
what it resolves: http(s) schemes only, and no `//host` or `/\host` slipping
through the relative branch to another origin.

Identifiers: refs and keys arrive percent-decoded, so `..%2F..%2F` was a
traversal waiting for the first backend to join it to a path. Both are now
constrained on both sides, and the contract states the MUST that keeps a
storage layer from being the first to look.

Also: the JSON gate runs before anything reads the value (a `JSON.stringify`
pre-flight ran caller code first and reclassified `{ toJSON: () => undefined }`
as a delete); scope routing fails closed; an upload's Content-Type can no
longer be relabelled by the headers hook; a mutation invalidates in-flight
resolution; unparseable 2xx bodies become typed errors rather than native
SyntaxErrors; signed tokens are validated rather than merely present; and the
contracts settle the per-principal claim model, the media-type allowlist, the
literal prefix rule, and the identifier charset that Part B would otherwise
have had to guess at.

* fix(storage): close the second review round on the KV + asset HTTP clients

The device-scope guard was in one place and needed to be in three. The
transport is exported and structurally a `KVStore`, so `kvPersistStorage(new
HttpAccountKV(...), 'device')` and a plain `KVStore`-typed reference both
compiled without a cast, dropped the scope argument on the floor, and put a
device value on the wire. The transport now takes the scope it was pretending
not to have — typed as `'account'`, refused at runtime for anything else, since
bivariant method parameters mean the type alone cannot hold — and the persist
adapter requires a `LocalKVStore` when paired with `'device'`.

The resolved-URL checks were text tests where only parsing works. The URL
parser strips ASCII tab, LF and CR before parsing, so `/<tab>/evil/x` walked
past the `//` prefix check and loaded cross-origin — in the app-mounted shape,
where a path-only base URL leaves that check as the only guard. The absolute
branch let through `https://` with no host and `https:host/x`, which names one
place standalone and another when a browser resolves it against a same-scheme
page. Both branches are now decided by the parser: control characters refused
outright, relative references validated against a probe origin, absolute ones
required to parse as `scheme://host` with an http(s) scheme — and still
returned byte-for-byte, so signatures survive.

The media-type MUST had no implementation and contradicted itself: the
conformance server stored and served `text/html` verbatim, and a metadata-less
upload became `application/octet-stream`, which cannot be on a renderable
allowlist. The server now implements the allowlist, serves everything else as
an opaque attachment, and the contract scopes its "usable as an img src"
promise to allowlisted types.

The KV key domain was HTTP-only, which made "the same suite proves the backends
equivalent" untrue: `a/b`, an empty key and a 600-byte key all passed in the
browser and would have become unreachable on a server. An audit of the app's
keys (learner key, storage generation, current-scene and migration markers,
persist names) found none in violation, so the rules are lifted to the
primitive and both backends enforce them, with the shared suite asserting it.

Also: the claim model the last round wrote into the contract is now implemented
and covered case by case; malformed percent-escapes are a 400 rather than a
500; the in-flight identity guard has a test that can fail; and the Content-Type
conflict raises a typed error.

* fix(storage): final review round — backend parity, URL probe, credentials

Four small things the final pass found, plus the contract patches Part B would
otherwise inherit.

The `toJSON` delete semantics were documented backwards. `BrowserKVStore.set`
was the trial-serialize the KV contract said no backend does, so
`{ toJSON: () => undefined }` was a silent delete there and a rejection over
HTTP — opposite answers to the same call. The browser backend now validates
scope and key before anything can run caller code, recognizes the delete case by
type, and refuses a value that still fails to serialize. Both cases are in the
shared suite, so the two backends cannot drift apart again.

The probe-origin check could not vouch for a URL naming the probe host itself:
`//asset-url-probe.invalid/x` resolves onto the probe origin and passed, then
resolves somewhere else entirely against a real base. Any authority-bearing
form is refused outright, so the check no longer depends on the probe host
being unguessable.

Cross-origin cookie deployments had no way to send a cookie: `fetch` omits
them cross-origin by default and the headers hook cannot help, since `Cookie`
is a forbidden header name. Both clients accept `credentials` and pass it to
`fetch`; the contract records the CORS obligations that come with it. Rather
than restricting cookie deployments to same-origin, which the asset contract
explicitly does not require.

The conformance server's dot-segment check was reading a path the URL parser
had already normalized, so `/assets/%2e%2e/x` arrived as `/x` and the documented
400 was really post-routing behaviour. It now validates the raw request target,
and the injected transport preserves it instead of handing over a normalized one.

Contract: the prefix rule names backslash (PostgreSQL's default LIKE escape,
and the one an implementer skips because a legal key cannot contain it, though
a prefix can), and acquiring a claim MUST leave the bytes present — a
de-duplicating server that inserts only the claim can race reclamation of the
last previous claim and leave a document resolving to a 404.

Also: base URLs are checked for an http(s) scheme at construction, the ref
domain moved next to the KV key domain and is enforced by both asset backends
with shared suite coverage, the KV client refuses a hook-supplied Content-Type
like the asset one, and CI runs the package typecheck that makes the
`@ts-expect-error` guards mean something.

* fix(storage): probe guard spelling, prefix validator, base url shape

Three one-line-level holes from the closing review.

The probe-host guard was spelled `startsWith('//')`, so the backslash forms
`/\host/x` and `/\/host/x` reached the origin comparison, passed it by naming
the probe host, and were stopped only by that host failing to resolve. It now
refuses any second character that can open an authority — which is also what
makes the comment true rather than aspirational: a path-absolute reference
always inherits the base's origin, so with no authority expressible the
comparison cannot be satisfied cross-origin, whatever the probe host is.

The conformance server validated `?prefix` with the path-segment validator,
rejecting `keys('.')` and `keys('..')` — legitimate prefixes of keys like
`.hidden`, which the client rightly allows. A prefix travels in the query
string and is never a path segment, so it now has its own validator: the key
rules minus the non-empty and dot-segment requirements. The contract says the
same, and the shared suite pins both backends to it.

Base URL validation moved into one shared helper both clients call, since two
copies of a security rule is how they drift. It now also refuses
`https:host/path` — which parses standalone to that host but is resolved
against the current document by `fetch`, sending requests and their credentials
somewhere the operator never wrote — and a base carrying a query or fragment,
which silently swallows the path concatenated onto it.

* fix(storage): close two holes in the shared base-url check

The path-only branch returned its input unexamined, so `//evil.example` and
`/\evil.example` were accepted as paths. A browser reads both as an authority,
which means every request built on such a base — and every credential the
headers hook attaches — leaves the origin. A base is now required to be a
single `/` followed by something that is not `/` or `\`, the same refusal the
resolved-URL check already makes, for the same reason. Control characters are
refused first: the parser strips or re-encodes them, and `/<tab>/evil.example`
becomes `//evil.example` after it does.

The query/fragment check read the parsed `search` and `hash`, which are empty
strings for a base ending in a bare `?` or `#` — so exactly the inputs that
swallow the concatenated path into a query or fragment were the ones that got
through. It reads the raw string now, which also makes the post-parse check
redundant rather than merely unreachable.

* fix(storage): bypass HTTP caches on reads, and require a 404 for not-found

Two mechanisms the clients were missing, on both sides.

Reads are marked `cache: 'no-store'`. The asset resolve request answers two
mutable questions — does this principal hold a claim, and which URL is valid
now — so a cached answer returns an expired signed URL or replays a negatively
cached 404 for an asset written since; the in-flight coalescing that would
otherwise cover this lives in the client and cannot reach the HTTP cache layer.
KV reads have the same problem for a different reason: `account` is by
definition the scope another device may have just written, and nothing here can
invalidate a cached copy when it does. The contracts now require `no-store` on
those responses. Asset content is left cacheable — bytes at a content-addressed
ref never change — and writes are unmarked, having nothing to read from a cache.

The not-found mappings now require the status as well as the code. A proxy or
gateway answering 401, 403 or 500 while echoing the body's error code was being
read as "this does not exist", and `null` is indistinguishable from a real
miss — so an outage reached the caller as data loss.

* fix(storage): reject unusable base urls, and make the harness meet the contract

Two configuration checks and two harness gaps.

A base URL carrying userinfo built a client that threw a native TypeError on
every operation, because Fetch refuses to construct a request from a URL with
embedded credentials — one legible error where the deployment is configured
beats a per-call failure with no explanation. A base with surrounding
whitespace was worse than useless: `new URL` strips it, so validation passed,
and then concatenation moved the space inside the URL where it is no longer
trailing and no longer stripped, breaking every request.

The conformance server now sends `Cache-Control: no-store` on the three reads
the contracts require it on. The requirement landed last round with nothing
asserting it, so the suite would have passed against exactly the implementation
it exists to catch. And `DELETE /kv/entries/{key}` refuses a request body
outright: the query and the header were already checked for a smuggled scope,
leaving the body as the one channel through which a delete could still describe
one.

* fix(storage): allow a device-routing composite to persist device-scoped state

cosarah found kvPersistStorage(httpKVStore, "device") wrongly rejected. An
HttpKVStore is a full dual-scope composite: it routes device operations to the
LocalKVStore it requires at construction and only reaches the network for
account, so device values never leave the machine. The guard tested
isLocalKVStore — "is the whole store local" — and so turned away a store that
is safe for device precisely because it is not fully local.

The guard now turns on the capability that actually matters: does the device
scope stay local. A new DeviceSafeKVStore brand (servesDeviceScopeLocally: true)
marks a store whose device writes never leave the device — held by a fully-local
LocalKVStore (which extends it) and by HttpKVStore, and declared false by
HttpAccountKV, the pure account transport with no local device backend. The
persist device overload requires DeviceSafeKVStore; the account overload is
unchanged.

The security boundary the red team opened this whole line on is untouched:
HttpAccountKV used for device is still doubly refused — its false brand is not
assignable to the true DeviceSafeKVStore requires (type), and the runtime check
rejects the cast that would erase it. The brand is structurally unforgeable, not
a flag the transport could set; and the injected device backend of a composite
still demands the stronger, fully-local LocalKVStore, so composites cannot nest
into a router loop with no local floor.

Regression test (cosarah's ask): kvPersistStorage(httpKVStore, "device")
set/get/remove reach the injected device backend, land device-scoped there, and
make zero HTTP requests. Mutation-checked: widening the brand so HttpAccountKV
is accepted, or neutering the guard, turns the device-out reject test red.

* refactor(storage): scope #1002 down to KV — remove the asset HTTP backend

Per the maintainer decision, the AssetProvider server design moves to the
global-resource-pool RFC (#1007), where AssetRef changes from content-addressed
to an allocated global id and the contract model itself changes. Continuing to
harden the content-addressed asset client here would be work on code that is
being replaced, so the asset half of #1002 is withdrawn and the PR is pure KV.

Removed (all introduced by #1002):
- docs/asset-http-contract.md
- src/asset/http.ts (HttpAssetProvider) and its index / package.json exports
- src/asset/content-ref.ts (assertAssetRef / computeAssetRef / the ref shape)
- test/http-asset-provider.test.ts and the asset half of the conformance server

Reverted to the #858 baseline, untouched:
- src/asset/browser.ts — back to opaque resolve/remove, no assertAssetRef guard.
  This directly removes cosarah's latest asset finding: an opaque ref such as
  `bucket/path` no longer throws, restoring the DSL AssetRef-opaque contract.
- test/asset-contract.ts, test/asset-browser.test.ts — 0-diff from origin/main.

Kept (all KV): kv-http-contract.md, src/kv/* (HttpKVStore / HttpAccountKV /
the DeviceSafeKVStore brand), the KV tests, and the KV conformance server —
renamed kv-conformance-server.ts and reduced to KV routing over the shared
harness framework (body caps, no-store, scope-channel and dot-segment guards,
auth hooks). base-url.ts stays; KV still uses it.

* fix(storage): treat KV keys as opaque; document account-sync prerequisites

cosarah found the key validation we added for traversal defense breaks
legitimate callers: DSL identifiers (stageId/sceneId/learnerKey) are
unconstrained strings — verified, the DSL only requires typeof === 'string' —
and app callers compose keys like `editor-current-scene:<stageId>`, so a valid
stage id `stage/one` now throws in BrowserKVStore before localStorage.

Fix, matching the asset-opaque precedent: a KV key is an opaque string again.
The `/` and `\` rejection is removed from assertKVKey / assertKVKeyPrefix and
from the conformance server. Traversal is defended by encoding, not rejection —
the HTTP client already percent-encodes the whole key into one path segment, so
a separator never opens a new segment on the wire, and the server stores the
decoded key as a plain map key (bound value), never a path. `a%2F..%2F..%2Fb`
decodes to the single opaque key `a/../../b`, which addresses one entry and
traverses nothing; a raw unencoded `..` path produces extra segments that match
no route (404). The rules that remain are only what encoding cannot cover and
the transport cannot carry (empty, whole-key `.`/`..`, NUL, lone surrogate,
length) — none producible by `prefix + id`.

Tests: the shared suite now round-trips keys with `/`, `\`, `%`, `:`, spaces
across both backends; a `stage/one` key round-trips through the real conformance
server; the traversal input is proven to store as one opaque key and the raw
path to 404. Mutation-checked: re-adding the `/` rejection reddens exactly the
round-trip cases.

Docs (kv-http-contract.md):
- "A key is an opaque string" replaces the identifier-shape section — keys may
  contain separators; traversal is an encoding + server-bound-value duty.
- Account settings sync now documents its security prerequisite next to the
  account scope: a deployment MUST provide real per-user isolation of stored
  rows and encryption at rest before enabling sync; the reference server is not
  that posture.
- kvPersistStorage(device) doc corrected from LocalKVStore to DeviceSafeKVStore
  (the device-safe brand accepts the HttpKVStore composite; HttpAccountKV stays
  double-rejected).

* fix(storage): close the scope/key/prefix contract gaps with a coverage matrix

Three completeness gaps, nailed by an executable matrix so an untested
channel/shape can't slip through the next pass rather than patched one by one.

1. Scope via a GET body was not rejected. `assertNoScopeChannel` covered the
   query and header, but the bodyless GET routes never read their body, so
   `GET /kv/keys` with `{"scope":"device"}` returned 200 []. Bodyless methods
   (GET, DELETE) now reject any request body up front — that is the channel a
   scope would hide in — and only `set` reads a body, checking it for a scope
   field. The DELETE route's own body check folds into the shared one.

2. The 512-byte key bound wrongly rejected legitimate keys. A DSL id is an
   unconstrained string (verified: the DSL requires only typeof === 'string'),
   and `editor-current-scene:` + a 500-char stage id is 521 bytes, which
   BrowserKVStore rejected before localStorage. Keys are length-unconstrained;
   the bound is raised to a generous 8 KiB DoS ceiling and documented as a
   resource guard, not part of the key domain — not a derived/hashed key, which
   would break the literal-prefix semantics of keys(prefix). Both backends and
   the conformance server move in lockstep.

3. The prefix contract text contradicted itself: it claimed a legal key can
   never contain a backslash, while the key section (correctly) makes keys
   opaque, backslash included. Reworded — a prefix is opaque like a key — and
   the shared literal-prefix matrix now covers `\` (PostgreSQL's LIKE escape),
   which it had skipped, alongside `%`, `_`, and combinations.

Coverage matrix (CI-executable):
- scope-rejection: {get,set,remove,keys} × {query,header,body}. query/header
  run in-process; the body cells use a raw HTTP client (fetch cannot put a body
  on a GET — exactly the non-conforming client the finding described) against a
  loopback listener, skipped where the sandbox forbids binding.
- key-validity: `/ \ % _ : .` space, BMP + astral Unicode, `%2F`, a 500-char
  composed key, and all-at-once — each round-trips (set/get, keys(prefix)
  match, remove) on both browser and HTTP backends via the shared suite.
- prefix-charset: `%`, `_`, `\`, and combinations match literally, both backends.
- Boundaries still rejected: whole-key `.`/`..`, NUL, lone surrogate, empty,
  and the DoS ceiling.

Mutation-checked: removing the bodyless-body rejection reddens the three GET/
DELETE body cells; lowering the ceiling back to 512 reddens the long-key
round-trip on both backends.

Bumps @openmaic/storage to 0.1.2 (src/kv/* changed).

* fix(storage): bound the KV key DoS ceiling by encoded size, not UTF-8 bytes

Follow-up to the 8 KiB ceiling: measuring it in UTF-8 bytes ignored
percent-encoding expansion and reopened a backend-parity break. A non-ASCII key
is small in bytes but large on the wire — `€` is 3 UTF-8 bytes and `%E2%82%AC`,
9, encoded — so `'€'.repeat(2730)` (8190 bytes, under the old cap) encoded to
~24.6 KB, which the Node HTTP server rejects with 431 before routing while
`BrowserKVStore`, which never builds a URL, accepted it.

The ceiling is now measured on `encodeURIComponent(key).length` — the size the
key occupies as a request target — at 4096, far above any real id (a 500-char id
under `editor-current-scene:` encodes to ~523) and well below Node's default
16 KiB `maxHeaderSize` (empirically the 431 trigger on Node 22), so no key that
passes validation can reach a 431. Both backends and the conformance server use
the same encoded-size limit, so a key is accepted iff its encoded form fits,
whichever backend it reaches — parity restored. `MAX_KV_KEY_BYTES` becomes
`MAX_KV_KEY_ENCODED_LENGTH`.

Tests, against a real node:http server for the 431 boundary:
- the max-legal non-ASCII key (455×€, encoded 4095) round-trips over real HTTP
  with no 431, and the browser accepts the same key — parity on accept;
- one code point over (456×€, encoded 4104) is rejected by both backends at
  validation, before any request — the exact parity case a byte bound missed
  (1368 bytes, would have passed, then 431'd over HTTP);
- a raw client bypassing client validation with an over-ceiling (under 16 KiB)
  encoded key gets a clean 400, not a 431.
Shared suite's key-domain matrix now bounds by encoded size on both backends.

Mutation-checked: reverting the ceiling to UTF-8 bytes reddens the three
encoded-length parity tests. Bumps @openmaic/storage to 0.1.3 (src/kv/* changed).

* fix(storage): make the KV key domain truly opaque; keep transport limits out

The recurring key-limit findings (512 → 8 KiB → 4096, each patch exposing the
next) shared one root cause: transport-driven rules kept being added to the key
domain — the browser primitive included — breaking the "opaque unconstrained
string key" contract. This is the structural fix, per cosarah: "keep HTTP
transport limits out of the browser primitive and preserve the existing string
key domain."

Approach (a), the minimal one: a KV key is any string, validated nowhere. The
browser primitive is a Map/Storage that stores any key; the HTTP backend carries
the key as a URL path segment and inherits that transport's limits, documented
as HTTP-deployment concerns that never constrain the key domain. Every real key
(a `prefix:id` from an unconstrained DSL string) round-trips on both backends;
only pathological keys hit a transport limit, and none of those are keys a caller
produces. (b) — keys in the request body for accept-parity — was unnecessary:
verified that no real key needs it.

Removed from the key domain (browser + HTTP + conformance server):
- the encoded-size / length ceiling entirely — there is no key-length limit;
- the NUL exclusion — NUL percent-encodes to %00, is preserved end to end, and
  is a legitimate Map/Storage key (a false "cannot represent" premise);
- the `.`/`..`/empty/`/`/`\` rejections — all opaque, all round-trip.
`assertKVKey` / `assertKVKeyPrefix` / `MAX_KV_KEY_*` are gone.

Kept, at the transport layer only (HttpAccountKV), never reaching the primitive:
- an unpaired UTF-16 surrogate genuinely has no percent-encoding, so the HTTP
  client surfaces a clear transport error (KEY_NOT_ENCODABLE) while the browser
  stores the same key — verified: encodeURIComponent throws on it.

Scope completeness (P2): the "reject scope in every channel" guarantee had two
holes — a scope path segment (`/kv/device/keys`) 404'd instead of 400, and only
`x-scope` was checked among header spellings. The server now rejects a scope
path segment and the whole prohibited header set (scope, x-scope, kv-scope,
x-kv-scope). The coverage matrix is {get,set,remove,keys} × {path, query, each
header spelling, body}.

Coverage matrix reworked to verify arbitrary opaque keys (empty, NUL, control
chars, separators, long, Unicode) round-trip on both backends — not to verify a
limit. A real node:http test proves NUL and long keys round-trip end to end, and
that a lone surrogate is a transport limit (browser accepts, HTTP client errors).
Mutation-checked: re-adding a NUL rejection, removing the path-segment check, or
dropping a header spelling each reddens the matching cells.

Cleanup: package.json description updated (KV HTTP delivered, not "later");
network-server bind/skip/close lifecycle deduped into one helper. Bumps
@openmaic/storage to 0.1.4 (src/kv/* changed).

* fix(storage): reject whole-key "."/".." in the HTTP client — they alias entries

Follow-up to making the key domain opaque: removing the `.`/`..` key-domain
rejection was right (the browser stores them fine), but the HTTP client then let
them reach `fetch`, where URL path normalization silently rewrites the target
before it is sent. `encodeURIComponent('.')` is `.`, so `/kv/entries/.` collapses
to `/kv/entries/` (the empty key) and `/kv/entries/..` walks up a level — so
`get`/`set`/`remove` on a whole-key `.` or `..` silently read, overwrite, or
delete a *different* entry, with no error. A data-correctness bug.

Fix, same pattern as the unpaired-surrogate limit already handled: a whole-key
`.`/`..` is something the URL-path transport structurally cannot carry, so the
client's path-segment encoder refuses it with `KEY_NOT_ENCODABLE` before a
request is built. Split the encoder in two: `encodeKeyPathSegment` (key → path
segment, rejects whole-key `.`/`..`) and `encodeComponent` (the `keys()` prefix →
query value, where `.`/`..` are legitimate and untouched). A key that merely
*contains* a dot (`a.b`, `prefix:id`) is unaffected and round-trips. The browser
primitive still stores `.`/`..` opaquely.

The HTTP transport's limits are now complete — three cases, all fail-loud at the
transport layer, all storable on the browser: (1) an unpaired surrogate (no
percent-encoding), (2) a whole-key `.`/`..` (URL normalization aliases it), and
(3) an over-long key (deployment request-target ceiling). The contract lists all
three as HTTP-deployment limits, not key-domain constraints.

Tests: whole-key `.`/`..` — browser stores and round-trips (distinct from the
empty key); HTTP client throws `KEY_NOT_ENCODABLE` and builds no request; and a
real-server test asserts the empty-key entry is never read, overwritten, or
deleted by a `.`/`..` operation. A dot-containing key (`a.b`) round-trips.
Mutation-checked: dropping the whole-key check reddens the alias tests.

Bumps @openmaic/storage to 0.1.5 (src/kv/* changed).

* fix(storage): reconcile the settings/profile persist consumer with the KV API

The integration merge (#1002 KV into main) surfaced two things the merge itself
could not resolve mechanically:

- `lib/store/kv-persist.ts` (added on main by #1001) called
  `kvPersistStorage(kv, scope)` with a *variable* scope, which #1002's overloads
  deliberately reject — `'device'` must be paired with a device-safe store, so
  the overloads take the scope as a literal. Branch on the scope: `'account'`
  takes any `KVStore`; `'device'` narrows the backend to `DeviceSafeKVStore` via
  its brand (the adapter re-checks at runtime) and otherwise fails loud. The app
  wires only `'account'` today; the `'device'` arm keeps the generic helper
  honest. `ControllableKV` in the persist test wraps a local `BrowserKVStore`,
  so it is genuinely device-safe and now declares the brand.

- `packages/@openmaic/storage` version bumped 0.1.5 → 0.1.6: the merge changed
  storage inputs (README + a pg-schema test carried from main) relative to the
  previous integration tip, so the version-bump guard requires an increase above
  #1002's 0.1.5. Still ahead of main's 0.1.1.

Verified: root `tsc --noEmit` clean, the persist test green (40 passed), and the
storage package suite green on Node 22.
2026-07-30 22:31:17 -04:00
wyucandClaude Opus 5 cc95851dc5 chore(packages): publish storage and enforce version bumps (#998)
* chore(packages): publish storage and enforce version bumps

* fix(packages): cover all publish inputs and tarballs

* fix(packages): scope version guard to release inputs

* fix(packages): fail closed on new package inputs

* docs(packages): clarify version guard boundaries

* fix(packages): harden release guard edge cases

* fix(packages): align smoke peers and git inputs

* docs(packages): make release smoke policy explicit

* fix(packages): anchor version guard at repository root

* fix(packages): report version guard setup errors

* fix(packages): harden release validation

* fix(ci): validate complete main pushes

* test(storage): pin the exported PostgreSQL schemas

DOCUMENT_PG_SCHEMA and RUNTIME_PG_SCHEMA are public API. A deployment that
provisions these tables with its own migration tooling has to reproduce the
DDL exactly for ensureDocumentSchema() / ensureSchema() to stay the intended
no-op against an already-provisioned database.

Nothing guarded that today: every statement is CREATE ... IF NOT EXISTS, so
PostgreSQL silently accepts whatever table already exists under the name. A
column type, a nullability, an index or a FK action can drift apart from a
downstream migration without an error, and the first symptom is a store query
failing in production or succeeding against the wrong types.

Pin both constants verbatim, and assert every statement stays IF NOT EXISTS
guarded so the ensure functions remain idempotent. The pin does not judge the
DDL; it makes changing it impossible to do by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): gate publishing on the version check

The version check ran only in ci.yml. publish-packages.yml is a separate
workflow, so it could reach `pnpm publish` on a main push, a matching tag, or
a manual run with dry_run=false even when CI was red, skipped, or never ran.

Run the validation inside the publish job, before publishing, in a new release
mode that works for every trigger. A push range only exists for branch pushes,
so release mode judges each package against the registry instead: a version
that is not published yet is a release, and a version that is already
published is accepted only when the package source has not moved since that
release. `pnpm publish` skips an already-published version, so without that
second half a drifted package releases as a silent no-op.

The anchor for "since that release" is the @openmaic/<name>@<version> tag the
job now writes after every successful publish, falling back to the push range
for packages released before those tags existed. With neither anchor the
release stops instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): make the release gate prove only what it can

Three independent reviews of the previous revision converged on one root
cause: it used git tags and a push range as the record of which tree produced
a published version. Neither is trustworthy. Tags are mutable and can be
created or moved by hand, so "the tag resolves" was being read as "the tag is
authentic"; pnpm records no gitHead; and a push range describes one push, not
the origin of a release. Packages released before the scheme existed had no
anchor at all, which made the tag and manual triggers fail closed from the
first run with no way to self-heal, dry runs included.

Drop the anchor machinery. Drift is prevented where it is provable, in diff
mode at merge time, which sees every commit before it can be released. The
pre-publish gate is now limited to claims a release can establish: every
version it publishes is new and moves forward, an already-published version is
reported and left alone, and any registry answer that is not a definitive
404 stops the release.

Around it, close what the reviews found in the workflow:

- real publishes only from a commit contained in main, so a tag or manual run
  cannot ship an unreviewed ref while holding NPM_TOKEN
- one repository-wide concurrency group, so a main push and a tag push cannot
  each decide the same version is unpublished
- publish and mark each package individually, so a partial failure stays
  retryable instead of leaving published packages unrecorded
- fail if the build rewrites tracked package files, which would otherwise
  publish content that is not in the released commit
- compare against every published version when ordering, and refuse to guess
  when the registry holds versions this check cannot order

The schema pin also asserted the constants without asserting what the ensure
functions execute, so those could diverge. Both are now run against a
recording queryable and their exact statement sequence is asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): refuse to release from a tree behind the registry

Simplifying the gate introduced a hole that a mutation check caught: a package
whose local version is already published took the "already published, skip"
path before any ordering check, so a tree rolled back to an older published
version passed silently. That is not a harmless no-op, because pnpm publish
rewrites each workspace dependency to the version in the tree, and a sibling
released alongside it would be published declaring a dependency on the older
package.

Order every package against the registry first, then decide: at the highest
published version it is a legitimate skip, below it the release stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): couple publishing to a green CI run for the same commit

Second review round, on the reworked design. The headline defect was a hard
blocker: `git fetch --depth=0` is not valid git (that is an actions/checkout
convention), so under `set -e` every real publish aborted before the gate ever
ran. Remove the depth argument; the checkout already has full history.

The rest closes the gap both reviewers reached independently, that being on
main is not the same as having passed the gate:

- wait for this exact commit's CI conclusion and require success. ci.yml runs
  concurrently with this workflow on a push to main and blocks nothing, so an
  unpublished version whose source drifted could be released while its own
  version check was still running or already red.
- require the commit on main's FIRST-PARENT history. Plain reachability also
  accepts every intermediate commit of every branch merged with a merge commit.
  A pull request that adds a bad tree and reverts it in the next commit leaves
  that tree an ancestor of main forever, and a tag pointing at it published it.
- do not cancel CI runs on main. Each run validates only its own push range, so
  cancelling one drops the range that contained the change, and its replacement
  compares against the cancelled tip and sees nothing.
- run the version check on every main push. A branch creation or force push
  reports an unusable `before`, which used to skip the gate entirely; fall back
  to the first parent instead.
- publish with --ignore-scripts. Every package's prepublishOnly reruns the
  build, deleting and regenerating the dist that was just verified and
  smoke-tested, so npm could receive bytes nothing had checked.
- run storage's PostgreSQL contract suites before publishing it. They skip
  themselves without a database, so the one backend that needs a real
  PostgreSQL was shipping unexercised.
- order registry versions with real semver, prereleases included. Rejecting
  every non-x.y.z version meant one historical prerelease anywhere in one
  package's history would refuse every future release of every package.
- warn when a release marker exists but points at another commit.

Note what is NOT enforceable here: a workflow_dispatch or tag run executes the
workflow definition from the selected ref, so a branch that edits this file can
delete these checks. The boundary has to be a GitHub Environment holding
NPM_TOKEN behind a main-only deployment branch rule. The job now declares that
environment and the header states the required setup; the in-file checks are
defence in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): stop the first-parent check failing on SIGPIPE

Third review round. The blocker this time: `git rev-list --first-parent | grep
-qx` under `set -o pipefail`. grep exits at the first match, rev-list then dies
of SIGPIPE, and the pipeline returns 141, so a commit that IS on main is read
as "not on main" and every real publish is refused. It does not reproduce in a
shallow clone, where main's history fits in the pipe buffer; with the full
history this job checks out it always will. Count matches instead, which reads
the whole stream.

Also from this round:

- identify the CI run by workflow file and triggering event rather than by a
  check-run display name. Names are not unique, so any other workflow or app
  publishing a check called "Lint, Typecheck & Unit Tests" could stand in for a
  red version gate.
- fail the main-push version check when the push range is unusable instead of
  substituting HEAD^. A force push can replace many commits at once, so the
  previous commit is not the range that needs checking, and a green run here is
  what publishing depends on.
- correct the stated limitation. It claimed the package-directory input model
  was exact for dsl and storage; it is not. Their dist is whatever the
  lockfile's TypeScript emits, and dsl's shipped JSON schema comes from the
  lockfile's ts-json-schema-generator, so a toolchain bump can change any of the
  four tarballs with no diff under the package directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(packages): make a merged version bump the only release input

The tag trigger was incoherent. Its own documentation admitted that any
@openmaic/* tag republishes the whole family and that which packages actually
go out is decided by the manifests, so the tag name never participated in the
decision. It was a second entry point to one decision function, it bypassed
review entirely because a tag is not a diff and can be created by anyone with
write access on any commit, and it cannot be guarded: environment deployment
rules match GITHUB_REF, so refs/tags/* and a main-only rule are mutually
exclusive.

Drop it. A version bump that landed on main is now the only release input, and
`@openmaic/<name>@<version>` tags are an output written after a package
reaches the registry. Nothing is lost: a manual dispatch from main still
republishes without a new commit, and that path is inside the protected
environment.

Split the workflow so the token has a boundary. `validate` holds everything
that does not need NPM_TOKEN and runs from any ref, which keeps dry runs
useful to contributors. `publish` is the only job declaring the `release`
environment, so the token is never attached to a run that is merely
validating. It rebuilds rather than sharing state, which is the right trade
for a release.

Also add the `actions: read` permission the CI-conclusion query needs; it was
switched from the checks API to the Actions API without updating the scope,
which would have failed with 403 on every real publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(contributing): explain when a package version must be bumped

CONTRIBUTING said nothing about versioning or releases, while this branch adds
a CI check that fails a contributor's PR with "publishable package inputs
changed but version did not increase". A gate that rejects work without
telling anyone the rule is the kind that ends up disabled.

State the rule, the exact failure message, which files are exempt, and how to
choose the number, with a specific warning for @openmaic/dsl: it is the
contract the other packages validate against, so narrowing what an existing
document may contain is breaking even when the diff is small.

Also state what contributors do NOT do: publishing is automatic once a bump
lands on main, and the release tag is a marker written afterwards rather than
something to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): run renderer's tests before publishing renderer

The pre-publish gate ran renderer's typecheck but never its test suite, so the
one package whose behaviour is hardest to typecheck was the one shipping
unexercised. It has 25 test files and 242 tests; they just were not in the
filter list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): keep the git credential away from package code

Review finding: the publish job held `contents: write` while
actions/checkout had persisted that credential, and it then ran
`pnpm install` and the package builds. Any dependency or build script
executed with a credentialed git remote in reach and a write scope to use
it, which is a wider blast radius than publishing needs.

Separate the two capabilities so no job holds both:

- `validate` and `publish` run package code. Both now check out with
  `persist-credentials: false`, `validate` is pinned to `contents: read`,
  and `publish` drops write scope entirely, keeping only the `actions: read`
  it needs for the CI conclusion and `id-token: write` for provenance.
- a new `mark` job is the only holder of `contents: write` and a git
  credential, and it installs nothing and builds nothing.

`mark` also reconciles instead of only recording this run. A marker whose
push failed previously could never be repaired before, because the release
plan excludes versions already on the registry and the marker logic sat
behind that plan. It now marks any registry version whose tag is missing,
and leaves an existing tag alone rather than moving it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:54:23 -04:00
wyuc c923e50e4a ci: retire the completed document-server-backend trunk from the PR whitelists (#981) 2026-07-25 00:35:49 -04:00
wyucandCodex 5cef056d17 feat(storage): server-backed documents — HTTP contract, Postgres backend, one reference server for both stores (#979)
* ci: whitelist the document-server-backend integration trunk

* feat(storage): DocumentStore HTTP contract + client + reference-server routes (1B PR1) (#975)

Mirrors the runtime line's server-backed delivery for the document store:
a documented /documents HTTP contract (validation/not-found/FUTURE_VERSION
taxonomy; authorization stays a deployment concern via injected hooks with
an any-authenticated default), an HttpDocumentStore that runs the same
validators and migrate-on-read semantics as the browser backend before and
after the wire, and document routes on the shared reference server — one
server, both stores. The backend-agnostic document contract suite now runs
against HTTP → reference server → browser store.

Co-authored-by: Codex <codex@openai.com>

* ci: whitelist the trunk in the storage PG contract workflow too

* feat(storage): PgDocumentStore — Postgres document backend (1B PR2) (#976)

Implements the full DocumentStore contract over document_stages /
document_scenes / document_outlines, reusing the pure adapter split so
every backend shares one aggregate semantics: saveDocument replaces the
aggregate in one transaction under the stage row's FOR UPDATE lock
(vanished scenes deleted, outline row added/removed — omission means
deletion), incremental putStage/putScene/deleteScene take the same
current-version gate as the browser backend, loads read FOR SHARE and
migrate without writing back, listings never touch content, and deletes
cascade by FK. The document contract suite is PG-gated the same way the
runtime one is; local coverage comes from stubbed-queryable unit tests.

Co-authored-by: Codex <codex@openai.com>

* feat(document-store): sealed bootstrap injection seam for the document backend (1B PR3) (#977)

configureDocumentStorage mirrors the runtime seam: module-level bootstrap
injection sealed after first resolution, a factory that receives the app
validators so a server-backed store cannot silently skip them, and
reset-for-tests wiring. A deployment swaps the browser document backend
for HttpDocumentStore without touching app code. Storage README roadmap
updated — the document HTTP/PG backend items are done.

Co-authored-by: Codex <codex@openai.com>

* fix(storage): 1B final-review hardening — error classification, JSON-safety, body limits, parity coverage (#978)

Unversioned legacy incremental writes classify as 400 instead of leaking a
500; document/scene reads assert JSON safety before serialization so a
structured-clone value fails loud (500 NOT_JSON_SAFE naming the path)
instead of silently converting; a shared streaming body reader bounds
request bodies (default 32 MiB, 413 PAYLOAD_TOO_LARGE, option exposed
through every handler factory); the putStage stale/future guards and
corrupt-stamp listing tolerance move into the shared contract suite so all
four backends prove them; both HTTP store errors retain structured details.

Co-authored-by: Codex <codex@openai.com>

* refactor(storage): typed document version/not-found errors replace prose classification (#980)

DocumentVersionError (future | not-current, storedVersion) and
DocumentNotFoundError now carry the classification the server previously
regex-derived from message prose — instanceof matching mirrors the runtime
sibling's RuntimeAppendConflictError pattern, so a wording change can no
longer silently break status mapping. The prose fallback remains, after
the typed checks, solely for third-party DocumentStore implementations.
The HTTP client rematerializes 409s as DocumentVersionError for symmetry,
and http.ts's private validator fields are renamed to match the public
option names all three backends already share.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>
2026-07-23 12:56:26 +08:00
d0d227424b feat(document): complete the document-persistence cutover — stage/scene/outline onto DocumentStore (#965)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* ci: whitelist the document-cutover integration trunk for PR checks

* feat(document): widened-stage contracts + app document seam (#860 follow-up, PR1) (#961)

Package: MaicDocument/DocumentStore/StageRow gain a TStage parameter
(defaults keep existing users unchanged), BrowserDocumentStore accepts an
injectable validateStage, and a version-guarded putStage closes the rename
read-modify-write race — same current-version gate as putScene.

App: lib/document/ seam — AppDocument types (currentSceneId is device
state, not document data, and the app stage validator rejects it), a
four-kind scene validator (slide/quiz delegate to the DSL; interactive/pbl
validate envelope + discriminant binding only), and pure legacy
canonicalizers (type rebound from content.type, singular whiteboard →
whiteboards, outline envelope). No consumer switches off Dexie yet.

Co-authored-by: Codex <codex@openai.com>

* feat(document): stage-storage cutover + locked lazy migration (PR2) (#962)

* feat(document): cut stage/scene persistence over to DocumentStore with locked lazy migration (PR2)

stage-storage now persists through the lib/document seam: aggregate
saveDocument with a mandatory outline envelope (omission means deletion in
the contract; ordinary saves fall back to the stored envelope), lazy
per-stage migration under a Web Lock (destination authoritative, one
coherent legacy snapshot, save-verify-marker, legacy rows retained
read-only; no-lock environments read legacy without migrating),
currentSceneId in device KV, rename via version-guarded putStage, and a
list that merges legacy-only stages until they migrate. stageOutlines'
second write authority folds into the document service, making the final
scene set and generationComplete one atomic commit.

Co-authored-by: Codex <codex@openai.com>

* fix(document-store): relocate the persistence seam out of lib/document

lib/document/ is the server-side content-extraction domain (sharp, cloud
SDKs); exporting the persistence seam from its barrel dragged node-only
modules into the client bundle and broke the Turbopack build. The seam now
lives in lib/document-store/ with its own barrel, and the extraction barrel
is restored. Client-only guards become capability probes (indexedDB /
localStorage presence) so injected node test environments keep working
while a true server render still fails loud.

* fix(document-store): no-lock write classification + lock-order inversion (PR2 fix round)

Lock order is now per-stage document lock → global runtime epoch (a save
never waits on a document lock while occupying the shared epoch, which
deadlocked against fair-queued maintenance). Without Web Locks, mutation
classifies instead of refusing wholesale: destination-backed and genuinely
new documents accept the product's established lock-free/LWW risk, while a
legacy-only document stays read-only so two authorities cannot fork —
matching the chat-cutover-era acceptance suite, whose save assertions now
verify through the public API instead of legacy Dexie rows.

Co-authored-by: Codex <codex@openai.com>

* style: prettier on document-store seam files

---------

Co-authored-by: Codex <codex@openai.com>

* feat(document): cut secondary consumers over to the document seam (PR3) (#963)

ZIP classroom/video exports read stage metadata through the seam; classroom
import builds one canonical aggregate under the per-stage lock and writes
canonical 'whiteboards' (fixing the import-side singular-alias defect the
manifest never had); backups become versioned document aggregates that
finally include outline envelopes, while old {stages, scenes} backups are
accepted through the legacy canonicalizers; clearDatabase now clears
maic-documents and the seam's KV keys so clear-cache cannot strand
documents; deleteStageWithRelatedData is deprecated in favor of
deleteStageData. Four e2e seeders target maic-documents directly;
classroom-interaction stays legacy-seeded to exercise real-UI lazy
migration.

Co-authored-by: Codex <codex@openai.com>

* fix(document): final-review hardening — restore pre-images, honest markers, clear-race exclusion (3 P1 + 2 P2) (#964)

- Backup rollback restores pre-existing destination documents (and imported
  current-scene KV values) from captured pre-images instead of deleting them
- The skip-migration path only writes its marker after reconciling the
  legacy snapshot against the authoritative destination; a divergent
  snapshot logs and stays discoverable instead of being falsely certified
- Migration's destination write enrolls in the global shared storage epoch
  (per-stage -> global order), so clearDatabase's exclusive epoch can no
  longer race a lazy migration into resurrecting a document
- listStages drops legacy entries whose snapshot vanished mid-merge

Co-authored-by: Codex <codex@openai.com>

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(document): backup covers no-lock legacy courses; clears fence stale saves (review P1s) (#968)

Backup assembly appends read-only canonicalized aggregates for legacy-only
stages the lazy migration could not persist (no-Web-Locks environments) —
courses visible in the app can no longer vanish from an export. A
device-scoped storage generation now fences document mutations: clears bump
it inside their exclusive section, and any save or migration commit that
entered before the clear re-reads it inside the shared epoch and fails loud
instead of repopulating a wiped store.

Co-authored-by: Codex <codex@openai.com>

* fix(document): review round 2 — legacy-only chat backup coverage + full-width generation fence (#972)

Backups now enumerate runtime chat sessions for every exported document,
not just store-backed ones — a legacy-only course's chat history (the chat
and document cutovers are independent seams) no longer drops out of the
export; the runtime read injects an empty legacy chat store since legacy
rows are collected by direct table reads, which also keeps the export
working without Web Locks. The storage-generation fence now guards every
mutating store method, with the exclusive-epoch delete cascades passing
storageSharedLockHeld so the guard cannot self-deadlock against their own
hold.

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:18:24 +08:00
3eea9dc542 feat(runtime): complete the #869 learner-data cutover — quiz + playback onto RuntimeStore (#955)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:28:04 +08:00
杨慎andClaude Opus 4.8 84b1907255 feat(video-export): service-backed MP4 render + in-app one-click export (#866) (#937)
* feat(video-export): service-backed MP4 render + in-app one-click export (#866)

Adds the last mile of classroom video export: turning the self-contained
Hyperframes project ZIP (#865) into an MP4 via an isolated render service,
one-click in-app.

- render-service/: standalone Node 22 + Chromium + FFmpeg container wrapping
  @hyperframes/producer's library API. Async job model (POST /render -> 202
  jobId, GET poll, GET download, DELETE cancel). Swappable JobStore /
  ArtifactStore seams (in-memory + local-disk now; Redis/S3 + presigned-302
  download later) so it scales horizontally without changing the HTTP contract.
  Concurrency + per-user guards are config knobs.
- App integration: thin Next proxy routes under app/api/export-video/* (forward
  only, no rendering) + capability probe. use-render-video.ts uploads the ZIP,
  polls via runPolledTask, downloads the MP4; shared buildExportZip prefix with
  the existing ZIP path. Export menu gains resolution/fps/quality selectors and
  a progress bar; degrades to ZIP download when RENDER_SERVICE_URL is unset.
- docker-compose: render-service under an opt-in "video-export" profile.
- Entry is main.ts (not server.ts): the producer auto-starts its own server on
  :9847 when the process entry path ends with /src/server.ts.

Verified end-to-end in the container: rendered a real 640s (10.7 min) classroom
ZIP to a valid H.264 720p + AAC MP4 (duration matches source) in ~9.6 min
(~0.9x realtime, 4-worker frame capture). Degrade path, queued-cancel + cleanup,
and per-user 429 guard all exercised. pnpm check / lint / tsc / i18n pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(video-export): global render progress store, percent+ETA UI, ring on export button (#866)

Addresses two UX issues found while driving the in-app MP4 export:

1. Progress display was raw and unfriendly (showed producer's English stage
   strings like "Capturing frame 5130/19220") and had no time estimate. Now the
   menu shows only "<percent>% · about <remaining> left". ETA is computed from a
   recent-speed estimate (percent-per-ms over the last sample), EMA-smoothed —
   which tracks the render's non-uniform pace (prep -> frame capture with a
   4->1 worker drop -> encode) far better than a whole-run average, and never
   shows a stale/rising ETA.
2. Switching scenes mid-render unmounted the export menu and lost the progress
   (and reset the local "already rendering" ref, allowing a duplicate submit).
   The whole render lifecycle now lives in a global store
   (lib/store/video-render.ts), so progress survives menu close / scene switch
   and duplicate submits are guarded by status. A persistent CircularProgress
   ring on the export button shows live progress whether or not the menu is open.

Also fixes the progress scale: the producer reports progress as 0..100, but our
HTTP contract (and success path) is 0..1 — the service now normalizes it, so the
client no longer showed "2000%".

- lib/store/video-render.ts: new global store owning submit->poll->download,
  recent-speed ETA, duplicate-submit guard.
- lib/video-export-app/use-render-video.ts: thin facade over the store.
- components/ui/circular-progress.tsx: lightweight SVG progress ring.
- components/stage/{header-controls,video-export-menu}.tsx: ring on the export
  button; menu shows percent + ETA, subscribes to the store.
- render-service/src/render-manager.ts: normalize producer progress 0..100 -> 0..1.
- i18n: percent/ETA strings across all 8 locales (drops the stage-based string).
- render-service/package-lock.json: complete integrity hashes (reproducible npm ci).

Verified: ETA logic checked against the real segmented render curve (worker drop
raises ETA, encode speedup drives it to ~0); progress scale fix confirmed live
against the container (0.2 -> 20%). tsc / lint / prettier / i18n pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): persist render options in the store, not the menu component

Selecting 720p/24fps/draft, switching scenes, and reopening the export menu
showed the defaults again (1080p/30/standard). The selections lived in the
VideoExportMenu component's local state, which reset when the menu unmounted on
a scene switch — the running render still used the chosen options, but the UI
misrepresented them.

Move resolution/fps/quality into the global video-render store (with a
setOptions action). The menu now reads/writes the store, so selections survive
menu close / scene switch, and while a render runs the selectors reflect the
options that render is actually using. startRender() reads options from the
store instead of taking them as an argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): deployment correctness + resource/isolation controls (PR #937 review)

Addresses the blocking findings from wyuc's review. Output fidelity was fine;
these harden deployment and production resource/isolation boundaries.

#1 Compose advertised MP4 but couldn't render in prod:
- Capability now probes the service's /health (checkRenderServiceHealth), so a
  configured-but-absent service reports disabled and the UI degrades to ZIP
  instead of 502-ing.
- RENDER_SERVICE_URL is operator-supplied trusted config, so the proxy no longer
  runs it through the SSRF guard — the one-command `docker compose --profile
  video-export up` now works without globally weakening SSRF via
  ALLOW_LOCAL_NETWORKS. resolveRenderServiceUrl() is now synchronous.
- Client degrades to ZIP on any failed submit (not only 501).

#2 Unbounded upload/queue (ZIP-bomb / DoS):
- unzip.ts bounds the archive via fflate's filter BEFORE decompression: entry
  count, per-entry and total expanded size, and compression ratio.
- Proxy rejects oversized uploads (413) by Content-Length before forwarding.
- RenderManager enforces a global queue-depth cap (RENDER_MAX_QUEUE).
- All limits are env-tunable knobs in config.ts.

#3 Per-user guard was ineffective + admission ran after extraction:
- Identity is derived server-side (client IP) and forwarded as x-openmaic-client;
  the service ignores any client-supplied userId, and the proxy strips it.
- Admission is split into reserve()/submit()/release(): the slot is reserved
  BEFORE extraction, so a rejected caller never triggers a decompression.

Additional risks:
- Per-job wall-clock watchdog (RENDER_JOB_DEADLINE_MS) aborts + fails a hung
  render so it can't hold a slot/scratch forever.
- Download proxy bounds only the time-to-headers, not the body stream, so large
  MP4s over slow links no longer truncate.
- Client cancels the server job (DELETE) when a started render fails/times out.
- Compose puts render-service on an internal:true network (no host/internet
  route), sandboxing the Chromium that runs the uploaded HTML; the export ZIP is
  self-contained so no outbound is needed. README documents the standalone caveat.

Not closing #866: the smoke/golden-render CI acceptance criterion remains a
follow-up (see PR description).

Verified in-container: legal render 202; ZIP-bomb (entry-count + compression-
ratio) rejected 400 before any decompression; per-identity guard 429 with a
spoofed multipart userId ignored; reserve-before-extract leaves no scratch dir
on rejection; watchdog aborts an overrunning job and frees the slot. tsc / lint /
prettier / i18n pass; render-service tsc passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): real audio durations + burned-in subtitles (PR #937 review)

Two export-fidelity issues found in wyuc's deeper E2E:

A. Narration was scheduled from estimated durations, cutting audio off mid-
   sentence and advancing the timeline early. The scheduler trusted
   AudioFileRecord.duration (recorded only since #861), so the many existing
   classrooms without it fell back to text-length estimates — measured 4.35s
   average / 10.23s max underestimate across 47 clips. timeline-deps now probes
   the real duration from each narration blob via an off-document <audio>
   (symmetric to the existing video probe), preferring it over the stored
   duration, then the estimate only when no audio asset exists. Everything
   downstream (narration starts, scene/total duration, subtitle cues) re-derives
   from the corrected value in the pure compiler — no compiler change needed.

B. The final MP4 had no subtitles (only H.264+AAC), and the ZIP's SRT/VTT used
   the same estimated boundaries. The emitter now renders a burned-in subtitle
   overlay: one caption box + a hidden div per cue, revealed/hidden by the paused
   GSAP timeline at each cue's start/end (corrected timings from A), so Chromium's
   frame capture bakes them in. The producer has no subtitle track of its own, so
   burn-in is the v1 approach.

Verified: emitter unit tests + snapshot updated (subtitle overlay + toggle
statements, escaped text, hidden-by-default); 82 video-export tests pass incl.
the determinism red-line proxy. Rendered a synthetic subtitle project through the
container and confirmed by pixel analysis that captions appear only within their
cue window (2429 near-white px in the caption band at t=1.5s vs 0 at t=0.05s).
tsc / lint / prettier / i18n pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): subtitle layout + upload/admission hardening (PR #937 review)

Address the three P1 blockers plus actionable P2s from the 6a585c29 review.

P1:
- emit-hyperframes: stack every subtitle cue in one grid cell and toggle
  display:none/inline-block, so inactive cues leave the flow instead of
  pushing the active cue up into the slide title. Adds a multi-cue
  regression test the single-cue snapshot couldn't catch.
- render route + service: cap the upload by actual bytes (capBodyStream),
  not the spoofable Content-Length; the app now streams the multipart body
  through instead of buffering it via formData(). maxUploadBytes is now read.
- render-service: move makeProjectDir() inside the release()-guarded block
  so an ENOENT/ENOSPC no longer permanently leaks the admission slot; mkdir
  the scratch root at startup for the standalone path.

P2:
- config: allow RENDER_MAX_JOBS_PER_USER=0 to disable the per-identity guard.
- timeline-deps: per-probe timeout + bounded concurrency so a stuck audio
  blob can't wedge export in "compiling" forever.
- render route: only trust x-forwarded-for/x-real-ip under
  TRUST_PROXY_HEADERS=true; otherwise all callers share one "direct" bucket.
- render-service: add vitest tests (unzip limits/traversal, reservation
  arithmetic, body cap, config zero-disable) and a dedicated CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): sync render-service lockfile so `npm ci` passes in CI

The vitest devDependency's transitive esbuild@0.28.1 (and its platform
optionals) were missing from package-lock.json, so the new CI job's
`npm ci` failed with EUSAGE. Regenerated the lockfile from a clean install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): dedupe esbuild so render-service `npm ci` installs on linux

@hyperframes/core pins esbuild@0.25.12 exactly, hoisting it to the top and
forcing vite@8 (via vitest) to keep a nested esbuild@0.28.1 copy. npm fails to
flag that nested copy's platform-specific optionals as optional, so `npm ci`
tried to install @esbuild/aix-ppc64 on linux and died with EBADPLATFORM.

Add an `esbuild: 0.28.1` override so a single copy is shared (satisfies tsx
~0.28 and vite ^0.27||^0.28); esbuild is build-time only, so pinning the
producer's bundled build tool is runtime-inert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): resource/isolation hardening (PR #937 round-2 review)

Address the round-2 P1/P2/P3 findings (P1#1 lockfile EBADPLATFORM was already
fixed by the earlier esbuild-dedupe commit; CI Render Service job is green).

P1:
- Admission before buffering (#2): the render service now reserve()s the slot
  from the header identity BEFORE parsing/buffering the multipart, so concurrent
  near-cap uploads are bounded by the queue depth, not just each body by the cap.
- Chromium egress lockdown (#3): the producer exposes no browser-arg hook and the
  render shares the internal network with the app, so a container entrypoint
  installs an iptables egress lockdown (drop all outbound except loopback +
  established replies) then drops privileges. Needs CAP_NET_ADMIN (added in
  compose); graceful warn-and-continue if unavailable. The self-contained ZIP
  needs no outbound.
- Default one-render bottleneck (#4): with no trusted proxy every caller is
  "direct", so RENDER_MAX_JOBS_PER_USER=1 throttled the whole deployment. Default
  compose now sets it to 0 and relies on concurrency + global queue caps.
- Non-blocking bounded extraction (#5): unzipSync -> fflate async unzip (worker,
  off the event loop), keeping the pre-decompression filter; default expanded
  ceiling 1GB -> 512MB; a semaphore caps concurrent extractions; compose adds a
  container mem_limit.

P2:
- Raise the app submit timeout/maxDuration (300MB upload can't finish in 60s).
- video-render store: only degrade to ZIP when the service is genuinely
  unavailable (501/unreachable); surface real 429/413/5xx instead of an
  unsolicited download.
- useExportVideo dedupe guard moved to module scope so it survives the menu
  unmounting (no second concurrent ZIP pipeline).
- .env.example: RENDER_SERVICE_URL bypasses SSRF; drop the ALLOW_LOCAL_NETWORKS note.

P3:
- Deadline overruns are marked failed (not cancelled).
- submit() decrements the identity slot if jobs.create throws (no leak).
- CI sets PUPPETEER_SKIP_DOWNLOAD; unzip tests use tiny fixtures + low env limits.

Tests: render-service now 22 tests (unzip limits/traversal, admission incl.
create-leak, body cap, semaphore, config); app video-export suite unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): buffer under the extraction permit + fail-closed egress (PR #937 round-3)

Address the two remaining round-3 P1 boundary blockers.

P1#1 — buffering was outside the gate: `bounded.formData()` materialized the
whole uploaded file into memory BEFORE `extractionGate.run()`, so up to
RENDER_MAX_QUEUE (20) admitted bodies could each buffer ~300MB (≈6GB vs the 4g
mem_limit) before the 2-permit gate. Move the entire RAM-heavy section —
formData buffering, file read, and unzip — INSIDE the permit; the queue
reservation still runs first (a rejected caller consumes nothing). Requests
beyond the permit wait with their body unconsumed (socket backpressure), so at
most maxConcurrentExtractions bodies are buffered at once. Refactored main.ts
into a testable `createApp(deps)` factory and added an integration test proving
peak concurrency in the buffering+extraction section never exceeds the permits.

P1#2 — egress lockdown failed open: the entrypoint warned and started normally
if iptables setup failed, so /health stayed green while Chromium could reach the
app. With RENDER_EGRESS_LOCKDOWN=true (default) it now FAILS CLOSED — exits
non-zero if not root, iptables is missing, or the rules don't apply. Operators
accepting an unisolated setup opt out with RENDER_EGRESS_LOCKDOWN=false. Added
scripts/egress-smoke.sh to assert the boundary (lockdown active, loopback works,
new outbound blocked).

Verified: image builds; container boots as `render` with lockdown active and
serves /health; fail-closed exits 1 without CAP_NET_ADMIN; egress smoke passes
(outbound blocked); 23/23 render-service tests + tsc; app tsc + root prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:28:34 +08:00
wyuc 34448beb6c feat(storage): server-backed runtime — pluggable backend seam, HTTP contract, Postgres backend, reference server (#939) (#946)
* ci: run PR checks against the runtime-server-backend integration branch (#939)

* feat(storage): RuntimeStore HTTP contract + HttpRuntimeStore client (#939 Part B) (#940)

* feat(storage): RuntimeStore HTTP contract + HttpRuntimeStore client (#939 Part B)

- documented JSON HTTP contract for every RuntimeStore operation
  (server-assigned seq, learnerKey derived from auth — never trusted
  from the request, machine-readable error codes, idempotency notes)
- HttpRuntimeStore client with injected fetch + auth headers hook;
  session reads (including createSession responses) migrate forward
  on the runtime line so an older server cannot leak stale envelopes
- conformance test server bridging the contract onto the browser
  backend; full runRuntimeStoreContract green over HTTP plus error
  mapping cases

* fix(storage): harden HTTP runtime backend after cross-review (#939 Part B)

Cross-review fixes:
- appendRecord gates payloads through assertJsonValue: fail loud on
  values JSON cannot represent (Map, Date, NaN, nested undefined, NUL
  strings) instead of silently mangling them in transit; payload
  domain documented in the contract
- appendRecord/listRecords responses validate via validateRuntimeRecord
  (server-assigned seq is no longer trusted verbatim) and listRecords
  sorts by seq like listSessions already sorted defensively
- request headers no longer require an ambient Headers constructor
  when fetch is injected
- conformance server classifies errors structurally (existence checks,
  DSL validators, version stamps) instead of regex-sniffing messages
  that interpolate caller-controlled ids
- router preserves empty path segments so empty-key calls keep browser
  no-op semantics instead of shifting onto other routes
- stray runtimeDslVersion/seq in request bodies are ignored (store-
  assigned wins), matching the browser reference; docs updated
- loopback integration test exercises the real listening server
- docs state the conformance server is test-only; auth-derived
  learnerKey is Part D's reference server

* fix(storage): round-2 cross-review hardening for the HTTP backend (#939 Part B)

- json-value guard rewritten: additionally rejects -0, symbol-keyed and
  non-enumerable own properties, and non-index own properties on arrays;
  U+2028/U+2029 are accepted again (they round-trip through JSON per
  RFC 8259 — rejecting them lost legitimate pasted text)
- conformance server classifies racing duplicate creates as 409 via a
  structural post-check, gates payloads and merge learner keys as 400
- segment() rejects '.'/'..' ids the URL layer would fold away
- list responses must be arrays and mergeLearner's moved must be a
  finite number, else a typed MALFORMED_RESPONSE error

* fix(storage): round-3 cross-review hardening for the HTTP backend (#939 Part B)

- json-value guard: NUL is rejected in object keys too, and unpaired
  UTF-16 surrogates are rejected in string values and keys (jsonb
  refuses both; other JSON stacks corrupt lone surrogates to U+FFFD)
- typed storage errors survive non-object 200 responses instead of
  degrading into TypeErrors on .id access
- mergeLearner's moved must be a non-negative integer
- conformance server classifies missing/non-object request bodies as
  400 VALIDATION_FAILED
- createSession/appendRecord gate the full init envelope, not only the
  payload (stray Date/undefined properties, NUL in ids)

* fix(storage): round-4 convergence fixes for the HTTP backend (#939 Part B)

- envelope JSON gate tolerates explicitly-undefined optional anchors
  (sceneId: undefined behaves like omission, matching the browser)
- body-carried identifiers reject '.'/'..' so nothing persists that
  the URL layer cannot later address; conformance server mirrors it
- mergeLearner keys pass the JSON-domain gate on client and server
- json-value guard v4: rejects enumerable accessors (validation/serialize
  TOCTOU), Array subclasses and null-proto arrays, prototype-supplied
  array indices; sparse-array test now constructs a genuine hole

* fix(storage): round-5 convergence fixes for the HTTP backend (#939 Part B)

- undefined-stripping is limited to the DSL-declared optional anchors
  (sceneId/actionIndex/subAnchor); any other undefined member fails the
  JSON gate loud instead of being dropped silently
- json-value guard: array and object prototype checks are realm-agnostic
  (chain-shape instead of identity), so ordinary values from another
  realm are accepted while subclasses stay rejected; shared
  isLosslessJsonString predicate exported for SQL key guards
- conformance server merge route validates target-key addressability

* fix(storage): close the toJSON prototype channel in the JSON guard (#939)

toJSON is the single channel through which a prototype can alter JSON
output — prototype properties never serialize and own accessors are
already rejected — so refusing any value with a callable toJSON closes
prototype influence on serialization entirely, including prototypes
crafted to pass the realm-agnostic chain-shape check.

* fix(storage): probe toJSON via descriptor walk, not property read (#939)

Reading value.toJSON would execute an inherited accessor, letting a
stateful getter hide from validation and reappear at stringify time.
The probe now walks own descriptors up the prototype chain without
invoking any user code. Post-validation mutation of the caller's object
graph is documented as out of scope — it is equally unpreventable for
every other validated property.

* fix(storage): toJSON probe mirrors JSON.stringify semantics exactly (#939)

The own-most descriptor decides: absent is safe, a non-callable data
value is an ordinary shadowing member and is safe, a callable data
value is rejected, and an accessor is rejected because it cannot be
inspected without invoking it. No daylight remains between the guard
and the serializer on this property.

* feat(storage): PgRuntimeStore — Postgres runtime backend (#939 Part C) (#941)

* feat(storage): PgRuntimeStore — Postgres runtime backend over an injected queryable (#939 Part C)

- PgRuntimeStore implements RuntimeStore over a minimal injected
  Queryable (node-postgres and PGlite both satisfy it) — the package
  keeps zero runtime dependencies beyond @openmaic/dsl
- runtime_sessions / runtime_records schema exported as
  RUNTIME_PG_SCHEMA with idempotent ensureSchema()
- appends serialize per session via a session-row lock; MAX(seq)+1
  and the insert share one transaction, UNIQUE(session_id, seq) plus
  bounded retry backstop non-cooperating writers
- envelope semantics mirror the browser backend: version stamping,
  validation gates, migrate-on-read, fail-loud on future-stamped rows
- full runRuntimeStoreContract green on PGlite plus PG-specific cases
  (concurrent-append seq atomicity, ensureSchema and mergeLearner
  idempotence)

* fix(storage): require pinned transactions + JSON payload gate in PG backend (#939 Part C)

Cross-review fixes:
- withTransaction is now required; the BEGIN/COMMIT fallback is removed
  (on a pg Pool it spread BEGIN/body/COMMIT across different connections
  — no real transaction, leaked idle-in-transaction clients, broken
  mergeLearner atomicity; on a shared pinned client concurrent calls
  interleaved transactions)
- single-statement deletes no longer wrap in a transaction hook call
- appendRecord gates payloads through assertJsonValue: fail loud on
  values JSON cannot represent (Map, Date, NaN, nested undefined, NUL
  strings) instead of silently persisting something different
- append retry also covers 40001/40P01; READ COMMITTED assumption
  documented at the retry loop
- ensureSchema documented as create-only; redundant stage index dropped
- deterministic interleaving test proves the 23505 retry path; real
  PostgreSQL contract lane added (postgres:16 service workflow, pg
  driver suite skipped locally without PG_CONTRACT_URL)

* fix(storage): round-2 cross-review hardening for the PG backend (#939 Part C)

- json-value guard rewritten (shared with the HTTP backend): additionally
  rejects -0, symbol-keyed and non-enumerable own properties, and
  non-index own properties on arrays; accepts U+2028/U+2029
- storage-pg-contract workflow now triggers for the runtime-server-backend
  integration branch and fails loud (STORAGE_PG_CONTRACT_REQUIRED=1)
  when PG_CONTRACT_URL is missing instead of silently skipping
- loadSession distinguishes corrupt non-object rows from absent rows so
  getSession fails loud instead of reporting the session missing

* fix(storage): round-3 cross-review hardening for the PG backend (#939 Part C)

- json-value guard (shared): NUL rejected in object keys, unpaired
  UTF-16 surrogates rejected in values and keys
- createSession/appendRecord gate the full persisted envelope through
  assertJsonValue, not only the payload — stray Date/undefined
  properties and NUL in ids fail loud instead of silently diverging
  from the returned record or leaking raw 22P05
- real-PG lane covers a genuine 23505 conflict from a second
  connection and recovery after an aborted transaction
- retry-set asymmetry and mergeLearner's unbounded lock set documented

* fix(storage): round-4 convergence fixes for the PG backend (#939 Part C)

- NUL/lone-surrogate lookup and delete keys resolve to absent/no-op
  instead of leaking 22021 driver errors; setSessionStatus reports the
  session missing; mergeLearner from-key moves 0
- mergeLearner destination key passes the JSON-domain gate fail-loud
- envelope JSON gate tolerates explicitly-undefined optional anchors,
  matching the browser backend
- json-value guard v4 (shared) + tests for accessors, Array subclasses,
  prototype-supplied indices

* fix(storage): round-5 convergence fixes for the PG backend (#939 Part C)

- appendRecord pre-checks the session key like every other lookup path,
  so a NUL/lone-surrogate sessionId reports 'no session' instead of
  leaking a 22021 driver error
- the queryable-key predicate now structurally reuses the shared
  isLosslessJsonString export instead of restating the string rule
- undefined-stripping limited to the DSL-declared optional anchors
- json-value guard: realm-agnostic prototype checks (shared)

* fix(storage): close the toJSON prototype channel in the JSON guard (#939)

Shared guard change with the HTTP backend branch; adds the crafted
null-proto-prototype regression test. Both final-gate reviewers
independently converged on this same channel.

* fix(storage): probe toJSON via descriptor walk, not property read (#939)

Reading value.toJSON would execute an inherited accessor, letting a
stateful getter hide from validation and reappear at stringify time.
The probe now walks own descriptors up the prototype chain without
invoking any user code. Post-validation mutation of the caller's object
graph is documented as out of scope — it is equally unpreventable for
every other validated property.

* fix(storage): toJSON probe mirrors JSON.stringify semantics exactly (#939)

The own-most descriptor decides: absent is safe, a non-callable data
value is an ordinary shadowing member and is safe, a callable data
value is rejected, and an accessor is rejected because it cannot be
inspected without invoking it. No daylight remains between the guard
and the serializer on this property.

* fix(storage): conform HTTP/PG backends to the post-#926 RuntimeStore interface (#943)

The chat cutover (#926) added deleteAllRuntime() to the RuntimeStore
contract while the HTTP and Postgres backends were developed in
parallel against the pre-#926 interface. Implements the method on both
backends (single-statement wipe on PG via the FK cascade; DELETE
/runtime on the HTTP contract, documented as an operator-gated
administrative endpoint), restoring a green typecheck and contract
suite on the integration branch.

* feat(runtime): injectable RuntimeStore backend + learner-key provider (#939 Part A) (#944)

* wip(runtime): backend injection seam — pending gate verification

* fix(runtime): cross-review hardening for the storage injection seam

- stage-deletion IndexedDB probe applies only to the default browser
  backend; an injected store always receives deleteStageRuntime
- explicit kv argument takes precedence over the configured learner-key
  provider, matching the store seam's explicit-beats-global rule
- configured learner-key resolution is latched with in-flight dedup,
  mirroring the store singleton; identity changes require app-level
  handling
- factory retry semantics documented; seal errors explain the
  module-level bootstrap requirement; isRuntimeStorageConfigured()
  probe and a test-only reset added
- client-bootstrap-only contract documented (SSR/HMR caveats)

* fix(runtime): snapshot configuration and make the test reset complete

- configureRuntimeStorage copies the option fields so mutating the
  caller's object after configuring cannot swap the sealed backend or
  identity provider
- resetRuntimeStorageForTests now clears every latched consumer cache
  (store singleton, learner-key in-flight promise) via a reset-hook
  registry, so a reset-then-reconfigure test actually gets the new
  backend

* fix(runtime): reset also clears the default learner-key caches

resetRuntimeStorageForTests left defaultInFlight/defaultKv latched, so
a default-path test could leak its anonymous key or KV store into the
next test despite the documented full-reset promise.

* feat(storage): runtime reference server — auth-derived learnerKey (#939 Part D) (#945)

* wip(storage): reference server — pending deleteAllRuntime route + gates

* feat(storage): runtime reference server — auth-derived learnerKey enforcement (#939 Part D)

- createRuntimeHttpHandler(store, options) wires the documented HTTP
  contract onto any injected RuntimeStore over node http
- authenticate is required; every learner-scoped operation verifies the
  path/body learnerKey against the authenticated principal (403
  FORBIDDEN_LEARNER) — the client-supplied value is never trusted
- mergeLearner requires an explicit authorizeMerge grant and admin
  surfaces (stage cascade, DELETE /runtime) require authorizeAdmin;
  both default-deny
- runnable reference entry demonstrates a pg Pool withTransaction and
  bearer-token authentication, marked as demo-only
- contract suite green through HttpRuntimeStore -> listening reference
  handler -> PgRuntimeStore(pglite); security matrix tested (401/403
  paths, admin default-deny); threat model documented

* fix(storage): cross-review hardening for the reference server (#939 Part D)

- principal learnerKey is optional: admin/merge-only credentials no
  longer fabricate learner identity; learner-scoped routes 403 without
  ownership
- full session/record envelopes pass the JSON-domain gate at the
  handler, so NUL/lone-surrogate identifiers map to 400 instead of 500
- payload validation follows the injected store's validator map
  (options.payloadValidators) instead of imposing DSL defaults
- reference factory accepts authenticate/authorizeMerge/authorizeAdmin/
  payloadValidators overrides; docs no longer claim the factory binds
  to localhost; demo-impersonation warning hardened
- 500 responses carry a generic message; details go to the server log
- ownership checks precede version checks and unowned sessions read as
  404, closing existence/version oracles; cross-learner denial matrix
  tested per route
- merge/delete concurrency documented as linearizable-equivalent with
  the ownership re-check narrowed to the delete call

* fix(storage): align reference-server semantics with the store contract

- future-stamped sessions read and delete through unchanged; 409
  FUTURE_VERSION applies only to guarded writes (status, append, merge)
- check-then-write races reclassify structurally via a post-failure
  re-fetch: missing session 404, non-active session 400, never a
  message-sniffed or generic 500

* fix(storage): close the reference CLI's pg pool on startup failure

ensureSchema opens connections inside createReferenceRuntimeServer, so
a failed schema init or occupied port left the pool holding database
resources until its idle timeout.

* fix(storage): close the records-route existence oracle

cosarah's review point on #946: the records list answered 200 [] for an
absent session but 404 for another learner's, so the 404 leaked that an
id exists. Absent and foreign sessions now answer identically (404
SESSION_NOT_FOUND) and the HTTP client maps that code back to an empty
list, preserving the store contract's absent-lists-as-empty semantics.
Contract doc records the server MAY/SHOULD and the client MUST.
2026-07-17 16:15:57 +08:00
wyucand杨慎 6f7ffa8302 chore: tighten GitHub issue intake (#921)
* chore: tighten issue intake

* fix: address issue intake review feedback

* test: preserve scene selectors in pro mode

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 15:44:57 +08:00
wyuc 1c5078842a feat(storage): RuntimeStore — sessions + append-only records, browser backend (#869 Part B) (#880)
* feat(storage): RuntimeStore contract — interface + backend-agnostic suite (#869)

* feat(storage): BrowserRuntimeStore — IndexedDB backend for the runtime layer (#869)

* test(storage): runtime version-skew coverage; run the storage suite in CI (#869)

Backend-specific tests seed raw session rows (mirroring the document
backend's reStampStage) to cover the version-skew surface the public API
cannot express: a stale stamp fails loud while the runtime ladder is empty,
a future stamp reads through unchanged but rejects appendRecord and
setSessionStatus, and a stamp-stripped row hits the no-unversioned-epoch
guard. Also covers the injected payloadValidators override (the contract
case deferred to the backend test, as document-contract does for scene
validators).

The @openmaic/storage suite — including the pre-existing DocumentStore /
KV / asset contracts — never ran in CI at all; add the missing step after
the dsl one. README gains the RuntimeStore section.

* fix(storage): harden RuntimeStore writes and listings per cross-review (#869)

- mergeLearner now takes the same gates as every other write: rejects
  empty learner keys, self-merges return 0 without a transaction,
  future-stamped rows throw (mutating a newer client's data), and every
  re-keyed envelope is validated before put — any throw aborts the whole
  merge atomically instead of contaminating the target partition.
- listSessions sorts by the instant a timestamp denotes (Date.parse),
  not by string order, which mis-ranks numeric zone offsets; and it
  tolerates corrupt rows by omission (the listDocuments precedent) so
  one poison row cannot make a partition unenumerable — direct reads
  stay fail-loud.
- appendRecord validates the ACTUAL completed record (with the assigned
  seq) inside the transaction, on top of the pre-flight, and derives seq
  from a key cursor's primaryKey instead of deserializing the previous
  record's payload.
- Docstrings now enumerate the corrupt-stamp throw paths and no longer
  claim every query is partition-scoped (mergeLearner is the one
  deliberate cross-stage sweep).
- The stale-row test comment no longer promises an impossible one-line
  flip; the IDBKeyRange shim moves to test/setup.ts alongside the other
  guarded shims.

* fix(storage): mergeLearner migrates stale sessions before re-keying (#869)

The re-key previously spread the RAW stored row, unlike setSessionStatus
and appendRecord which migrate a stale session in place before mutating
it. Once the runtime ladder gains a real step, that would either write an
old runtimeDslVersion into the target partition or fail validation when a
migration adds required fields. Build the updated row from
migrateSession(row) instead (order: future-guard, migrate, re-key,
validate, put).

Observable today (empty ladder): a below-epoch stale row makes the merge
abort with the ladder's no-migration-path error — pinned by a new backend
test with nothing moved.

* fix(storage): reads gate stored sessions through envelope validation (#869)

Reads previously ran only version resolution, so a row with a valid
runtimeDslVersion but corrupt other fields (a non-ISO createdAt, a bogus
status) came back as-is — contradicting the documented corrupt-row
semantics, which covered only version corruption. Extend the same policy
to the whole envelope: getSession validates the migrated row and fails
loud as a stored-row integrity error; listSessions omits rows whose
envelope validation fails, exactly like version-resolution failures.

setSessionStatus and appendRecord need no change: the status update
validates the full post-update envelope before writing (catching corrupt
non-status fields), and appendRecord's own writes are already validated.
2026-07-09 00:03:51 +08:00
83fdecf399 feat(dsl): runtime envelope — RuntimeSession + RuntimeRecord + core-kind skeletons (#869 Part A) (#870)
* feat(dsl): runtime envelope — RuntimeSession + RuntimeRecord + core-kind skeletons (#869)

* feat(dsl): runtime envelope validators (#869)

* test(dsl): runtime envelope rides the version ladder; document the runtime contract (#869)

* fix(dsl): harden runtime envelope validators and tighten contract linkage (#869)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): isQuizAttemptSkeleton requires a plain-record answers map (#869)

* fix(dsl): review round — ISO 8601 timestamps, dedicated runtime migration ladder, payload excludes undefined (#869)

* fix(dsl): mechanical version-line separation + ISO day-overflow check (#869)

- Runtime sessions now stamp their own envelope field (runtimeDslVersion,
  RuntimeVersioned) instead of sharing dslVersion: the document and runtime
  ladders read disjoint fields, so misrouted data is inert rather than
  silently reinterpreted once the two lines diverge. Adds
  runtimeDslVersionOf + needsRuntimeMigration as the runtime-line
  counterparts of dslVersionOf + needsMigration.
- isIsoTimestamp: Date.parse normalizes day-of-month overflow on V8
  (2026-02-30 parses, rolls to Mar 2) instead of rejecting it; add a pure
  calendar round-trip so the verdict is correct and engine-independent.
- Report an empty-string timestamp once (required-field table), not twice.
- Scope migrateRuntime's contract to RuntimeSession; records are not
  independently versioned units.
- Run the @openmaic/dsl test suite in PR CI (it previously ran only in the
  publish workflow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): isIsoTimestamp validates calendar components without Date.parse (#869)

* test(dsl): cover month-00 / day-00 in isIsoTimestamp negatives (#869)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): cross-line guard makes misrouted data inert across migration ladders (#869)

* fix(dsl): mirror the cross-line guard in the migration predicates (#869)

runLadder's cross-line guard returns a misrouted aggregate untouched, but
needsLadder still reported it as needing migration (its own stamp absent
reads as 0.0.0), so a correctly paired
`while (needsMigration(x)) x = migrate(x)` loop on a runtime-line object
would never terminate. Give the predicates the same guard, restoring the
predicate/runner agreement invariant the docs promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): fail loud on ambiguous cross-line envelopes (#869)

The guard treated raw sibling-key presence as proof of misrouting, but the
same byte state also arises as this line's data carrying a stray foreign
stamp — e.g. a valid session that once passed through the document runner.
Silently returning it unchanged permanently orphaned that data from its own
line (predicate reports current forever, runner never lifts it).

The state is undecidable from the envelope, so stop guessing: both runners
and both predicates now throw the same cross-line error on
own-stamp-absent + sibling-stamp-present, mirroring the malformed-stamp
rule. validateRuntimeSession additionally rejects any present dslVersion
on a session at the door, so the ambiguous state cannot enter storage
through a validated write path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): enforce the cross-line rule in the shared envelope reader (#869)

dslVersionOf / runtimeDslVersionOf still read a sibling-stamp-only
envelope as unversioned while the runners and predicates threw on it, so
an authoritative version read (e.g. storage's putScene guard messages)
could report 0.0.0 with a load-and-save remediation for a row the load
path refuses to migrate. Move the cross-line throw into versionOf itself:
the plain readers, the needs*Migration predicates, and the migration
runners now give one answer for one envelope, and the duplicated guard
sites in runLadder/needsLadder collapse into the shared reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): the runtime line has no unversioned epoch — sessions are born stamped (#869)

An unstamped object read as 0.0.0 let the runtime ladder lift arbitrary
document-shaped data; once a real session transform lands that walk
corrupts or throws. The runtime envelope is brand new — nothing
legitimately predates it and stores stamp runtimeDslVersion at write —
so an unstamped object on the runtime line is always a bug (a misrouted
legacy document or an unstamped producer write), not legacy data:

- RUNTIME_DSL_MIGRATIONS drops its legacy-lift entry (empty until the
  first real shape change appends a step from the pinned initial version).
- migrateRuntime / needsRuntimeMigration / runtimeDslVersionOf throw on
  an unstamped object (versionOf gains a per-line legacy version, null
  meaning no epoch). The document line keeps its legacy lift — real
  pre-versioning documents exist.
- RuntimeSession requires runtimeDslVersion (type and validator), so an
  unstamped session cannot pass a validated write path and the
  type/validator contract stays aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(dsl): pin the future runtime ladder's first step to the initial version (#869)

A non-empty RUNTIME_DSL_MIGRATIONS must start at
INITIAL_RUNTIME_DSL_VERSION — the runtime line has no unversioned epoch,
so a ladder starting earlier (a copy-pasted 0.0.0 legacy lift) would
reintroduce the lift-arbitrary-unstamped-data hole. The contiguity test
now asserts the pinned origin as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dsl): restore the empty runtime ladder — drop swept-in local fault injection (#869)

e9d480e1 accidentally swept a local-only fault-injection edit (a mistaken
0.0.0 legacy origin used to exercise the new ladder-origin invariant test)
into the commit alongside the test change. The injected fault is exactly
what the new test guards against — the suite failed on it as intended.
Restore the empty ladder; 189 tests green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-08 17:47:30 +08:00
xuyuanwei678 261fddb062 fix(packages): add openmaic repository metadata (#813)
* fix(packages): add openmaic repository metadata

* ci(packages): publish on openmaic manifest changes
2026-06-29 16:52:03 +08:00
wyuc 7cb129183d chore(packages): publish the @openmaic/* SDK family to npm (#778) (#780)
* chore(packages): publish the @openmaic/* SDK family to npm (#778)

Prepares the @openmaic/{dsl,renderer,importer} family for its first npm
publish, and moves the SDK packages onto the @openmaic scope.

Why the scope move: the @maic org name is unavailable on npm (an unscoped
`maic` package already holds the name), so @maic/* is not claimable. @openmaic
matches the project name, the scope is free, and the repo already ships an
@openmaic/docs package — so the SDK family now lines up with that convention.

- rename @maic/{dsl,renderer,importer} -> @openmaic/* across packages, the
  workspace glob, the package dir, and all import sites; lockfile regenerated
- renderer: add publishConfig (public, registry.npmjs.org) — was missing, so
  a scoped publish would default to the wrong registry / restricted access
- importer: add a files allowlist (dist, README, LICENSE) and drop the
  fragile .npmignore blacklist that shipped src; add an exports map so ESM
  consumers resolve dist/index.js instead of falling back to the .cjs main
- all three: add a prepublishOnly build (+ test/typecheck) guard so a publish
  can never ship a stale or empty dist
- add a tag-triggered publish workflow with npm provenance, pinned by name to
  the three @openmaic packages so the vendored forks (mathml2omml, pptxgenjs)
  are never published

Refs #778, #720 (Phase 1).

* fix(packages): address cross-review on the @openmaic publish prep

Cross-review (Claude /code-review + codex) on this PR surfaced:

- renderer's advertised CJS entry was broken: it keeps @openmaic/dsl external
  and imports a runtime enum from it, but dsl is ESM-only (no `require`
  condition), so `require('@openmaic/renderer')` would throw
  ERR_PACKAGE_PATH_NOT_EXPORTED. Make renderer ESM-only: drop the `.cjs`
  rollup output, `main` now points at the ESM build, and the `require`
  conditions are removed from `exports`. (importer is unaffected — it bundles
  dsl, so its CJS build still works.)
- prepublishOnly re-ran the test suite during `pnpm -r publish`, so a flaky
  test after dsl had already published gave a non-atomic partial release.
  Reduce prepublishOnly to a build-only guard (never ship stale/empty dist)
  and move the real test/typecheck gate into the workflow, before any publish.
- document that an @openmaic/* tag publishes the whole family via `pnpm -r`
  (pnpm skips already-published versions); the tag is a release marker, not a
  per-package gate.

Verified: dsl + renderer + importer build; renderer emits ESM only (0 .cjs),
all exports entries resolve; `npm pack` ships dist + README + LICENSE with no
src leak; frozen-lockfile passes.

Refs #778.

* style: reflow @openmaic/dsl type imports past print-width after rename

The @maic -> @openmaic rename lengthened two single-line type imports past
prettier's 100-col width; prettier --check flagged them. Pure formatting.

Refs #778.

* docs(importer): mark @openmaic/importer browser-only (cr-loop accepted limitation)

codex cross-review flagged that the published @openmaic/importer throws
`XMLHttpRequest is not a constructor` when loaded in a pure Node process —
its rollup build is browser-targeted (`nodeResolve({browser:true})` + a
browser pdf.js build). The app only consumes it client-side ('use client'),
so this is by design. Document it as an accepted limitation: prominent
browser-only note in the README and a `browser` field in the manifest.

Refs #778.
2026-06-24 15:12:23 +08:00
wyuc 1d1ce80e04 feat(maic-agent): editor-agent line (v0) — Pro-mode "Edit with AI" (#777)
Promote the editor-agent line to main: read-then-act editor agent (read_scene_content, regenerate_scene, regenerate_scene_actions, edit_interactive_html), a routable maic-agent model stage, the agent sidebar UI, interactive-scene runtime-error capture, and the timeline/script editor. Cross-reviewed and smoke-verified; see #777.
2026-06-23 16:56:37 +08:00
d403014918 fix(importer): correct hanging-indent bullet slot rendering (#727)
* fix(importer): correct hanging-indent bullet slot rendering

Size the bullet slot to the hanging-indent amount (|indent|) instead of
marL so wide marL paragraphs no longer push body text past the bullet.
Reset text-indent on the inline-block slot to stop symbol bullets from
drifting left onto adjacent shapes, and only pad synthesized-marL symbol
bullets. Add vitest setup and serializer tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(importer): apply prettier formatting to serializer tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(importer): wire up package-local vitest config and CI

Add packages/@maic/importer/vitest.config.ts so the package tests in
test/ are discovered (the root config only globs tests/), and add a CI
step that runs them. Align vitest to a single 4.1.8 in the lockfile and
move the tableSerializer setup into beforeAll so parse failures surface
as test failures instead of collection errors.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-15 12:16:09 +08:00
wyucandClaude Opus 4.8 47d28145b4 feat(maic-editor): slide surface — MAIC Editor v0 (epic #562) (#615)
* feat(maic-editor): framework primitives + edit StageMode (#564)

* feat(maic-editor): framework primitives + edit StageMode

Phase 1 framework foundation for the MAIC Editor (RFC #547,
tracking #560). Plumbing only — no UI consumers ship in this
sub-PR; the EditShell chrome and slide surface registration
land in follow-ups.

- StageMode gains 'edit' alongside 'autonomous' | 'playback';
  setMode resets canvas selection when leaving 'edit'.
- <Stage> auto-exits 'edit' whenever the current scene becomes
  uneditable (no scenes / pending generation / no current
  scene) so a follow-up Pro toggle can never strand the user
  in an empty edit shell.
- SceneEditorSurface contract + tiny registry under lib/edit/
  so each SceneType plugs in a surface without the shell
  importing surfaces directly. Surfaces declare CanvasComponent,
  useSurfaceState(), insert palette items, floating actions,
  commands, and (reserved for AI) inline coach hints.
- Slide kernel (lib/edit/slide-ops.ts): immutable, history-aware
  operations covering slide-update, element add / update /
  updateMany / delete / deleteMany / reorder / duplicate / align /
  removeProps, and text content edit.
- Slide element factories (lib/edit/slide-edit-elements.ts) for
  default text / shape / image elements + HTML <-> plain-text
  helpers.
- i18n: stage.editCourse + stage.doneEditing across all 6
  locales, consumed by the header toggle in the next sub-PR.
- Vitest coverage for the slide kernel (operations + history)
  and the edit-mode store transition (entry + canvas reset on
  exit).

PBLRenderer's mode prop is widened from the literal pair to
StageMode so <SceneRenderer> (which already passes StageMode)
type-checks. The prop is unused inside the renderer.

* style(maic-editor): apply prettier to lib/edit + tests/edit

CI runs on PRs to main only, so the prettier check did not fire
for this PR's target branch — applying formatting locally before
the merge train reaches main avoids a follow-up style commit.

* ci: also run on PRs targeting feat/maic-editor-v0

The MAIC Editor lands as a series of stacked sub-PRs against
the long-lived feat/maic-editor-v0 branch. Without this entry,
none of those sub-PRs get a CI gate — style/lint/type/test
regressions only surface when feat/maic-editor-v0 finally
merges back to main, at which point fixing them is a lot more
disruptive than catching them per sub-PR.

Push trigger is intentionally left main-only: nobody pushes
directly to feat/maic-editor-v0, every change arrives through
a PR that now runs the gate.

* fix(maic-editor): address kernel review on #564

Addresses cosarah's review against #561 scope:

Important:
- element.align now uses the canonical lib/utils/element.ts geometry
  helper instead of a forked copy. The local fork ignored
  PPTLineElement start/end and rotation, so bounds were wrong for
  lines and rotated elements.
- Cap slide-edit history at MAX_HISTORY = 50; drop oldest on overflow.
- Narrow slide.update patch to Partial<Omit<Slide, 'elements' |
  'animations'>> via a new SlideMetaPatch alias, so element /
  animation collections can only be mutated through their dedicated
  ops.
- element.add throws on id collision; element.duplicate throws when
  idMap is missing entries or when new ids would collide with
  existing elements.
- scene-editor-registry dev-warns on overwriting a *different*
  surface for the same SceneType (HMR re-register of the same
  instance stays silent); add unregister() for HMR cleanup and tests.

Minor:
- Unify on structuredClone over JSON.parse(JSON.stringify(...));
  inside immer's produce, un-proxy with current() first.
- Skip history push when produce returns the same content reference
  (true no-op detection). element.delete / deleteMany pre-check
  membership so their unconditional filter assignments don't break
  the ref-equality signal.
- Drop redundant cloneSlideContent calls in undo/redo/push paths;
  immer's structural sharing already guarantees immutability of the
  produced output. createSlideEditHistory keeps its defensive clone
  since the initial value comes from outside immer.

Coverage:
- Extract auto-exit predicate into lib/edit/stage-mode.ts so the
  policy can be unit-tested without rendering <Stage>.
- New tests: every align direction, line / rotated element align,
  no-op paths for update/delete/reorder/removeProps/text/align,
  element.add index clamping + id collision, element.duplicate
  default offset + contract errors, history future cleared after
  branching, history capped, registry register/unregister/HMR-safe
  re-register, and the auto-exit predicate.

371 vitest tests pass (was 335). tsc/lint/prettier/i18n/build all
green locally.

* fix(maic-editor): close kernel escape hatches (subagent CR follow-up)

Two defense-in-depth fixes flagged by independent review after the
prior commit:

- element.duplicate now deep-clones the source via
  structuredClone(current(element)). The previous shallow spread
  shared nested mutable references (start/end tuples, outline,
  points) with the source; immer's COW would have handled most
  mutations but ops that operate on nested arrays in place
  (sort/reverse/splice) would silently leak between source and
  duplicate. The deep clone keeps the kernel's invariants
  independent of how downstream op consumers write their recipes.

- slide.update gains a runtime guard that throws when patch
  contains elements / animations. The type-level SlideMetaPatch
  narrowing already forbids these keys, but the runtime guard
  closes the `as any` escape hatch for callers that might bypass
  the type system.

New tests cover both paths: meta-only slide.update succeeds, an
elements-containing patch throws, and a duplicated line element's
start/end/points tuples are independent from the source.

* feat(maic-editor): EditShell chrome and Pro mode toggle (#565)

* feat(maic-editor): EditShell chrome and Pro mode toggle

Adds the scene-type-agnostic editor chrome (EditShell + CommandBar +
FloatingToolbar + HintRail), an edit-mode sidebar, and the header Pro
toggle that flips into the 'edit' StageMode from #561.

No scene editor surfaces are registered yet — the next sub-PR wires up
the slide surface. In this PR every scene type falls through to the
i18n unsupportedScene placeholder, which is the verifiable visible
behavior.

- canEdit gating reuses the canonical isCurrentSceneEditable predicate
  shipped in #561 so the toggle and the auto-exit effect are in
  lock-step.
- handleToggleEditMode tears down live session / engine / TTS before
  entering edit mode.
- ChatArea slides out in edit mode for a full-width canvas.
- reorderScene extracted from EditModeSidebar with unit tests; the
  positional-order preservation is the part worth a guard test.
- i18n scoped to keys this PR's components actually reference;
  surface-specific keys deferred to the slide-surface PR.

* test(reorder-scenes): single-element + reference-inequality cases; zh-CN newSlide distinct from addSlide

CR follow-ups:
- reorderScene tests now cover a 1-element array (both directions
  return null) and explicitly assert the returned array is a new
  reference, not the input.
- zh-CN edit.sidebar.newSlide was duplicating the addSlide label
  ("新建幻灯片" both); using "未命名幻灯片" for the default new-slide
  title to match the English Add slide / New slide distinction.

* refactor(maic-editor): drop EditModeSidebar; clean Pro mode chrome (#568)

Course-correct on #565. EditModeSidebar was rejected by the design
owner as inappropriate for Pro mode (#560 wording is "minimal top
bar + slide thumbnail rail", not a file-list panel). #565 also left
the playback chrome wrapped around the editor — Header / sidebar /
Roundtable / ChatArea all stayed mounted with only the sidebar
swapped, and EditShell's CommandBar/FloatingToolbar/HintRail were
never visible since no surface registers yet.

Drop EditModeSidebar + reorder-scenes helper + tests + the edit.sidebar
i18n block (8 keys x 6 locales) + the CommandBar sidebar-toggle.

Stage keeps Header mounted in both modes — it owns the global Pro
toggle Switch, which is the entry AND exit affordance (closing the
Switch exits; no separate Done-editing button). In edit mode:
SceneSidebar / Roundtable / ChatArea are not mounted, and the canvas
slot renders <EditShell scene> instead of <CanvasArea>. EditShell
internally resolves the surface via sceneEditorRegistry; when none
is registered it falls through to edit.unsupportedScene. With no
surfaces registered, every scene type lands on that placeholder —
the visible v0 behavior.

New optional EditShell.leftRail slot reserves the spot for a
redesigned slide-navigation surface; v0 ships with the slot empty.

SceneRenderer is now playback-only — the mode === 'edit' branch and
its sidebarCollapsed / onToggleSidebar props moved up to EditShell /
Stage.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): enablement infrastructure (pre-slide-surface) (#571)

* feat(maic-editor): enablement infrastructure (pre-slide-surface)

Pre-requisite for the slide surface (#562). Ships the safety
infrastructure so each subsequent surface PR is small and
recoverable:

1. Feature flag NEXT_PUBLIC_MAIC_EDITOR_ENABLED, default OFF —
   gates the Pro toggle in Header. StageMode unchanged.

2. SlideContent.schemaVersion + pure idempotent migrateSlideContent
   / migrateScene; setScenes / addScene funnel legacy data through
   the migrate at the store boundary.

3. tests/edit/round-trip/ harness: apply ops -> buildPptxBlob ->
   JSZip parse -> assert content survived. No PPTX -> Slide reimport
   exists in the codebase, so the full reimport-diff shape isn't
   doable; per-op assertions extend the harness in #562.
   buildPptxBlob is now exported (hook is still the only runtime
   caller).

4. Per-scene slide-history persistence helpers (persist / load /
   has / clear, keyed maic-editor:slide-history:${sceneId}, swallow
   storage failures) + standalone SlideHistoryRestorePrompt dialog
   + 4 new i18n strings x 6 locales. Stage wiring deferred to #562.

5. Concurrency guards: isSceneEditLocked predicate (defensive; no
   current call path structurally hits it); localStorage-backed
   multi-tab edit lock with tryAcquire / refresh / release / heldByOther,
   stale-lock takeover after 3x heartbeat; standalone
   MultiTabEditConflictPrompt + 3 new i18n strings x 6 locales.
   Stage wiring deferred to #562.

The slide-surface PR owns the edit-entry effect machinery (where
the history-state lifecycle and per-tab tabId ref naturally live),
so shipping half-wired dialogs here would speculatively build Stage
state we know we'll restructure on contact with the surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): migrateSlideContent forward-compat — no silent downgrade

CR follow-up: previously, content with schemaVersion newer than
CURRENT (e.g. v2 written by a future client) was silently truncated
back to the current version. Now: if schemaVersion >= CURRENT, return
the content untouched. The slide may not render correctly on an older
client, but its on-disk shape stays intact for the next compatible
client to read.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): slide surface skeleton + #571 wiring + geometry (#562) (#579)

PR1 of the slide-surface work (infra-first slice). Registers the slide
SceneEditorSurface so EditShell lights up Pro mode for slide scenes.

- SceneEditorSurface impl + sceneEditorRegistry registration; the surface
  owns a SlideEditHistory via the #564 kernel.
- Reuse the unmodified slide renderer Canvas through a surface-owned
  scene context; geometry drag/resize/rotate commits funnel into
  element.update ops (scene-edit bridge), one gesture = one undo step.
- Geometry numeric x/y/w/h/rotate popover as the precise fallback; gated
  off for line elements (PPTLineElement omits height/rotate).
- Wire #571 infra: cross-tab edit lock + conflict prompt, slide-history
  persistence + restore prompt, regen-lock guard.
- Renderer-commit classification: a real geometry gesture commits
  synchronously inside a pointer interaction; the renderer's
  ResizeObserver text-normalization commits with none, so it is folded
  into the baseline (no undo step / no persist / no spurious restore
  prompt on entry) instead of being staged as a user edit.
- Per-op round-trip test for element.update geometry; bridge + session
  unit tests; edit.geometry i18n across all 6 locales.

Upstream-shared changes are kept minimal and additive: an optional
`controller` prop on SceneProvider (uncontrolled/playback path
unchanged) so staged edits don't write through to the live stage store,
and a FloatingToolbar trigger-nesting fix (it wrapped PopoverTrigger
around <Tooltip>, a provider, so no popoverContent action could open).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): lock data-URL image PPTX round-trip (PR2 R1 gate)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): insert palette — text box + image (data-URL/URL)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): address Task 1 review — spy cleanup, popover-only comment, ImagePicker error log

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): drop PR1 debug geometry toolbar; element-aware floating bar

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): drop redundant PPTTextElement cast (Task 2 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): additive ProseMirror command bridge for the property bar (C1)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): exhaustiveness guard + tidy C1 adapter (Task 3 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): refresh property-bar attrs on caret/keyboard selection (C2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): satisfy no-explicit-any in PR2 test stubs (Task 1+4 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): compact text property bar in the reused floating slot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): Task 5 review — uniform selection-guard, Lucide icons, JSX, memo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(maic-editor): edit.text.* + edit.insert.* across 6 locales

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): round-trip gate for formatted text + inserts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(maic-editor): clarify remote-URL image round-trip scope (Task 7 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop stale scaffolding comment + orphaned geometry i18n keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(maic-editor): prettier --write PR2 files (pre-push check)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): make no-explicit-any suppression prettier-robust

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): CommandBar insert popover never opened (PopoverTrigger wrapped a Tooltip provider)

Insert→Image was unreachable: InsertButton wrapped <PopoverTrigger asChild>
around <Tooltip> (a context provider, no DOM node), so Radix's Slot bound
no element. Chain both triggers onto the real <button>, exactly mirroring
the PR1 fix already in FloatingToolbar's ActionButton. PR2's insert-image
is the first popoverContent InsertButton consumer to exercise this path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): text property bar no longer clips/overflows in the floating popover

The ~450px single-row bar was jammed into FloatingToolbar's fixed w-72
(288px) PopoverContent and clipped. Let the popover size to content
(w-auto, max-w-[92vw], Radix handles edge collision) and harden the bar
row (w-max + no child shrink, fixed-width font select) so it renders as
one clean line. Chrome/surface layout only — no renderer change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): property bar stays open across consecutive formatting steps

execCommand refocuses the editor after every command; the uncontrolled
Radix popover treated that focus-shift as focus-outside and dismissed,
forcing a re-open of the Text bar for each format action. Prevent
onOpenAutoFocus (don't steal the canvas selection on open) and
onFocusOutside (editor refocus must not dismiss); Escape and pointer-down
truly outside still close it. Chrome-only, no renderer change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): editor canvas now resolves gen_img_* media placeholders

The editor's interactive ImageElement rendered elementInfo.src raw,
so entering Pro mode on any slide whose image was a generation
placeholder showed a broken-image icon (while playback's read-only
BaseImageElement correctly resolved the placeholder to the generated
objectUrl). Extract the resolution into a shared useResolvedImageSrc
hook so both variants stay aligned. Strictly additive: for any
non-placeholder src (legacy / direct URL / data URL) resolvedSrc ===
elementInfo.src and the media store is not subscribed to. Pre-existing
upstream gap surfaced by PR2 as the first real-user editor consumer —
same shape as the CommandBar popover-trigger fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): unit-test gen_img placeholder resolution (9 cases)

Splits useResolvedImageSrc into a pure resolveImageSrc function (no
hooks) wrapped by the hook, so the resolution logic can be unit-tested
in vitest's plain node environment (no jsdom/RTL needed in this repo).
Covers: done→objectUrl; no task→raw; pending/generating/failed→raw;
done with no objectUrl→raw; cross-stage isolation; no-stageId path;
non-placeholder src passes through (the additive contract).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): auto-save edits to stage store, drop staging UX

Reverses PR1's "staged edits don't write through to the live lesson"
design. The slide-edit-session now writes through every history move
(applyOp / user commit / ResizeObserver normalization / undo / redo)
to useStageStore.updateScene as the canonical source of truth, which
Dexie already auto-persists. The renderer reads from the stage store
via the controller's getSnapshot.

Removes the entire staging surface that has no place in a modern
editor (Figma/Notion/Google Docs have no "unsaved changes" concept):

- DEL  lib/edit/slide-history-persistence.ts  (localStorage layer)
- DEL  tests/edit/slide-history-persistence.test.ts
- DEL  components/edit/SlideHistoryRestorePrompt.tsx  (restore dialog)
- DROP pendingRestore field + restore() action from slide-edit-session
- DROP restorePrompt branch + handlers from useSlideCanvasController
- DROP edit.history.restore.* keys across all 6 locales

Edits now flow: user input → renderer onUpdate → controller.updateSceneData
→ slide-edit-session.commitContent → writeThrough(useStageStore.updateScene)
→ Dexie. There is nothing "unsaved" to restore, by design.

The session retains its in-memory undo/redo history (per Pro session)
and the user-vs-ResizeObserver gesture classification (so reflow
doesn't push undo steps).

Test suite rewritten to assert write-through on every history move and
no write-through on seed (the stage already has that content).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): address PR review — element delete affordance + cross-platform fonts

Two issues from review on #586:

1. A selected image/text element couldn't be deleted — the renderer's
   delete lives only in a right-click menu, undiscoverable in Pro mode.
   Add a Delete button to the FloatingToolbar for any single selected
   element (text or image), dispatching the existing element.delete op.
   Button-only, consistent with #560's keyboard-shortcuts deferral.

2. Switching fonts had no effect on macOS Chrome — the property bar's
   font list was a hardcoded SimSun/SimHei set (Windows-only system
   fonts the renderer never loads). Use OpenMAIC's canonical FONTS
   registry (configs/font.ts) — the web fonts the renderer actually
   loads, so a pick renders identically on every platform.

Adds edit.delete × 6 locales + the parity-test key; floating-actions
unit tests for the delete action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): selection-anchored text editing for the slide surface (#590)

* feat(maic-editor): add resolveEditingElementId text-editing policy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drop text-format floating action (moves to anchored bar)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): surface hooks to derive and sync editingElementId

Add useResolvedSlideContent / useEditingTextElementId / useSyncEditingElementId.
Realign the PR2 buildFloatingActions tests with the new behavior (text
formatting moved off the FloatingToolbar) and co-locate the editing-state test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(ui): export PopoverAnchor from the popover wrapper

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): add useTrackedRect for element screen-rect tracking

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): add AnchoredTextBar selection-anchored format bar

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): wire anchored text bar + editing flag into SlideCanvas

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): draw a clean solid frame for the text element being edited

Gated on the canvas store's editingElementId (default ""), so the dashed
select frame is unchanged for multi-select and for any consumer that never
sets the flag. Editor-path only; playback never renders Operate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): drop the editor focus ring so text editing shows one frame

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(maic-editor): prettier-format the editing-state test import

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): anchor the bar to the text element node, not the wrapper

Code review caught that #editable-element-{id} is a zero-size absolute
wrapper — measuring it would pin the bar to the canvas origin. Measure the
.editable-element-text child, which carries the real geometry. Also correct
the dismiss-behavior comment: the bar is purely selection-driven.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): modernize the text format bar UI

Replace the native <select> font picker with the design-system Select,
rebuild the size control as one cohesive stepper pill, swap the color "A"
for a swatch chip, and unify every control to a single height and hover/
active language (violet accent, matching the editor's Pro-mode accent).
Behavior and the text commands are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): curate the font picker to fonts the app actually loads

configs/font.ts listed 29 fonts but the app only ever loads Inter (via
next/font); the other 28 had no @font-face or bundled file, so picking them
silently fell back with no visible effect — and nothing but the format bar
even imports the registry. Trim it to what genuinely renders; the file's
comment records how to restore the rest (wire up font loading first).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): load the picker fonts via @fontsource

The font registry listed 29 fonts the app never loaded. Wire up a curated
set that genuinely renders — 思源黑/宋, 霞鹜文楷, 站酷快乐体, and 9 Latin
families — via @fontsource packages (npm-managed, no font binaries in the
repo; CJK faces are unicode-range-subsetted so they download lazily per
glyph range). app/editor-fonts.ts registers the @font-face CSS from the
root layout; configs/font.ts is now the real, honest 14-entry list.

The ~14 commercial decorative Chinese fonts are intentionally left out —
they need self-hosting + subsetting + a licensing review, separate work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): quote font-family names so spaced/numeric ones work

Picking a font whose family name has spaces or a trailing digit (e.g.
"Source Sans 3") threw `Failed to execute 'check' on 'FontFaceSet'` —
`document.fonts.check(\`16px ${name}\`)` needs the family quoted — and the
fontname mark's toDOM emitted an invalid unquoted `font-family`, so the
font silently never applied. Quote the family in both spots; the mark's
parseDOM already strips quotes, so the attr still round-trips clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): make the editing frame pointer-events-none

The clean editing frame is a purely visual full-size overlay, but it was
pointer-events: auto — so it masked the text element's own move cursor,
text cursor, click-to-place-caret and drag-to-move; only a thin uncovered
sliver at the edges still triggered them. The dashed BorderLines it
replaced are thin edge lines, so they never had this problem. Mark the
frame pointer-events-none; the resize/rotate handles are separate and
keep their own pointer events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop the "font is loading" toast

With @fontsource fonts and font-display: swap, a picked font swaps in
smoothly on its own — the "Font is loading, please wait..." toast was
noise (and fired on most CJK picks while a unicode-range chunk loaded).
Remove it along with the now-unused document.fonts.check and toast import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): move the delete action onto the anchored text bar

A text element's contextual actions now sit together on the anchored bar —
format controls + delete, hugging the element — instead of delete sitting
alone in the top-center FloatingToolbar. buildFloatingActions returns
nothing for text (its FloatingToolbar then renders null); non-text
elements still get their delete there. Delete logic is shared via a new
deleteSlideElement helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): anchor the delete action for image elements

A selected image element now gets a selection-anchored bar hugging it —
just a delete button (image replace/crop/flip stay in a later sub-PR) —
the same way text elements do. The anchoring shell is extracted out of
AnchoredTextBar into a reusable AnchoredBar, and the delete button into a
shared DeleteButton; AnchoredTextBar and the new AnchoredImageBar are thin
wrappers. useTrackedRect now measures .editable-element-text or
.editable-element-image. buildFloatingActions returns nothing for image
elements too (other element types still get their delete there).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(maic-editor): tighten the anchored bar padding (p-2 → p-1)

p-2 left a chunky white margin around the content — most visible on the
image bar, a lone delete button in an oversized box. p-1 (4px, the value
the FloatingToolbar used) makes both bars sit snug to their controls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): anchor the delete bar for every element type

The selection-anchored delete bar now covers all non-text element types
(shape, line, table, chart, …), not just image — so every element's
editing chrome is anchored uniformly. AnchoredImageBar becomes the
type-agnostic AnchoredDeleteBar; useTrackedRect matches any
.editable-element-{type} content root; buildFloatingActions is dropped —
the surface no longer contributes top-center FloatingToolbar actions,
everything is on an anchored bar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): show legacy font names in the picker trigger

When a text element's `fontname` was a value not in the curated FONTS
registry (e.g. `Microsoft YaHei`, `PingFang SC`, theme defaults), the
Select couldn't match it and `<SelectValue/>` rendered a blank trigger —
both reviewers (cosarah Important, xuyuanwei678 #1) caught this. Add a
placeholder fallback so the raw family name surfaces in the trigger.

Also clean up the dead `'默认字体'` label that `text-format-bar.tsx`
overrode unconditionally: introduce an optional `labelKey` field on
`FontEntry`, use it for the default entry, and let the picker prefer
the i18n key when present — no more by-value special case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): address cr minors

- `marks.ts` fontname `toDOM` rejects `"` or `\` instead of interpolating
  them: a hand-crafted mark with `fontname: 'X"; background:url(...);'`
  could otherwise close the quoted string and inject arbitrary CSS.
- `AnchoredBar` gains `onOpenChange` (clears the canvas selection on
  Radix-initiated dismiss): silences the controlled-without-handler dev
  warning, and brings back Esc / SR dismissal that our focus-outside
  hardening had cut off.
- `useSyncEditingElementId` folds two `useLayoutEffect`s into one with
  a cleanup; the previous unmount-only effect was structural noise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: pin body padding-right so popovers don't reflow the page

Radix Select / Popover wrap with `react-remove-scroll`, which adds a
compensation `padding-right` to <body> when they open. Our <html>
already reserves the scrollbar gutter (`scrollbar-gutter: stable` +
`overflow-y: scroll`), so the compensation added a visible ~15px shift
on every dropdown open. Pin body's padding-right with `!important` so
the page stays still. (xuyuanwei678 review #2.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): surface legacy font names via SelectValue children

The earlier placeholder approach didn't work — Radix's `placeholder` only
fires for an empty `value`, not for an unmatched non-empty one. So an
element with a legacy fontname (e.g. `Microsoft YaHei`, `PingFang SC`,
theme defaults) outside the curated FONTS registry still rendered a blank
trigger. Render the trigger text via `SelectValue` children instead — the
new `currentFontLabel` helper covers all three cases: matched → entry's
i18n / fallback label, unmatched non-empty → the raw family name, empty
→ the default-font label. Unit tests cover each case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): preventDefault on pointer-down-outside so drag/resize work

The onOpenChange handler added to silence the Radix dev warning + restore
Esc dismissal also fired on pointer-down-outside — i.e. on every mousedown
on the selected element to drag it or grab a resize handle. That cleared
the selection before the drag could start, so nothing on the canvas could
be moved or resized. preventDefault on `onPointerDownOutside` (matching
the existing `onFocusOutside` hardening) keeps the bar selection-driven
while leaving Esc as the legitimate onOpenChange path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): arm-and-place insertion for text boxes

Replaces the "auto-insert at a hidden default position" UX. Click
`Text box` → arms text-insertion: the button takes the violet active
style, and the renderer's existing ElementCreateSelection overlay turns
the canvas cursor into a crosshair. On the canvas:

  - click  → 300×60 box at the click point
  - drag   → a box at the dragged rect

Either way the new box is auto-selected (addElement defaults that on),
and the surface's existing useEditingTextElementId picks it up so the
AnchoredTextBar opens on it. Esc disarms; clicking the armed button
again disarms (toggle).

Completes the text branch in the renderer's `useInsertFromCreateSelection`
(pptist scaffolding left it TODO) and bypasses the 200² square fallback
in `ElementCreateSelection` for the text type (a square wouldn't suit a
text box). `InsertPaletteItem` gains an `active?` field so `CommandBar`
can render the armed style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): render list bullets in slide text

Tailwind's preflight resets `list-style` to none, so the format bar's
`bulletList` toggle wrapped selected text in `<ul><li>` but no marker
ever appeared — the button looked inert. Scope a list-style restoration
to `.editable-element-text ul/ol/li` so bullets / numbers render in the
slide text without leaking into the rest of the app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): editable font-size input in the text format bar

The size was a read-only `<span>` between the −/+ steppers. Replace with
an `<input type=text>` that mirrors `attrs.fontsize` locally, commits on
Enter / blur (clamped to [8, 96]; non-numeric reverts), and reverts on
Escape. Adds the `edit.text.fontSize` aria-label key in all 6 locales.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): force list markers visible (defeat preflight specificity)

The earlier list CSS didn't survive Tailwind's preflight (which also
resets `padding: 0` on `<ul>`/`<ol>`, so with `list-style-position: outside`
the markers had no room to render). Add `!important` on `list-style` and
`padding-inline-start`, and broaden to also match `.prosemirror-editor ul`/
`ol`/`li` in case the markup ever nests differently than expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): reset richTextAttrs when the editing element changes

`richTextAttrs` is a single shared store updated by whichever ProseMirror
was last focused. Switching from one text element to another visibly
carried the previous element's toggle states (bold / italic / alignment /
list) on the format bar for a moment — until the new element's
ProseMirror took focus and repopulated the attrs. `useSyncEditingElementId`
now resets the attrs to defaults whenever the editing id changes, so the
bar shows a neutral state during the transition instead of stale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): replace OS color dialog with a curated palette popover

Clicking the text-color swatch opened the browser's native `<input type=color>`
dialog — off-brand and inconsistent across platforms. Swap it for a
`ColorPicker` popover: a 12-swatch grid covering the common slide-text needs
(4 neutrals + warm + cool) plus a hex input for anything else. Closes on
pick. Selected swatch gets the violet outline; hex input commits on Enter /
blur (reverts if invalid).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): replace flat swatch popover with a real color picker

The previous popover was a chunky 12-swatch grid plus a hex input nobody
types into. Rebuild on `react-colorful` (3KB, well-tested):

- SV pad + hue slider for free-form picking, with scoped CSS overrides to
  keep the picker tight (128px pad height) and rounded — not stock.
- OS eyedropper via the EyeDropper API, feature-detected (Chrome / Edge;
  hidden on Safari / Firefox).
- Row of 10 small (18px) common colors at the foot for one-click reach.
- Current-color preview + read-only hex display.
- Hex input dropped entirely — picking is meant to be tactile.

Live preview while dragging; the popover closes on a swatch / eyedropper
commit (not on drag).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): keep the color popover open while dragging the picker

Each SV-pad / hue-slider drag tick fires onChange → dispatches the color
command → `editorView.focus()` pulls focus out of the popover into
ProseMirror. Radix's default onFocusOutside path was treating that as a
dismiss, so the popover closed the instant a drag started — clicking
anywhere on the picker shut it. preventDefault on `onFocusOutside`
(mirrors the AnchoredBar hardening) keeps it open; the popover still
closes on swatch / eyedropper commits and on outside-click / Esc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): scope body padding override + gate ColorPicker mid-drag sync

Two follow-ups from a self-CR on the branch:

- `body { padding-right: 0 !important }` was global, overriding Radix's
  `react-remove-scroll` compensation for every Dialog / Sheet / Select /
  Popover across the app. Scope it to a `body[data-maic-editor='true']`
  selector; `SlideCanvas` sets the attribute while mounted. Non-editor
  pages get Radix's default behavior back.
- `ColorPicker`'s `useEffect(() => setColor(value), [value])` mirror
  could race a stale `value` against the user's current pointer position
  mid-drag — a single late round-trip would snap the picker back. Gate
  the re-sync on `isDragging.current` (cleared on `pointerup`); external
  commits (swatch / eyedropper) still sync immediately because they fire
  while no drag is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): polish from self-CR

- Gate the `richTextAttrs` reset in `useSyncEditingElementId` to only
  fire on element-to-element transitions (track previous editing id via
  a ref). The unconditional reset on the first selection briefly flashed
  neutral defaults (color #000, fontsize 16px) before the focusing
  ProseMirror repopulated the real values.
- Doc-comment the text-insertion add-element asymmetry: text uses the
  renderer's `addElement` (because the rect math lives there and we get
  auto-select for free), image uses surface-side `applyOp` (its source
  is the ImagePicker, not a canvas gesture). Both commit through the
  same store, but the text lane doesn't show as a typed `element.add`
  op in the session history — acceptable, now explicit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): listen to every gesture-end channel in ColorPicker

CR round-2 residual nit: the single `pointerup` listener that clears the
drag-gate would silently keep the gate stuck on any browser / emulator
that only emits the older mouse/touch families. Listen on all four
(`mouseup`, `touchend`, `pointerup`, `pointercancel`) — belt-and-suspenders,
no behavior change on the common path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): preserve image aspect ratio on insert

`createDefaultImageElement` hardcoded the new image's box to 360×220, so
anything not ~1.6:1 (which is almost everything users upload — photos,
screenshots, logos) ended up squashed or stretched the moment it landed
on the slide. Wrap the factory in `insertImageElement` that measures the
source via `new Image()`, then dispatches `element.add` with dimensions
scaled to fit MAX 600×400 while preserving the natural ratio. Load
failure falls back to the factory default so insertion always succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop the now-dead addElement helper

`addElement` was only ever used by the inline image-insert which became
`insertImageElement`; text uses `armText` (toggle). PPTElement-typed
parameter was already unused after the text refactor — removing the dead
helper resolves the lint warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop now-unused PPTElement import in use-slide-surface

After `addElement` was dropped (55a9a71), the `PPTElement` type import has
no remaining consumers in this file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): nav rail, scene management, Pro mode chrome rework (#601)

* feat(maic-editor): slide nav rail + scene management (3/3)

PR3a Phase 1 ships the Pro mode left rail + slide-level management, the
last user-visible block of #562. Closes the gap where Pro mode locked
the user on the current scene with no way to navigate or manage the
deck.

SlideNavRail (Studio Editor aesthetic, mirrors playback `SceneSidebar`
visually — index badge + title above an aspect-video thumbnail card —
so the two sidebars read as the same component family across mode
toggle):

- Vertical thumbnail strip via `motion.dev` `Reorder.Group` with
  drag-to-reorder. `Reorder.Item layout="position"` keeps the layout
  animation on y-axis only; width changes from rail resize don't fight.
- Drag-to-resize handle on the right edge writes `style.width`
  directly on the DOM during the gesture and commits to settings store
  only on mouse-up; matches playback drag feel exactly and skips the
  per-frame `persist` serialization that would otherwise burn the
  frame budget at 60 Hz.
- Collapsed and expanded modes; width and collapsed flag persist in
  `useSettingsStore` (`editRailWidth`, `editRailCollapsed`).
- All scene types are first-class — slides render a live
  `ThumbnailSlide` (now with optional `size` prop → self-measures via
  `ResizeObserver` when omitted, so the rail width is the single source
  of truth), non-slide scenes render the same stylised mockups
  playback `SceneSidebar` uses (extracted to `SceneThumbnailContent`).

Slide management:

- `+ Add` in the rail header inserts a blank slide after the current
  scene; new store action `useStageStore.insertSceneAfter` validates
  stage id, migrates the scene, splices, rebalances `order`, and
  triggers `debouncedSave`.
- Three-dot menu per tile: Rename / Duplicate / Delete. Rename also
  reachable via double-click on the title; Enter commits, Escape
  cancels, blur commits, empty input reverts.
- Duplicate deep-clones slide content with fresh element IDs (avoid
  React key collisions) and a `(copy)` title suffix.
- Delete uses a toast with Undo action; deleted scene is held in a
  small `useDeletedSceneRecycle` zustand store and re-inserted at its
  original index on Undo. Deck-empty guard at the rail layer.
- Inter-thumb `InsertionZone` reveals a violet `+` badge on hover,
  right-anchored, with a popup motion (`cubic-bezier(0.34,1.56,0.64,1)`)
  + drop shadow + `z-20` so it lifts above the active tile's violet
  ring. Zero layout shift.

Chrome bar:

- `HeaderControls` (settings pill + Pro Switch) extracted from
  `Header` so Pro mode can mount it in the CommandBar's trailing slot
  — single top chrome bar in Pro mode instead of stacking Header +
  CommandBar.
- Back-to-home button in CommandBar mirrors the playback Header's
  leftmost button.

i18n: new `edit.nav.*` namespace across en-US / zh-CN / zh-TW / ja-JP
/ ar-SA / ru-RU.

Tests: vitest for `insertSceneAfter`, `useDeletedSceneRecycle`,
`createBlankSlideScene` / `duplicateSlideScene`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): split Stage chrome into mode-specific roots

Bug-driven architectural rework. Two symptoms motivated this:

1. Switching from a slide scene to a non-slide one (interactive / quiz
   / pbl) flickered the entire edit chrome — CommandBar and SlideNavRail
   remounted along with the canvas. Root cause: EditShell returned a
   different component type (EditShellWithSurface vs EditShellReadOnly)
   based on whether a SceneEditorSurface was registered for the scene
   type, so React reconciled the change as an unmount/remount of the
   whole subtree.

2. `components/stage.tsx` had grown to 1391 lines — playback engine
   state, chat / TTS / discussion wiring, presentation/fullscreen,
   keyboard handling, AND the edit-mode dispatcher all in one place.
   Any change to mode coordination meant touching this god component.

Changes:

- New `NOOP_SURFACE` (`lib/edit/noop-surface.tsx`) — a no-op
  SceneEditorSurface used as a fallback when a scene type has no
  registered editor surface. `SurfaceState.history` is now optional so
  read-only surfaces can omit undo/redo cleanly. EditShell falls back
  to NOOP for unregistered types.

- EditShell now mounts a single Frame across all scene types. Surface
  state is published from a child `SurfaceStateRunner` keyed by
  `scene.type` (so it remounts only when the runner's hook signature
  changes — rules-of-hooks compliant), with a custom shallow
  equality so the chrome doesn't re-render every render cycle for
  reference-fresh state objects. Result: slide ↔ interactive no longer
  remounts the CommandBar or the leftRail.

- `stage.tsx` → 113 lines. Mode dispatch + cross-tab edit-lock
  coordination + Pro-Switch toggle wiring + multi-tab conflict prompt
  only. Everything else moved into one of two new components:

  - `PlaybackChromeRoot` (`components/edit/PlaybackChromeRoot.tsx`):
    owns the entire playback / autonomous chrome — PlaybackEngine,
    chat, discussion TTS, presentation mode, keyboard shortcuts,
    SceneSidebar, Header, CanvasArea, Roundtable, ChatArea,
    AlertDialog. Exposes `teardown()` via forwardRef so the toggle can
    `await` SSE / engine / TTS shutdown before unmounting it.

  - `EditChromeRoot` (`components/edit/EditChromeRoot.tsx`): the Pro
    mode chrome wrapper — EditShell + SlideNavRail + HeaderControls
    trailing slot. Owns `body[data-maic-editor]` lifecycle (lifted from
    SlideCanvas so it covers read-only Pro-mode scene types too).

- New `StageGrid` (`components/edit/StageGrid.tsx`) — CSS-Grid named-
  slot layout shell with top / left / center / right / bottom areas
  for the Pro mode chrome. Future right panel (properties / AI) and
  bottom timeline plug in as props with no structural code change.
  EditShell's Frame now uses StageGrid internally.

- Deleted `components/edit/SlideTransitionBridge.tsx` (dead code from
  the original A3 transition plan that this rework supersedes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): cross-fade chrome roots on Pro mode toggle

Wrap the chrome-root dispatch in `AnimatePresence mode="wait"` with a
180 ms opacity fade-out / fade-in. The outgoing root fully exits before
the incoming one mounts, so:

- The single-canvasStore-writer guarantee from the chrome split is
  preserved (ScreenCanvas and Editor/Canvas never coexist).
- Mode toggle reads as a smooth fade instead of a hard cut.

Stage's outer wrapper now carries the stable `bg-gray-50 dark:bg-gray-900`
background so neither root reveals raw page colour while it passes
through opacity 0. `initial={false}` skips the entry animation on first
mount so the initial playback render is instant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drawer-style mode swap transition

Pro toggle was a hard cut — playback chrome vanished, edit chrome
popped into place. Now wraps the swap in `AnimatePresence` with the
two chrome roots layered via `absolute inset-0` so they coexist for
~280ms:

- Edit chrome enters from above (`translateY: -32 → 0`) + fades in,
  giving a "drawer drops down" feel that matches the inner
  CommandBar/leftRail stagger choreography.
- Playback chrome cross-fades opacity-only; no transform so its
  active slide canvas stays put underneath while edit drops over it.

Both roots keep rendering during the overlap, so `canvasStore`'s
scale writer doesn't briefly read zero and snap the slide to a stale
size when one root exits ahead of the other. Duration 280ms /
`CHROME_EASE` matches the inner Frame timing source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): Pro Switch as a shared layout element across modes

The Pro Switch is the click anchor for the mode swap, but it lives in
two different positions: the 80px playback Header (top-right) vs the
56px edit CommandBar trailing slot (also top-right but at a different
y and with different padding). After the click the switch "jumped" —
it visibly moved + restyled — which felt unsmooth even though the
chrome itself was cross-fading.

Tag the Pro Switch label (and the settings pill) with `motion.layoutId`
so motion treats them as shared elements across the AnimatePresence
swap. During the ~280ms transition, motion measures both instances
and morphs position + size between them — the user's click target
slides into its new home instead of teleporting. Same easing source
as the chrome cross-fade so the two animations stay locked together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): unify chrome shell across modes

Pro Switch + settings pill + download icon are the user's mode-toggle
"anchors" — they need to sit at the same screen pixel across
playback ↔ edit. They didn't, because the two chromes had different
shapes:

  Playback: SceneSidebar (left, full height) | Header (h-20, 80px)
            on top of CanvasArea + Roundtable
  Edit:     CommandBar (h-14, 56px, FULL width) on top of a row of
            (SlideNavRail | content), so the rail sat *below* the bar

So when the user clicked Pro, the bar collapsed by 24px AND the rail
shifted down by 56px AND the right-side controls re-styled (compact
variant) — three simultaneous moves. layoutId masked some of it but
the underlying structure was wrong.

Unify the shells:

- `StageGrid` template flipped from `top top top / left center right /
  bottom bottom bottom` to `left top top / left center right / left
  bottom bottom`. The left column now spans all rows so the sidebar
  always reaches the absolute top edge, matching playback exactly.
- `CommandBar` grows h-14 → h-20 + px-5 → px-8, identical to playback
  Header.
- `EditChromeRoot` drops the `variant="compact"` flag on
  `HeaderControls` so the settings pill renders at the same h-9 pill
  it does in playback.
- `SlideNavRail` header replaces the "SCENES" label with the OpenMAIC
  logo (click → home), matching `SceneSidebar`'s shape so the sidebar
  top reads as the same component family in both modes.
- Download / Export dropdown moves out of `Header` and into
  `HeaderControls` so it's present in both playback and edit chrome at
  the same right-cluster position (was previously playback-only).

`Header.tsx` slimmed accordingly. Net effect: the right-edge cluster
(EN, theme, settings, download, Pro Switch) lives at the same screen
pixel across modes; the cross-fade transition only animates the
*contents* inside the bars + sidebar lists, not the bar/rail
positions themselves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drop sidebar header + button, add insert-before-first zone

The header `+` was a duplicate affordance — every gap between thumbs
already has its own `InsertionZone`. Remove the header button; insert
flows entirely through the gap zones now (with hover-popup + and
right-anchored visual).

Add one extra `InsertionZone` rendered BEFORE the first thumb so the
top padding of the rail is also clickable / hoverable. Insert-before-
first is implemented inline via `setScenes([blank, ...scenes])`
because the `insertSceneAfter` store API only handles insertion after
an existing anchor.

`PlusCircle` import dropped (no longer used).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): move Download out of settings pill, place right of Pro Switch

Download isn't a settings function (it's an export/share action), so
it shouldn't sit inside the pill that hosts language/theme/settings.
Move it back to a standalone button on the right side of the Pro
Switch — both in playback and edit chrome. Right cluster now reads:

  [ EN · theme · settings ]  [ PRO switch ]  [ Download ]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): floating insert toolbar above canvas (collapsible)

Text box / Image / future shape buttons no longer share CommandBar
with global stage controls (back / undo / redo / title / settings /
Pro Switch / Download). Insert is a content-creation action, not a
stage-navigation one — mixing them blurred the chrome's role.

Lift insert items into a new `FloatingInsertToolbar` that floats
centered ~12px above the slide canvas card. Default expanded; collapse
arrow tucks it into a small chevron handle at the same anchor. State
persists in `settings.editInsertToolbarCollapsed`. Reuses the existing
`InsertButton` (extracted from CommandBar into a sibling module so
both surfaces — the now-removed CommandBar slot and the floating bar —
can share styling).

CommandBar drops its `insertItems` prop / middle slot entirely;
right-side controls collapse to a single `flex shrink-0` cluster
matching playback Header's shape.

i18n: `edit.insert.expandToolbar` / `collapseToolbar` across 6 locales.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): auto-focus text element after toolbar insert

Inserting a Text box via the FloatingInsertToolbar + click/drag on
the canvas left the user one click short — the new element was
selected and the AnchoredTextBar opened, but the ProseMirror editor
never received focus, so the first keystroke went nowhere and the user
had to click inside the element again before typing.

`useEditingTextElementId` already mirrors the surface's editing-target
choice into `canvasStore.editingElementId`. Have `ProsemirrorEditor`
watch that flag in an effect: whenever its own elementId becomes the
editing target (insert, programmatic selection, etc.) and it doesn't
already have focus, push focus into the view. `hasFocus()` guard keeps
this from re-focusing on every re-render of an already-active editor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): prettier + drop ThumbItem rename sync effect

- prettier --write on three files touched by the recent edits.
- ThumbItem: drop the `useEffect(() => { if (!renaming) setDraft(...) })`
  external-title sync that tripped `react-hooks/set-state-in-effect`.
  Idle display now reads from `scene.title` directly (derived rather
  than mirrored); `startRename` seeds `draft` at session start and
  `cancelRename` resets it so the next session starts clean. Rename
  e2e still passes the menu + double-click + Escape paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): CR-loop pass — pointer capture, stage-scoped recycle, equality docs

PR #601 reviewer feedback.

**Drag handle uses Pointer Events with `setPointerCapture`** so the rail
no longer gets stuck in "still dragging" state when the cursor leaves
the window, the OS reclaims focus, or a tab interrupt suppresses the
mouseup that the old document-bound mousemove/mouseup pair relied on.
The handle's onPointerMove/Up/Cancel are now bound directly on the
element; capture guarantees event delivery for the lifetime of the
gesture. Drag tracking e2e still PASS (1 px cursor lock).

**Toast Undo guards stage identity** before re-inserting the deleted
scene. If the user navigated to a different stage while the toast was
up, the recycle entry belongs to the previous stage and
`insertSceneAfter` would reject it on stage-id mismatch — silently
losing the deleted scene. New check drops the undo cleanly when stage
ids don't match. The `stageId` field was already captured on
RecycleEntry; just wasn't consulted.

**`surfaceStateEqual` extended** to compare per-item `id` / `disabled`
/ `label` on `floatingActions` (was length-only) and per-item
`id`/`severity`/`message` on `hints` (was length-only). Today's slide
surface returns `floatingActions: []` and `hints: []` so this is
dormant, but PR3b's z-order actions land in `floatingActions` — pinning
the equality semantics now keeps a future state field from silently
going stale in the chrome. SurfaceState gets a maintenance note
cross-linking to the equality function.

**Header.tsx mode guard comment** updated. The `mode !== 'edit'` guard
around the title block isn't dead — it covers the ~280ms
AnimatePresence exit window where playback chrome is still rendering
its exit animation while mode has flipped to 'edit'. Without the
guard, this title would briefly stack on top of the incoming
EditChromeRoot's CommandBar title during the cross-fade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(maic-editor): CR-loop round 2 minors — sharpen JSDocs

Round-2 reviewer flagged two doc-only refinements:

- `surfaceStateEqual`: clarify that callback identity (`onInvoke`,
  `popoverContent`) is intentionally NOT compared, and that today's
  safety comes from slide-surface returning `floatingActions: []`
  rather than the per-item compare covering callbacks. A future
  surface that emits closure-capturing actions must fold its own
  change signal into the comparison or the stale callback fires at
  click time.
- `setPointerCapture` catch: spell out that this is paranoia, not a
  real fallback — if capture genuinely fails the gesture still
  tracks for in-window moves but out-of-window `pointerup` won't
  route here. Acceptable degradation; the catch exists only because
  the spec permits an `InvalidPointerId` throw that browsers we ship
  to don't actually emit on same-pointer `pointerdown`.

No functional changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): undo restore at index 0, reset mode on classroom load

Two issues from PR #601 manual-verification review:

**Undo of the first slide restored it as the second.** The toast undo
handler clamps `entry.index - 1` to 0 then calls
`insertSceneAfter(scenes[0], entry.scene)`, which lands the entry at
position 1 instead of position 0 — no scene exists before scenes[0] to
anchor on. Fall back to `setScenes([entry.scene, ...live])` when
`entry.index === 0` (or when the deck is empty). The store's existing
non-rebalancing `deleteScene` keeps the surviving scenes at orders
2..N, so the prepended entry's original order=1 lines up naturally;
StageGrid auto-selects the restored scene as current.

**`mode` survived SPA navigation between classrooms.** Refresh reset
mode to 'playback' via the initial store value, but switching
classrooms via Next.js navigation kept the zustand singleton intact;
entering Pro mode in A and then opening B left B in edit mode.
`loadFromStorage` and the server-side classroom-load path both now set
`mode: 'playback'` on every classroom load, normalising the SPA path
to match the refresh path. Mode stays transient UI state, not
persisted with the stage.

e2e: delete Slide 1 → Undo → restored to position 1 (was position 2
before fix).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): gate slide scene creation until inserted scenes are playable (#612)

* feat(maic-editor): gate slide scene creation until inserted scenes are playable

Editor-created slide scenes (blank insert + duplicate) ship without
playback actions, so the playback engine gives them zero dwell and skips
straight past them — a freshly inserted slide is effectively unplayable.
Seeding default actions on new scenes is a separate change; until then,
hide the two scene-creation entry points so the editor stays coherent as
an in-place "fine-tune the generated deck" tool.

- add lib/edit/scene-creation-enabled.ts (SCENE_CREATION_ENABLED=false)
- hide inter-thumb "+" insertion zones (SlideNavRail)
- hide per-slide Duplicate menu item (ThumbItem)
- keep reorder / delete / rename, which are playback-safe

Re-enable by flipping the flag once new scenes get default actions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): e2e guard for slide scene-creation gate

Adds an e2e that generates a classroom (mocked), enters Pro mode, and
asserts the slide rail exposes no insertion "+" zones and the per-slide
overflow menu has only Rename + Delete (no Duplicate). Fails if
SCENE_CREATION_ENABLED is flipped back on without removing the gate.

Two stable test ids support locale-independent assertions:
- slide-nav-insert  (InsertionZone button)
- slide-nav-more    (ThumbItem overflow trigger)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): enable editor flag for the e2e webServer

The scene-creation gate e2e needs the Pro Switch, which only renders when
NEXT_PUBLIC_MAIC_EDITOR_ENABLED is on. It's a build-time NEXT_PUBLIC_* flag,
so set it in the Playwright webServer env (applies to `pnpm build` in CI and
`pnpm dev` locally). Fixes the e2e failure on CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): attach gate screenshot to report instead of fixed path

CR: e2e-artifacts/ is not gitignored, so writing the screenshot to a fixed
path left an untracked file that could be committed by accident. Use
testInfo.attach so the image lands in the (ignored) Playwright report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(maic-editor): add pt-BR translations for edit.* / stage.* keys

pt-BR locale (added on main post-stack) lacked the 51 editor keys, so
check:i18n-keys failed after rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): defer editor-only side effects behind Pro mode mount

editor-fonts (~23 @fontsource CSS tables) and slide-surface registration
were top-level static imports in app/layout.tsx and components/stage.tsx,
so flag-off classroom/playback users paid the font-face CSS + slide-edit
module-init cost on every page load.

Move both to a dynamic import in EditChromeRoot (mounts only when
mode==='edit', which requires NEXT_PUBLIC_MAIC_EDITOR_ENABLED). Hold the
EditShell render until the slide surface registers to avoid a NOOP/
read-only flash on first Pro mode paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): CR-loop correctness fixes (redo/groupId/edit-lock)

Three confirmed issues from the rebase code-review pass:

- slide-edit-session: a non-user (ResizeObserver auto-height) commit
  updated history.present while preserving a now-stale future, so a
  redo after undo silently resurrected pre-undo content. Clear future
  on the non-user path (present has diverged from the redo branch);
  past is left untouched so no spurious undo step is created.
- slide-defaults: duplicateSlideScene reassigned element ids inline,
  leaving grouped elements pointing at the source slide's groupId.
  Use the existing createElementIdMap so clones get a new shared
  groupId. (Path is gated off today via SCENE_CREATION_ENABLED; fixes
  a latent defect.)
- stage: wrap playback teardown on Pro-mode entry in try/catch and
  release the just-acquired cross-tab lock on failure, so a rejected
  teardown can't strand the lock with the UI stuck in playback.

Adds regression tests for the redo-stale and groupId cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): scope editor list-marker CSS to Pro mode

The .editable-element-text ul/ol/li rules used a bare selector, but
that class is the playback text wrapper rendered for every classroom
user — so the !important list-style overrides leaked into normal
playback. Scope them to body[data-maic-editor='true'] (set only while
Pro mode is mounted) so flag-off playback rendering stays unchanged;
markers still show while editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): smooth Pro mode transition and stabilize header controls

The Pro mode enter/exit animation janked and the right-side header
controls (settings pill + Pro Switch) drifted in width/position across
the swap. Three causes, all addressed:

- The flag-gating dynamic import gated EditShell behind surfaceReady, so
  the chrome animated in empty and content popped in once the slide-surface
  chunk loaded. Preload the editor chunk (fonts + surface registration) in
  the Pro Switch handler BEFORE flipping mode (lib/edit/preload-editor.ts),
  and drop the render gate — content is present when the animation starts.
- Mode-swap layers and EditShell chrome layers used translateY/translateX
  slides; with backdrop-blur on the rail and pills that forced a per-frame
  backdrop-filter recompute (dropped frames) and, as transform ancestors,
  distorted the layoutId measurement. Switched all chrome enter animations
  to pure opacity fades.
- HeaderControls rendered a fragment whose children were spaced by the
  host's flex gap (Header gap-4 vs CommandBar trailing gap-2), so the
  control cluster changed width/anchor between modes. Wrapped it in a
  self-contained gap-4 container and dropped the cross-bar layoutId morph
  so the cluster is pixel-stable across the swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): address review — image insert race, scene migration, i18n staleness

Review feedback from @cosarah on the integration PR:

- Image insert resolved size via Image.onload then applied the op to
  whatever slide session was current at callback time, so switching
  slides before the image loaded inserted it into the wrong slide. Bind
  the insert to the scene active at click time and drop the op if the
  session changed before onload fires.
- Classroom scenes loaded from IndexedDB (loadFromStorage) and from the
  server API (classroom page) bypassed migrateScene, so legacy slide
  content was not normalized with schemaVersion. Both load paths now
  migrate on the way in, matching setScenes/addScene.
- surfaceStateEqual compared insert-item/command id/active/disabled but
  not label/tooltip, so the Pro-mode insert toolbar text stayed stale
  after a language switch. Compare label/tooltip too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:54:04 +08:00
77bcd589e0 docs: add Fumadocs documentation site (#622)
* docs: add Fumadocs documentation site (packages/docs)

Static-exportable Fumadocs site for the OpenMAIC docs, served under /docs.
- 6 locales (en default + zh-CN/zh-TW/ja/ru/ar) with Arabic RTL
- English unprefixed (/docs/getting-started) to preserve legacy URLs;
  other locales at /docs/<lang>/<slug>
- Orama static search compatible with `output: 'export'`
- Brand theming via CSS variables; "Live Demo" link back to open.maic.chat

Kept fully isolated from the main app so it does not affect its build or CI:
- excluded from the pnpm workspace (own lockfile, build and deploy)
- ignored in .prettierignore (own toolchain, like the other packages)
- excluded from the root tsconfig so the main app build does not type-check it
ESLint already ignores packages/**.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: add standalone docs build check

Validate that packages/docs still builds (next build + static export) on
PRs that touch it. Runs only on packages/docs changes; no secrets, no deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: address CR — CJK search, single-source locale config

- Fix Chinese/Japanese search: zh-CN/zh-TW use the mandarin tokenizer and ja
  the japanese tokenizer (@orama/tokenizers) on both the build-time index and
  the client query; the english whitespace tokenizer indexed CJK as one token.
- Add lib/locales.mjs as the single source for the locale list, default locale,
  per-locale search tokenizer, RTL set, labels, and the /docs basePath; consume
  it from i18n, the search index/dialog, next.config, and the postexport script
  (was duplicated and drift-prone).
- Drop dead gitConfig.branch; fix the stale README route table; document the
  English-nested-page limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-05-31 00:11:17 +08:00
Madoka a47b2d2fb1 Feat/439 i18n key alignment check (#447)
* feat(ci): add i18n key alignment check

* fix(ci): harden i18n key alignment script
2026-04-18 13:31:55 +08:00
wyucandClaude Opus 4.6 ae08cf47d1 chore: update Discord invite links
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:02:13 +08:00
37e04558b6 test: add Playwright e2e testing framework with core scenario coverage (#229)
* chore: add Playwright e2e testing infrastructure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add e2e mock fixture data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add MockApi route interception helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add base fixture and page object models

Also exclude e2e/ from ESLint to avoid react-hooks false positives
on Playwright's fixture `use` callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add home-to-generation e2e spec

Adds the first Playwright spec covering the home page UI and navigation
to generation-preview. Also updates playwright config to use port 3002
to avoid conflicts with other running services.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add generation-flow e2e spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add classroom-interaction e2e spec

Seeds IndexedDB with a 3-scene stage by navigating to / first (so
Dexie initializes the DB at v8), then writing data without re-triggering
onupgradeneeded. Verifies the sidebar renders 3 scenes and that clicking
a scene switches the active scene heading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: add Playwright e2e test job

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(e2e): fix review issues — data-testid selectors, shared helpers, type annotations

- Add data-testid="scene-list", "scene-item", "scene-title" to scene-sidebar.tsx
- Update classroom.page.ts selectors to use data-testid instead of CSS class chains
- Extract createSettingsStorage() helper into e2e/fixtures/test-data/settings.ts
- Update all 3 spec files to use createSettingsStorage() and import defaultTheme from scene-content
- Add SceneOutline[] type annotation to mockOutlines via relative import
- Add SlideTheme type annotation to defaultTheme in scene-content.ts
- Remove redundant mockServerProviders() call from setupGenerationMocks()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: exclude e2e/ from tsconfig, rename CI job

- Add "e2e" to tsconfig.json exclude array to prevent Next.js
  compilation from pulling in Playwright/Node APIs
- Rename CI job from "Lint & Typecheck" to "Lint, Typecheck & Unit Tests"
  to reflect that it now also runs unit tests (added in PR #144)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-03-24 12:19:59 +08:00