Skip to content

weft v0.10.0 — the request record, Studio's step and export routes, the honesty table, one version from the module

Choose a tag to compare

@wajihtba wajihtba released this 08 Oct 10:18
· 242 commits to main since this release

The request record (ADR 0028): every model call's system prompt, tool
catalog, parameters and attempts recorded beside the transcript, read
back by Studio and the devtools panel with a closed table of honesty
badges; app logs, the step route, the export in four formats; one
version from the module. Tags core/v0.10.0 (compatible additions
only) and v0.10.0 (breaking in the pre-freeze layers listed below).

Changed — breaking

  • One version, the module's. The new github.com/weftgo/weft/version
    package (standard library only) holds it: version.Version is the
    release tag, version.Runtime() reads the framework module's version
    from the build info (the main module or the github.com/weftgo/weft
    dependency, honouring replace) and falls back to Version under
    (devel). studio.Version is now version.Version — v0.4.1 →
    v0.9.0 — so /api/meta's studio_version and the devtools panel's
    embedded version (stamped from version/version.go at build) move
    with the module. A panel built for v0.4.1 refuses a Studio that
    reports v0.9.0 as newer.
  • /api/meta's weft_version is the framework module's build-info
    version
    (version.Runtime()), no longer the core dependency's;
    inside the workspace it reads the tag instead of (devel).
  • obsdb.DB gains TranscriptBatches (ADR 0028 §8): a run's
    messages records with what each stored — index, step
    (weft.step.index, -1 when absent), input flag. A third-party DB
    reads pos, step, body and the weft.messages.input attribute per
    messages record, returns them through obsdb.DedupBatches, and
    implements Transcript as obsdb.TranscriptBodies(batches). A
    backend with no attribute column infers the input flag on index 0 and
    sets TranscriptBatch.InputDerived.
  • /api/runs/{id}/transcript rows carry what the record stored: the
    stored index, the stored step (or -1 with "badge": "not_recorded") and the stored input ("badge": "derived" where
    the backend inferred it) — no longer derived from assistant-message
    order. Playground transcript_edits / from_step validation and the
    web client read the same stored step; only a batch without one is
    placed by inference, marked derived.
  • obsdb.DB gains Requests, Prompt, Tools and Catalogs
    (ADR 0028 §10, the request record's read side). A third-party DB
    reads its request, prompt and tools records (stored under
    (run, kind, index)) as obsdb.StoredRecords through
    obsdb.RequestRecordOf, obsdb.PromptRecordOf and
    obsdb.ToolsRecordOf, returns one tools record per hash (the lowest
    index) from Catalogs, and answers a missing hash with
    obsdb.ExplainMissing. obsdb.RunRow gains InstructionsHash,
    CatalogHash and RequestCount, which a backend fills with the
    larger of run_start's and the invoke_agent span's
    weft.instructions.hash, request index 0's weft.catalog.hash and
    max weft.request.index + 1.
  • obsdb/clickhouse's unreleased migration 0004 gained
    weft_records.Input, Content, TruncatedBytes, SystemHash and
    CatalogHash
    : a database
    that applied 0004's earlier text (only a development build wrote one)
    lacks them and must be recreated. ClickHouse now reads the transcript
    input flag as stored; only rows written before the column read
    TranscriptBatch.InputDerived.
  • obsdb.DB gains Compactions (ADR 0028 §8, plan A9): a run's
    compactions as obsdb.Compaction — each run-scope view in index
    order with its step, half-open range, hash and body, then thread's
    session markers in emission order. A third-party DB builds each through
    obsdb.CompactionOf (from a messages record whose
    weft.messages.reason is set, or a record of kind compaction) and
    orders them with obsdb.SortCompactions. Its Transcript and
    TranscriptBatches must skip every messages record whose
    weft.messages.reason is set, and its messages count must not count
    them (obsdb.Weft.Reason carries the attribute).
  • obsdb.DB gains OtherLogs (plan A7): a run's app log records —
    the non-weft records (no weft.run.id) the writers keep beside
    weft's, attributed to the run through the span they were emitted
    under (its own spans and the non-weft spans below them, never another
    run's) — as an obsdb.LogPage of obsdb.OtherLogs, paged by
    obsdb.LogQuery (From inclusive, Limit 0 = 100 max 1000,
    MinSeverity filtering without renumbering), with Partial (the run
    is running: lines under in-flight spans appear when those spans end,
    and indexes may shift), Gap (lines naming a span never stored) and
    Truncated (more than obsdb.MaxLogCandidates lines in the run's
    traces, counted before attribution). A third-party DB implements it as
    obsdb.ReadOtherLogs(ctx, db, runID, q, candidates), where
    candidates returns the first limit non-weft records of the run's
    traces within a time window, in time order. obsdb.HoleError.Kind gains "logs": a finished run
    with no span has nothing to attribute through and answers
    HoleNotRecorded.

Changed

  • The system prompt and the tool catalog now reach content-on
    destinations
    (otel.Local, otel.Studio) under the content policy:
    Redact-able, capped with weft.content.truncated_bytes, dropped by
    content-off destinations. ADR 0028 reverses the old stance that no
    record carries the instructions text. An application whose prompt must
    not leave the process redacts it (core.ContentPrompt) or turns
    content off for that destination.

Added

  • The compaction marker on the run page and in the panel (plan
    A9.2, ADR 0028 §8).
    GET /api/runs/{id} gains compactions: each
    compaction the run's records name, counts and hash only, never a
    message body — a run-scope view {scope: "run", index, step, from_seq, to_seq, hash, replaced, entries} and thread's session
    marker {scope: "session", hash, replaced, entries, tokens_before?, tokens_after?, reason}; [] for a run that never compacted or was
    written before A9 (the record is optional: no badge). A read-scoped
    panel token reads it. The run page's step card whose request saw a
    view draws "2 messages rewritten into 1 by PrepareStep" ("1 message
    inserted by PrepareStep" when nothing was replaced) with the
    compacted badge; a session marker sits at the top of the run it is
    filed under — "12 messages compacted into 2 · 8.1k → 1.2k tokens".
    "show original" (collapsed, no fetch) expands a view's replaced range
    [from_seq, to_seq) from the transcript route's growth records the
    page already holds (seq = position in their concatenation); a range
    that cannot be placed is the gap badge. The replacement shows its
    count — and, once the step route is loaded, the request's message
    count — and says where its body is (the export's
    compactions[].messages). The devtools panel draws the same marker:
    session markers at the top of the turn, views on their step line.

  • A run's app logs and its delta count in Studio (plan A7).
    GET /api/runs/{id}/logs?from=&limit=&severity=, under a new logs
    capability in /api/meta, pages the app's own log lines (an slog
    bridge or the OTel Logs API on the pipeline's LoggerProvider) that
    were emitted under the run's spans — a tool handler's lines are the
    run's — in time order: {logs: [{index, time, severity, severity_number, body, attrs, span_id?}], next_from?}; severity
    keeps a level and above (trace…fatal, or 1–24). holes lists
    every hole of the page (badge/reason/fix repeat the first):
    not_recorded for a run recorded without a tracer, truncated when
    the run's traces hold more than 10 000 log lines in its window (the
    cap applies before attribution: later lines of this run may be
    missing), gap for lines in the run's trace naming a span that is
    not stored (they may be another run's). A running run's page carries
    partial: true and a partial_reason (lines under in-flight spans
    appear once those spans end; indexes may shift) — not a hole.
    App logs may carry anything the app logged, prompts included, so a
    read-scoped panel token is refused them (403, badge: "hidden"); a
    playground-scoped token, the server token and loopback read them. The
    run row (runs, runs/{id}, sessions, the export) gains
    delta_count, the streamed deltas counted and never stored. The web
    client gains fetchLogs and the types (no UI yet).

  • Subagents on the page (plan A10). GET /api/runs takes all=1
    (every run, subagent children included — the same as parent=*; a
    parent=<run id> beside it wins; a malformed value is a 400) beside
    the existing parent= filter, whose absence keeps the list top-level
    only. On the run page a step whose tool call started a child run
    shows it as a nested row — agent, status, usage, its holes, a link to
    its own page (from the run document's children, or the step route's
    children[] when cached) — and opening it folds the child's steps
    with the child's own request record, read by the child's id
    (/api/runs/<child id>/requests, under the requests capability:
    the child's prompt, never the parent's; a read-scoped token sees
    hidden). A call joins its child by the child's id, which names the
    step (<parent>/<step>/<call id> — call ids may repeat across steps),
    in the story and the trace view's call detail alike; a child id of
    another form falls back to the first unlinked call with its
    parent_call_id. The run document's children[] rows carry each
    child's own holes; a child's usage shows once it ended ("usage at
    finish" while it runs, "—" beside the interrupted badge). The runs table is "top-level only" by default with a toggle
    (?subagents=all) and a ?parent= filter chip; a child row links its
    parent. The devtools panel's subagent badge opens the child inline,
    one level: its row (agent, status, usage), its steps with its request
    line, and an "open in Studio" hand-off carrying the child's id; a
    grandchild is its badge and the hand-off only. A child opened while
    it ran is read again when the parent's reload finds its status moved.

  • Studio's run export (plan A7, A9's byte-faithful fixtures).
    GET /api/runs/{id}/export?format=json|jsonl|otlp|wefttest, under a
    new export capability in /api/meta, downloads the whole run
    (Content-Disposition: attachment; filename="<run id>.<ext>", the
    quoted name plain ASCII — a child's slashes, quotes, backslashes,
    control and non-ASCII characters spelled _ — plus RFC 6266's
    filename* for a non-ASCII id; HEAD answers the headers alone). json is one weft.run.export/1
    document — run (as /api/runs/{id}), events (with gaps),
    transcript (the batches as /transcript serves them),
    compactions, requests (the rows with refs=1, plus prompts and
    catalogs keyed by hash: the record, or {hash, badge}), spans
    and holes; every absent block carries its badge, reason and fix
    (not_recorded for a run written before ADR 0028, stripped for a
    content-off run, gap for records a destination dropped). jsonl is
    the same records one per line, {"record": <kind>, …}, in a stable
    order (run, badges, events by pos, messages by index, compactions,
    requests by index, prompts, catalogs, spans). otlp is
    {"logs": ExportLogsServiceRequest, "traces": ExportTraceServiceRequest}
    in OTLP/JSON (ids as hex): the records are rebuilt from what obsdb
    reads, with the run's identity and metadata on each, the events'
    weft.content marks, and one heartbeat at its last-seen, so
    POST /v1/logs and /v1/traces re-ingest the same run, its
    stripped, truncated and derived holes included (an inferred
    input flag is left unstamped, a derived prompt or tools record goes
    out with its hash and an empty body — the malformed original is not
    kept by obsdb). The log records' resource carries only
    service.name: obsdb keeps no record's own resource, and the spans
    keep theirs verbatim. wefttest is a zip of replay fixtures for
    wefttest.Replay. A read-scoped panel token gets json/jsonl with
    the request block {badge: "hidden"} (the transcript still badged
    stripped for a content-off run); otlp and wefttest are 403
    with the hidden badge. An unknown format is 400, an unknown run 404,
    a run with nothing to fixture 409 with its badge (stripped, gap
    — no transcript, or a step whose request record was dropped — or
    derived).
    web/src/lib/api.ts gains exportUrl(runId, format).

  • Studio's replay fixtures key on the stored step and the request
    record.
    POST /api/playground/fixtures and the wefttest export
    share one builder: each fixture is the step its records were stamped
    with (weft.step.index), not the Nth assistant message; its key takes
    the tool names, thinking, tool choice and sequential flag from the
    step's answering request record (the caller's tools only for a run
    written before the record); a step whose request carried a run-scope
    compaction view keys on the compacted messages the model saw and
    carries a compacted_at header (index, step, range, hash); the
    finish event carries the step's recorded reason, raw reason and
    usage; tool-call signatures are kept; and the system prompt is
    filled for the reviewer. One fixture per step, from its answering
    attempt: failed retry attempts get no fixture, so a replay answers
    the step's first call under any middleware. POST /api/playground/fixtures now refuses a read-scoped panel token (403,
    badge hidden: fixtures are request-derived) and answers "nothing to
    fixture" with 409 and the badge, like the export (it was 400).

  • Every hole is a badge from one closed table, on both surfaces
    (plan A3, ADR 0028 §11).
    obsdb.HoleNote(h) holds each of the ten
    holes' one-line reason and, where one exists, its fix — Studio's
    routes read it (no second copy of the words), and its golden,
    studio/testdata/holes.golden.json, is what the web's
    lib/honesty.ts (the run page and the devtools panel's one table) is
    checked against, key by key. The events route's rows and the live
    record frames carry attrs, limited to the weft.content.* keys
    (weft.content when not full — stripped, or the core's own
    none — and weft.content.truncated_bytes), absent when the chain
    left the content as emitted; obsdb.PosEvent gains Content and
    TruncatedBytes (both backends read them; a ClickHouse row from
    before migration 0004 has neither). GET /api/runs/{id} gains
    holes: [{hole, reason, fix?}] — the run's own: not_recorded
    (written before the request record), interrupted, derived (no
    stored event: a row built from spans), stripped (a content-off
    run) and gap (event positions missing, once the run is over). The
    fold turns attrs into badges on the event, its call, its step and
    the run ("shortened by the recorder: 12.3 KiB cut", "content not
    captured by this app" with its fix); the run header, every step card,
    the raw view's event rows and the panel's turn header and step lines
    draw them; transcript words whose step has no events render as a
    gap row under the last step, and the replay playhead goes through
    the transcript overlay (only steps finished at the playhead take
    their final words), so holes survive scrubbing. The step route's
    holes read its events' attrs too (stripped, truncated with the bytes
    cut). The request routes' reasons are now the table's words.
    redacted stays reserved: weft's pipeline does not mark a redaction.
    studio/testdata/v0.9.0.db is a database weft v0.9.0 wrote; Studio's
    tests open it (migrated to the current schema) and every pane of its
    run says why it is empty.

  • Studio's step route (plan A7, with A4's attempts and A10's
    children).
    GET /api/runs/{id}/steps/{n} (n the step ordinal)
    answers one step assembled server-side from obsdb: status (ok,
    error, parked, running), started/finished, latency_ms/
    ttft_ms, model (requested, and answered — the model that
    answered under mw.Retry/mw.Fallback), request (the requests
    route's row for attempt 1, prompt and catalog inline), attempts
    (each request record joined to its attempt span on the attempt
    number: model, provider, outcome, error_type, retry_after_ms,
    times, span id, request index; attempt 1 falls back to the chat
    span), messages_in (the request's messages_ref as a count),
    events (as the events route serves them), tool_calls (args,
    result with bytes and truncation, the execute_tool span, the child
    run, pending), children (the child runs the step's calls started:
    id, call id, agent, status, usage), usage, compaction (the
    run-scope view the step's request carried) and holes — every hole
    that applies, deduplicated, from ADR 0028 §11's table with a reason
    and fix. A block missing for a reason carries its badge
    (attempts_badge: not_recorded for a run without attempt spans or
    A4 timing, request: {badge: "not_recorded" | "gap"}). Scoped like
    the events route; a read-scoped panel token reads the whole step with
    request: {badge: "hidden", reason, fix}. A step past the run's
    last, or a running run's next, is 404. /api/meta lists the new
    steps capability; the web client gains fetchStep and the
    StepDoc types (no UI change yet). children[].cost is omitted
    until A5 adds costs (absent, not zero); the answering model and the
    attempts' outcomes are read from spans, so a run recorded without a
    tracer badges its attempts not_recorded with the fix to install
    one — obsdb.HoleNoteFor(h, cause) words it (CauseNoSpans), the
    holes golden lists such causes under their hole. Each call is badged
    stripped from its own events' weft.content too, so a content-off
    run written before the request record says so per call. With neither
    content nor a tracer, a max_tokens step's calls are not listed; the
    max_tokens hole says the step made some.

  • Compaction in the record (ADR 0028 §8, plan A9.1). When a
    PrepareStep sends a request whose messages are not the run's
    transcript, the core emits one messages record with
    weft.messages.reason = compacted right before that request's
    record: weft.messages.from_seq/to_seq (the half-open range of the
    transcript it replaced, by longest common prefix and suffix over wire
    bytes), weft.compaction.scope = run, weft.compaction.hash, the
    replacement messages as its body. The request's messages_ref names
    it; the next request names the growth records again. Nothing is
    emitted when nothing changed or with capture off, and what the model
    receives is unchanged. weft/thread reports every session compaction
    (threshold, Compact, ApplyCompaction, a trim, the overflow
    re-run) as one record of kind compaction, emitted when it lands
    under the last run that produced the compacted context — scope
    session, the compaction entry's hash, the messages replaced →
    summary entries and tokens before → after; no messages. Studio's live
    lane never forwards a view, and its catch-up places messages records
    by their stored index. Both obsdb backends keep the plain
    transcript the growth records alone and read both through
    DB.Compactions.

  • (*core.Agent).LoggerProvider() (and weft.Agent's): the OTel
    logger provider the agent's runs emit records through — the option's,
    or the global one resolved at New — for satellites whose records
    must land beside the run's.

  • studio --version prints version.Runtime() and exits.

  • Studio's request record routes (ADR 0028 §10, plan A1). GET /api/runs/{id}/requests?step=&from=&limit=&refs=1: one row per
    model-call attempt (index, step, attempt, time, system_hash,
    catalog_hash, content — "", stripped or derived —,
    truncated_bytes, the parsed body), each row's prompt and tools
    resolved inline by hash unless refs=1 ({hash, badge} when the
    record is missing), next_from while pages are full. GET /api/runs/{id}/tools: {catalogs: [{hash, tools, content, truncated_bytes}]} in index order. A run written before the record
    answers both with badge: "not_recorded" (and a reason and fix)
    beside the empty list; a content-off run's tools answer badge: "stripped". Both are refused to a read-scoped panel token (403 with
    badge: "hidden"), as /api/manifest is.

  • GET /api/runs/{id} (and every run row) gains instructions_hash,
    catalog_hash, request_count and, for a run written before ADR
    0028, requests_badge: "not_recorded".

  • /api/meta lists the requests capability: the UIs gate the Request
    pane on it.

  • obsdb.TranscriptBatch, obsdb.TranscriptBodies, obsdb.DedupBatches
    (ADR 0028 §8).

  • obsdb.Hole and its ten constants (obsdb.Holes(), ADR 0028 §11's
    closed badge table); obsdb.RequestQuery (the zero value reads every
    step from index 0; Step *int, From, Limit, PageLimit()),
    RequestRecord, RequestBody (and its parts), PromptRecord,
    ToolsRecord, ToolEntry, StoredRecord, HoleError,
    RunRow.RequestsHole (ADR 0028 §10's reading table). A request,
    prompt or tools record whose body does not parse reads
    obsdb.HoleDerived, its hashes from the record's attributes.

  • The request record (ADR 0028). Three OTel log record kinds beside
    event, delta and messages: request (one per model-call attempt:
    step, attempt, system and catalog hashes, messages reference, tool
    names, tool choice, thinking, params, model), prompt (the composed
    system text, once per distinct hash per run) and tools (the offered
    catalog with its policy chips, once per distinct hash per run).

  • RunStart.InstructionsHash (instructions_hash on the wire, an
    additive field): sha256 of the run's raw configured instructions,
    always set by the loop; mirrored as weft.instructions.hash on the
    run_start record and the invoke_agent span.

  • ContentPrompt and ContentStop, the ContentKinds otel's
    Redact receives for a prompt's text and a request's stop sequences.

  • weft.Origin(name), a tool option naming the tools record's source
    (local by default; Subagent sets subagent, mcp.Tools sets
    mcp).

  • Model-call timing and the answering model (plan A4, ADR 0016's A4
    note).
    StepFinish.LatencyMS and StepFinish.TTFTMS
    (latency_ms, ttft_ms on the wire, additive, omitted when 0): the
    step's model call timed by the loop as it consumes the stream, whole
    milliseconds rounded up; ttft_ms is the first TextDelta or
    ToolArgsDelta and absent when neither arrived. The chat span gains
    weft.stream (true), weft.ttft_ms (when a delta arrived) and
    gen_ai.response.model; the step_finish record gains
    weft.latency_ms, weft.ttft_ms (when measured) and
    gen_ai.response.model; a successful attempt span gains
    gen_ai.response.model. The answering model is the last attempt the
    chain reported as a success (mw.Retry, mw.Fallback, a reporting
    adapter), else the model the call asked for.
    Studio's step card and the panel's step line show it (A4.2): "attempt
    4 of 4 · fallback to glm-b" and "1.2 s · first token 180 ms" from the
    folded step_finish and the request rows (no fetch while collapsed),
    the attempt list (model, outcome, retry_after, times) from the step
    route on expand under the steps capability, the same on the trace
    view's chat span, and not_recorded on a run from before A4.

  • The reporting hook (plan A8, ADR 0016). weft.ReportFromContext(ctx)
    returns the model call's Reporter: .Attempt(weft.AttemptInfo{…})
    records one provider request as an attempt span under chat
    (provider, model, weft.attempt.index, weft.attempt.retry_after_ms,
    error.type or Ok) and, from the second attempt on, its own
    request record; .Raw(weft.RawPair{…}) is accepted and dropped
    (sizes on a Debug line). The reporter numbers attempts itself; a
    layer that reports declares ReportsAttempts() bool and an outer
    reporting layer stays silent. mw.Retry and mw.Fallback report
    every attempt. Outside a run's model call the reporter is a no-op.

  • obsdb names the phase-1 readers added above: CompactionRun,
    CompactionSession, RecordCompaction, ReasonCompacted;
    HoleCause, HoleCauses, CauseDefault; LogSkew, ParseSeverity,
    SeverityText; RunDetail.{InstructionsHash,CatalogHash,RequestCount}
    beside RunRow's; the RequestBody parts RequestMessagesRef,
    RequestModel, RequestParams, RequestThinking, RequestToolChoice,
    RequestTools.

Fixed

Found by the phase-1 review of the request record (2026-10-08); none
changes what the model sees.

  • core: a reported attempt that raced the model call's end could
    still add a request record; the record is now emitted under the
    lock end() takes, so a late report adds none (ADR 0028 §7). A
    cancelled resume whose transcript ended at the assistant message
    (every call parked) records its rebuilt tool message like a resume
    with a held tail does, so the records still concatenate to
    RunError.Result.Messages. A further attempt that reports a model
    is recorded under the provider it reported, even an empty one — a
    fallback to another vendor no longer carries the primary's provider.
    The agent's instructions hash is computed once at New (two
    allocations fewer per run). The Debug line for a contained recorder
    panic names the panic's type, never its value.
  • otel: capTools encodes the catalog once instead of once per
    dropped entry (a 400-tool catalog took 86 ms on the run goroutine).
    A Redact panic on params.stop drops the stop sequences only; the
    request keeps messages_ref.index on a destination that received
    the messages records.
  • obsdb: a run row with request records but no instructions hash
    (the run_start batch lost or not yet landed) reads recorded, and a
    prompt or catalog its requests name but the store lacks is a gap —
    not not_recorded with "upgrade weft" (ADR 0028 §10's table gains
    the row). weft.attempt.retry_after_ms and the ten
    weft.override.* fingerprint fields join the contract keys on both
    backends (and 0004's tuples), so a non-string value no longer lands
    in ClickHouse's run meta and not SQLite's. A messages record
    without its index — growth or view — and an index attribute present
    but empty read position -1 on both backends and stay out of the
    transcript and the messages count (before, SQLite stored a growth
    record at 0 where the input record dropped it and ClickHouse read it
    first). A non-string weft.messages.reason reads as "not growth" on
    SQLite as it does on ClickHouse. Two session markers with distinct
    non-hex hashes keep distinct positions on SQLite (a 60-bit FNV
    fallback). App-log lines equal in time, span, severity and body sort
    by event name and attributes on both backends, so paging by
    next_from neither skips nor repeats a line.
  • thread: a compaction in a forked session no longer files its
    marker under the origin session's run — it is held for the fork's
    first run. A mid-run overflow's compaction keeps the earlier turn's
    span context and metadata on its marker (the Session remembers the
    last four runs it saw). After a reopen the marker carries
    weft.turn, weft.public_id and the lineage pairs, not the session
    id alone. A held marker that loses a race with Close is dropped
    with the "compaction markers dropped at close" Debug line.
  • version: version.Runtime() maps a VCS-stamped pseudo-version
    and a +dirty suffix to Version, as it does (devel), so a local
    go build reports the same version its runs stamp.
  • studio (server): a read-scoped token no longer sees tool names
    through the invoke_agent span's weft.override.tools,
    weft.override.park_on, weft.override.park_all_except (and a named
    tool_choice) on runs/{id}/spans, traces/{id} or the json/jsonl
    export, nor a compaction view's messages in the export (null with
    the hidden badge); /api/manifest's 403 carries the hidden badge
    like every other prompt-bearing refusal; the fixtures route checks
    scope before it decodes the body. The step route joins a child to
    the step its id names (a call id repeated across steps no longer
    borrows another step's child); a running step is no longer badged
    not_recorded/gap for a chat span that has not ended; a running
    run's step without a stored step_start reads running, not ok with
    a gap; weft.model.tool_calls is clamped before it sizes a response;
    a lone unnumbered request record merges into attempt 1 instead of
    counting the call twice; a messages_ref naming a dropped view reads
    gap on messages_in. The run document and the export share one
    rule for lost events (gap when requests or steps exist with no
    spans, derived when spans exist); one unreadable child no longer
    fails the parent's document; export children rows carry holes;
    the export's top-level holes also lists truncated, non-final
    max_tokens and per-event stripped. Every hole's fix comes from
    obsdb.HoleNote/HoleNoteFor (two new causes: result_cap,
    log_cap). The auth matrix gains runs?all=1, a child's otlp/jsonl
    export, steps/{bad} (403 wins), HEAD on every export format and
    the span-attribute check.
  • studio (web, panel): linkView is idempotent (a re-render no
    longer links one child to a second call with the same id); call
    rows and trace span keys are step-qualified (c:<step>:<call>,
    c:resume:<call>), so a repeated call id selects the right call and
    React sees no duplicate keys in a resumed step 0 — old ?sel=c:<id>
    links no longer resolve; the step document is re-read while a step
    runs and once the run ends, and its running-time holes are never
    shown as final; the requests query re-reads from the first missing
    index on the terminal refetch (an out-of-order row no longer leaves a
    false gap); applyTranscript clones the steps it overlays (a late
    delta no longer appends to transcript text); the derived attempt
    badge says what the server means and lists an unnumbered record by
    its request index; the header and the panel share one status-hole
    rule (statusHoles); "show original" reports a transcript read
    error instead of loading forever; findCall closes the most recent
    open call; no bare "1 attempt". logs.test.ts reads the Go goldens
    and a drift guard checks every step and logs golden against the TS
    types.