Commit Graph

3904 Commits

Author SHA1 Message Date
Pranav Raja
71d51cadac test(shell): pin each instruction, and name the test's real scope
Both from review:

- the description test checked two keywords, so a description that kept
  the word `workdir` while dropping "instead of a leading `cd`" would
  have passed with the guidance gone. Now asserts each clause, and
  mutation-verified against exactly that partial regression.
- renamed the second test to say validation + parsing, which is what it
  covers. Execution is genuinely not assertable here: this handler's
  scoped-path branch fails closed pending a process backend that can
  take a virtual cwd, so there is no executor to drive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:48:43 -07:00
Pranav Raja
e44b8b8868 test(shell): pin the calling guidance and the forms it recommends
Two assertions for the description change:

- the manifest description names the command-line and `workdir` facts.
  Mutation-verified: reverting to the old description fails this test,
  which is the whole point of a regression gate.
- the forms the description tells the model to use (`a && b`, a `for`
  loop, a heredoc, a pipe) all pass `validate_command`, and `workdir`
  parses as a per-call parameter. If validation is ever tightened to
  reject composition, the advice becomes a lie the model can only
  discover by burning a rejected call — this catches that instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:34:49 -07:00
Pranav Raja
0da56aac47 fix(shell): tell the model the tool takes a command line, not one primitive per call
`builtin.shell`'s description says what the tool does and nothing about how
to call it. Benchmarked side by side against two container harnesses whose
shell descriptions do carry calling guidance, reborn issues ~2.2x the tool
calls for the same tasks on the same model (957 vs 426/639 over a 45-task
corpus) — on the worst tasks 102 calls against 21 — because it treats the
tool as one-primitive-per-call and pays a round trip per `ls`/`cat`.

Two facts the description was leaving out, both already true of the handler:

- `command` is a whole shell command line, so `a && b`, a `for` loop, or a
  heredoc script all work in one call. None of those forms trip
  `validate_command`/`detect_command_injection` — only specific exfil and
  escalation patterns are blocked.
- `workdir` is a per-call parameter, so a leading `cd` buys nothing and the
  working directory does not carry over. (Deliberately stated this way rather
  than claiming state persists between calls, which it does not.)

Description-only change: no handler, schema, or validation behaviour is
touched, and no test pinned the old string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:30:36 -07:00
Illia Polosukhin
7d77a752f2 feat(documents): edit docx/xlsx/pptx structurally, render PDF from HTML, and fix the #7109 text-log regression (#7163)
* fix(coding): stop the binary-write backstop rejecting ordinary text logs

Follow-up to #7109, which shipped with the binary backstop in
`verify_read_before_edit` using the STRICT probe (any NUL in the first
8KiB) while `read_file` admits text carrying a few stray NULs via
`reject_binary_probe_lenient`.

The two disagree about what "binary" means, so a syslog the model had
just read successfully became unwritable — and was reported as a "binary
document", which it is not. `read_file_tolerates_stray_nul_and_invalid_utf8_in_text_logs`
pins the lenient read; this makes the write path agree with it.
`apply_patch` keeps applying the strict probe itself, where byte-fidelity
for write-back demands it.

Also adds the coverage #7109 landed without:

- `builtin_write_file_still_overwrites_text_log_with_stray_nul` — fails
  before this fix, and is the regression above.
- `builtin_write_file_rejects_extracted_read_representation_at_unlisted_extension`
  — the recorded-read-representation guard had no test at all. The
  existing docx test returns at the extension guard before
  `verify_read_before_edit` is ever reached, so `ReadState`, the
  `read_file` plumbing and the representation check were untested. `.rtf`
  is the one format `read_file` extracts that the extension lists omit,
  making it that guard's only live surface.
- `builtin_apply_patch_rejects_a_binary_document_with_an_actionable_reason`
  — #6898 named apply_patch's opaque failure as part of the bug, and the
  fix addresses it, but nothing pinned it.
- `reborn_integration_document_edit` — the whole journey at the
  integration tier: upload a .docx, ask for an edit, and the bytes served
  by the production `InboundAttachmentReader` (the WebUI download path)
  are byte-identical to the upload.

Refs #6898, #7109

* feat(documents): structure-preserving docx/xlsx/pptx editing and HTML-to-PDF

Issue #6898 item 3: the deferred "real document round-trip capability".
This is the library layer; capability wiring follows.

The governing rule is copy-through: rewrite only the parts an edit
targets, copy every other zip entry byte-for-byte. A generator that
rebuilds a document from the text a model saw drops everything the model
never saw — styles, numbering, headers, images, embedded objects — and
the file still opens, so the loss is invisible. Copy-through makes that
impossible by construction rather than by diligence, which is why this
is not "add docx-rs and write a docx".

- `ooxml`: package read/write plus an event-level XML transform. An
  event the handler does not claim is re-emitted exactly as read, so
  attribute order, namespace prefixes and self-closing forms survive; a
  DOM round trip normalizes all three and churns untouched parts.
- `docx`: paragraphs with `w:ins`/`w:del` surfaced as typed revisions
  (flat extraction shows deleted text as if it were still in the
  contract), and accept/reject. Rejecting a deletion converts `w:delText`
  back to `w:t` — without that Word renders the restored run empty.
  Revisions split across runs coalesce into one span.
- `xlsx`: shared strings resolved so headers read as text, formula edits
  that drop the stale cached `<v>` and set `fullCalcOnLoad`, and new
  cells inserted in ascending column order (Excel repairs, and silently
  drops content from, an out-of-order row).
- `pptx`: slide clone that duplicates the slide's rels part, so the copy
  inherits its layout — style lives in the layout chain, not the slide,
  which is why constructing a slide cannot preserve it. Registers the new
  part in content-types, presentation rels and slide order.
- `html_pdf`: PDF is deliberately not edited. A documented HTML subset
  renders via printpdf's standard-14 fonts. printpdf's `html` feature is
  left off on purpose: it resolves fonts through rust-fontconfig, which
  scans system fonts and would make pagination depend on the host.

51 tests, including the copy-through invariants (unrelated parts stay
bit-identical, a no-op edit is byte-stable) and every trap named above.

Refs #6898

* feat(coding): document_edit and html_to_pdf, and read_file reads OOXML structurally

Wires issue #6898 item 3's library layer to the model. Reads unify into
read_file; writes stay typed. That asymmetry is the design, not a
compromise:

- read_file on a .docx/.xlsx/.pptx now returns the ADDRESSABLE STRUCTURE
  (paragraphs with tracked-change spans, cells with resolved headers and
  formulas, slides) and records `ReadRepresentation::Structured`. Folded
  in rather than offered as a `document_read` tool because a model
  reaches for read_file on whatever path it is handed — a tool it had to
  know to prefer would go unused while read_file kept returning
  tag-stripped text that shows a redline's DELETED words as if they were
  still in the contract.
- write_file cannot be folded: its contract is (path, content: &str), so
  it cannot express "accept the revision in p3". Overloading it means
  regenerating the document from text — the corruption #6898 banned — or
  a sometimes-JSON `content`. apply_patch fails for the same reason plus
  ambiguity: its anchors match extracted text, and mapping a match back
  to runs is undefined when a string spans a revision boundary. The
  binary-write ban stays permanent.
- document_edit takes typed ops and always writes to a NEW path, so a
  bad edit can never cost the user the original. It requires a prior
  Structured read of the source, keeping write_file's mid-air-collision
  guarantee on a fingerprint over the same raw bytes.
- html_to_pdf renders; it refuses to overwrite an existing file, because
  silently replacing a PDF the user uploaded is the same class of loss
  the binary-write guard prevents.

Two findings from writing the tests, both fixed here:

1. The surface test caught that a builtin capability is invisible to the
   model without a published input schema — registration alone is not
   enough. Both new tools now publish one.
2. The xlsx journey caught that set_cell_formula could not create a row.
   A totals row sits just below the data, so "the row is not there yet"
   is the ordinary case, not an edge case; the crate fixture happened to
   have the row already and masked it. Rows are now created in ascending
   order, with regression tests.

Tests: 6 capability tests through real dispatch (including that a
Structured read still does NOT authorize a raw write_file overwrite —
the two guards must not cancel each other), and four integration
journeys on real OOXML fixtures: docx redlines resolved into a clean
copy, an xlsx total under a named column, a pptx slide cloned with the
source's layout, and a PDF produced by authoring HTML and rendering it.

Refs #6898

* test(reborn): register e2e coverage for document_edit and html_to_pdf

`reborn_builtin_first_party_capability_e2e_coverage_is_complete` requires
every always-on first-party capability to name where its Reborn e2e
coverage lives. The two new document capabilities had that coverage —
`reborn_integration_document_edit` drives all four journeys — but were
not registered, so the guardrail failed.

Caught by the pre-push hook, which runs the full workspace suite; the
per-crate runs used while developing never touch this test.

* ci(planner): classify tests/fixtures document fixtures

`Detect Reborn test scope` failed with "unmapped test or CI path:
tests/fixtures/contract.docx". The planner deliberately hard-errors on
any unclassified path under tests/ to force a per-file decision, and
binary document fixtures had no arm — only recorded LLM traces under
tests/fixtures/llm_traces/ were mapped.

These fixtures are consumed by integration tests through `include_bytes!`,
so a changed fixture changes what those tests assert; it schedules a
representative integration lane, matching how shared integration support
is treated.

Adds the matching planner test.

* ci: satisfy the panic check in the test-only fixture builder

`Fast deterministic checks` flagged four unwraps in
`ironclaw_documents::test_fixtures`. The module is `#[cfg(test)]`-gated
(`has_cfg_test_module_declaration` agrees), but the checker's main scan
path does not consult that for crate-root modules, so it reads them as
production code.

Uses the inline `// safety:` suppression the checker documents rather
than relaxing the checker, which guards a real invariant for everything
else. The comments must be on the same line as the call to take effect.

* fix(documents): nested paragraphs and leaked revision flags corrupted docx edits

Two critical defects from CodeRabbit's review of #7163, both confirmed by
tests that fail before the fix.

1. Nested `w:p` lost the outer paragraph and desynchronised ids.
   Word nests a paragraph inside a paragraph when a run holds a text box
   (`w:txbxContent`). The reader kept one `current` slot, so the inner
   Start overwrote the outer and the outer was never emitted; the writer
   meanwhile counted EVERY `w:p` Start. Read ids and write ids therefore
   addressed different paragraphs, so an edit landed on the wrong one.
   The reader now keeps a stack and assigns ids in Start order, matching
   how the writer counts, and both write paths keep a target stack so a
   nested paragraph's End restores the enclosing paragraph's state
   instead of clearing it.

   Note the mechanism: table cells do NOT reproduce this — `w:tbl`/`w:tc`
   paragraphs are siblings in document order. Only a text box nests.

2. Revision flags leaked past a dropped subtree and unbalanced the XML.
   Reject-insert and accept-delete set `dropping = 1` AND the
   `in_insert`/`in_delete` flag. The dropping branch then consumed the
   matching End, so the flag was never cleared and stayed set for the rest
   of the document — deleting a LATER paragraph's `</w:ins>`. Resolving
   revisions in one paragraph emitted `word/document.xml` with an
   unclosed element, which Word rejects.

   The flag is now set only on the unwrap paths, where the End genuinely
   must be dropped by the handler. On the drop-subtree paths the End is
   consumed by the dropping branch and no flag is needed.

Both defects are invisible to single-paragraph, flat fixtures — which is
what the crate's own fixtures were.

* fix(documents): preserve cell styles, resolve sheets by relationship, keep text around comments

Four more defects from the #7163 review, each with a test that fails
before its fix.

xlsx:
- Replacing a cell dropped its `s` style index, silently reverting a
  currency or date column to General. That is the precise "preserve what
  you did not touch" promise the crate exists for, so the style now rides
  across the replacement.
- Sheet names were paired to worksheet parts POSITIONALLY. I shortcut
  this deliberately and said so in a comment; the reviewer was right that
  it is wrong. Sheet declaration order does not have to match worksheet
  file numbering, so an edit could land in the wrong worksheet. Names now
  resolve through `r:id` in `xl/_rels/workbook.xml.rels`, falling back to
  positional pairing only when the rels part is absent.

html_pdf:
- A comment or declaration cleared the buffered text before it, so
  `<p>hello <!-- note --> world</p>` rendered as `world`. This directly
  contradicted the module's claim that wrapping markup never swallows
  content. Whitespace now also collapses across the resulting span join,
  so the repaired text reads `hello world` rather than `hello  world`.
- A stray `&` consumed up to ten following characters looking for `;`,
  swallowing a real entity behind it. The scan now stops at any character
  that cannot appear in an entity name.

* fix(documents): reject duplicate zip entries, stop self-closing tags corrupting reads and rows

Three more from the #7163 review.

- A duplicate zip entry name kept both names but only the last bytes, so
  `write()` emitted the same content under both and silently rewrote a
  package we were asked to preserve. Now rejected at read.
- `Event::Empty` latched `in_value`/`in_formula`. A self-closing `<v/>`
  has no matching `End`, so the flag stayed set and the NEXT cell's text
  was attributed to the empty one, corrupting every later value in the
  row.
- A self-closing `<row r="N"/>` target produced a SECOND row with the
  same `r`, which Excel repairs by dropping content. The existing empty
  row is now replaced in place.

* chore: retrigger Railway preview

* fix(documents): address review findings

* test(reborn): refresh read-file golden payloads

---------

Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-13 22:05:44 +00:00
Benjamin Kurrek
14f5790a4f fix(live-canary): align the bundled-skill marker owner with the runtime mint (#7590)
The scrub-verdict narration from #7579's first run (dispatched
31734252239) named the real cause of the weeks-long strict-scrub reds:
"kept skill snapshot for scanning (marker failed verification)" on every
skill, in every shard. The runtime mints markers with
BUNDLED_MARKER_OWNER = "ironclaw_composition_bundled_skill"
(crates/extensions/ironclaw_extension_host/src/bundled_skills.rs), while
the scrubber still expected the retired
"ironclaw_reborn_composition_bundled_skill" spelling — the WS6/WS7 crate
renames changed the Rust side and this shell copy silently drifted, so
the owner check failed for every marker and the bundled-skill pruning
never engaged. (The markers looked absent in downloaded artifacts only
because actions/upload-artifact drops hidden files by default; they were
present at scrub time all along.)

The owner string now matches the mint, the fixtures mint with the real
owner, and a new lockstep test extracts BUNDLED_MARKER_OWNER and
BUNDLED_MARKER_FILE from bundled_skills.rs and asserts the script's
constants equal them — the next rename fails this self-test instead of
silently disarming the scrubber. Sabotage-verified: the lockstep test
fails against the pre-fix script; the full suite (22 tests) passes with
it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:48:37 +00:00
Josh Ford
d82c9584e5 ci(check-guidance): extend the reference gate to the docs/ surface (doc-truth PR 2/5) (#7376)
* docs: fix live drift in extension, responses API, and channel docs

The public tutorial taught the retired manifest v2 authoring format
([[host_api]] / [capability_provider.tools] / runtime_credentials), which
the v3 parser hard-rejects, and never mentioned origin_gate_matrix; the
Responses API page claimed temperature is rejected (accepted 0.0-2.0 and
forwarded), claimed model must be "default" (any well-formed name <= 256
bytes), claimed max_output_tokens is rejected (accepted and ignored by DTO
policy), and omitted the required model field from every request example;
the channel tutorial pointed at two files that no longer exist.

- docs/extensions/building-a-tool.md: rewrite manifest sections to the v3
  [[tools]] / [[tools.credentials]] / [auth.<vendor>] shape, document
  origin_gate_matrix (origins, policies, ratchet), correct the hosted-MCP
  [mcp] section, packaging via ironclaw_extension_support package modules,
  and v3 test references; drop the nonexistent script runtime kind.
- docs/api/responses.mdx: correct model/temperature/tools/tool_choice
  rejection rules, document unknown-field tolerance, add the required
  model field to all 15 request examples.
- docs/channels/building-a-channel.mdx: replace dead
  crates/ironclaw_first_party_extensions + available_extensions.rs
  registration instructions with the current package-directory mechanism.
- docs/reborn/contracts/extensions.md: state that production manifests
  author v3 (lowering into the v2 resolved model described there); label
  the v2 examples as legacy.
- docs/reborn/how-to-port-tool-to-reborn.md: superseded banner pointing at
  the v3 guides.

Part of #7317 (doc-truth pipeline, PR 1 of 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): extend the reference gate to the docs/ surface

The public Mintlify tree had no path-reference validation — a published
tutorial told contributors to edit files that no longer exist and nothing
caught it. check-guidance.py already owned the machinery (tracked-tree
resolution, fence exclusion, suppress markers, shrink-only debt, fail-closed
floors), so the docs surface joins the same gate rather than a fork.

- discover_guidance() now collects every tracked docs/**.md|.mdx: published
  pages, the zh/ locale mirror, and the living contract corpus
  docs/reborn/contracts/. Dated archives (docs/internal/, the non-contract
  parts of docs/reborn/) are excluded as classes — measured 2026-08-07,
  705 of 709 dangling docs references sat in those historical corpora, and
  forcing dated plans/ADRs to track today's tree would either rewrite
  history or drown KNOWN_MISSING.
- docs/ files extract backticked inline paths only; Mintlify markdown link
  targets are site routes (extensionless pages, site-absolute /using/cli),
  a different namespace than the tracked tree, so the link extractor is off
  there by design.
- _reference_lines learns MDX comments ({/* ... */}), including
  {/* check-guidance: path-ok */} as the .mdx suppress-marker form, with the
  same one-reference-per-marker and multi-line semantics as HTML comments.
- Floors re-measured and re-dated (364 files / 2276 references; floors
  180/1100), plus a dedicated MIN_DOCS_FILES=60 floor: the aggregate floors
  sit below the guidance-only remainder, so the docs branch of discovery
  silently breaking needs its own refusal. --json now reports docs_files.
- Fixes the four real dangles the new scan found in docs/reborn/contracts/
  (moved nested_dispatch_stream.rs test home, retired event-store migrations
  directory, loop_driver_host tests->src move). KNOWN_MISSING stays empty.
- Self-tests: 8 new cases (dangling docs path fails; Mintlify links are not
  references; MDX marker suppresses exactly one reference; multi-line MDX
  comment hides content; zh discovered; archives excluded but contracts
  scanned; docs fence fails closed; docs floor refuses).
- ws12_workflow_contracts.py: docs/api/responses.mdx and docs/zh/index.mdx
  join the has_guidance in-scope probes so a narrowed trigger regex cannot
  silently skip the gate for public docs.

Part of #7317 (doc-truth pipeline, PR 2 of 5); stacked on #7375.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: address Copilot and CodeRabbit review on doc-drift PR

- responses.mdx: tool_choice is rejected only without external-tools wiring;
  with external tools enabled it passes validation and is currently ignored
  (validate_responses_supported_fields_with_external_tools never checks it).
- building-a-tool.md: clarify that effect-derived host ports are validation
  vocabulary against the HostPortCatalog allowlist; adapters are built by
  host-runtime services after authorization/obligations, never from manifests.
- how-to-port-tool-to-reborn.md: mark the decision tree's RuntimeKind targets
  historical (v3 accepts only wasm|first_party; MCP is top-level [mcp];
  process/CLI work is the sandbox lane).
- building-a-channel.mdx: document the user install flow — virtual package
  root /system/extensions/<id>/manifest.toml, ironclaw extension search /
  install <extension-id> (ID, not path), WebUI Extensions lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(responses): align the limits bullet with the corrected tool_choice claim

The rejection list was corrected in the previous commit (tool_choice is
rejected only without external-tools wiring); the "Limits and quirks"
bullet still said "not supported ... rejected with 400". Same claim, one
wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: apply verified code-review findings on the drift PR

A full code review of this PR against live code surfaced claims the
original drift pass got wrong or missed; every fix below was re-verified
against the cited source before editing:

- responses.mdx: standard `ironclaw serve` deployments always wire
  external tools (OpenAiCompatRouteMountPorts requires the store/resume
  pair; mount.rs wires them unconditionally), so `tools` is accepted and
  `tool_choice` is accepted-and-ignored on shipped binaries — the
  conditional 400s apply only to custom compositions without the wiring
  (now a Note). temperature is validated and carried in the submitted turn
  payload but not applied as a provider sampling parameter. Non-streaming
  wait timeout is 30 s (DEFAULT_RESPONSES_WAIT_TIMEOUT), not 120. usage on
  retrieval is read best-effort from persisted run state incl. USD cost
  (read_run_usage), not always zero.
- building-a-tool.md: the [auth.example] oauth2_code recipe gains the
  required token_response map (deny_unknown_fields rejects the example as
  previously written); Gmail/Google Calendar corrected to first_party
  runtimes (their manifests declare kind = "first_party"); the worked
  api_key recipe is github's, not slack's; the tail "Quick implementation
  checklist" and reference list were still v2-era (script lane,
  assets/<extension>/ path, "manifest v2", v2.rs pointer) and now teach
  the v3 shape; composition/CLI package-naming claim narrowed (the binary
  does link slack/telegram adapter crates).
- contracts/extensions.md: legacy-format paragraph no longer claims
  host-bundled packages ship v2 (none do), and origin_gate_matrix is
  attributed to capability.rs + building-a-tool.md instead of
  extension-runtime/overview.md §3, which does not mention it.
- how-to-port banner: `script` manifest authoring is retired; the
  RuntimeKind::Script symbol survives as the process-sandbox lane's kind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): repoint delivery_resolution.rs to its family directory

PR #7157 (merged to main 2026-08-07) cited
crates/ironclaw_outbound/src/delivery_resolution.rs in the
communication-delivery-resolution contract; the crate lives at
crates/domains/ironclaw_outbound/. Caught by this branch's docs surface of
check-guidance.py on the first merge of main after the gate landed —
exactly the drift class it exists for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): harden the docs gate and fix review-surfaced doc drift

Applies the verified findings from the PR #7376 code review:

- The loop-exit and turn-runner contract docs claimed the deleted
  loop_driver_host checkpoint-rejection test had 'moved into the
  module'; it was deleted in #6696 and the fenced verification command
  could not run. Both now cite the real surviving pins
  (planned_driver.rs executor test + the ironclaw_turns projection
  test mapped in scripts/reborn-e2e-rust.sh), with runnable commands.
- An unterminated comment now refuses at EOF like an unterminated
  fence; before, one typo'd closer silently un-scanned the rest of the
  file.
- Markdown links in the re-included corpora are now checked as repo
  paths (they are never published, so the Mintlify-route rationale did
  not apply); this alone added ~165 verified references.
- Each DOCS_REINCLUDED_PREFIXES entry must match at least one tracked
  page or discovery refuses, so the planned docs/reborn consolidation
  cannot silently drop the corpus from the scan.
- The living extension-runtime spec pages (overview.md,
  standard-operations.md) and guidance-conventions.md join the scan;
  guidance-conventions.md now describes the docs surface and the MDX
  marker form, and its one dangling test path is repointed.
- Floors comment corrected (57 rule globs, not 38).

Also fixes four drifted claims from #7375's pages, verified against
live code: the interleaved function_call_output example was rejected
with 400 (resume input must be exclusively function_call_output items
with previous_response_id); model is echoed only on create (GET/cancel
report the 'reborn' placeholder); output_schema_ref is optional; and
the unknown-fields claim now names the two deliberate exemptions.

Self-tests: 43 pass (three new arms — unterminated comment refusal in
both syntaxes, re-included links as repo claims, stale re-included
prefix refusal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): sync module docstring with re-included link checking

CodeRabbit caught the docstring still claiming the link extractor is
off for all of docs/** — stale since b172f69c7 enabled it for the
re-included corpora. The docstring now states the exception and the
current re-include set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): drop the docs/reborn re-include machinery after the docs/internal migration

The docs-surface scan carried a double negative — exclude docs/reborn/ as
an archive class, then re-include its living pages via
DOCS_REINCLUDED_PREFIXES — because the old tree mixed dead archives with
living specs. #7559 moved everything under docs/internal/, so the structure
is now: one excluded archive class (docs/internal/), and the living spec
pages (the contract corpus, the two extension-runtime spec pages,
guidance-conventions.md) named in INTERNAL_GUIDANCE_PREFIXES and scanned as
first-class guidance files — full link checking, guarded by the same
per-prefix zero-match refusal. The published-docs floor now counts only the
Mintlify surface (measured 2026-08-13: 82 pages; floor re-halved to 40).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): validate docs discovery against docs.json navigation instead of a count floor

MIN_DOCS_FILES was an arbitrary magnitude tripwire (half of last measured,
hand-re-dated) that only caught the docs branch of discovery losing ~half
its pages. The published surface already has an independent definition —
docs.json navigation, owned by docs_publication_boundary.py — so the gate
now asserts every navigation page's source file is in the reference scan
(reusing the boundary script's nav walker and OpenAPI pseudo-page filter).
Discovery breaking refuses on the first missing published page, unreadable
or page-less navigation refuses rather than passing vacuously, and there
is no docs count floor left to tune. --json reports nav_pages_covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): count the living internal spec pages in the docs_files metric

CodeRabbit: docs_files under-reported the scan — the living internal spec
pages are scanned docs files but were excluded from the count, a leftover
of the deleted MIN_DOCS_FILES floor's published-only semantics. The metric
now reports every scanned file under docs/ (131 at measurement); published
surface health has its own signal in nav_pages_covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(check-guidance): tighten comments and docstrings

Same behavior; the docs-surface comments and test docstrings were carrying
paragraph-length rationale better kept in the PR description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:27:31 +00:00
firat.sertgoz
bd243b35f2 fix(loop): make repeated-call detection advisory-only (#7531)
* fix(loop): make repeated-call detection advisory-only

* test(loop): align terminal warning integration coverage
2026-08-13 19:29:52 +00:00
Henry Park
d8653a6331 fix(extensions): refresh bundled MCP state after auth (#7581)
* fix(extensions): refresh bundled MCP state after auth

* fix(extensions): preserve bundled discovery trust pin

* test(extensions): verify bundled hash migration

* fix(extensions): serialize catalog discovery refresh
2026-08-13 18:32:42 +00:00
Benjamin Kurrek
c063394e2b fix(live-canary): widen the seeded slack grant to the manifest union and narrate scrub verdicts (#7579)
Run 31715187702 (the first scheduled canary after #7515 merged) surfaced
two problems:

1. QA lanes crash at slack connect ("Extension setup completion did not
   produce a fully ready projection (required installation_state=active)"):
   SLACK_PERSONAL_OAUTH_SCOPES seeds the stored grant of the canary's
   personal account, and runtime account selection requires that grant to
   carry every tool's manifest scopes — the eight new standard ops widened
   the union with reactions:read/reactions:write/im:write, so the stale
   eleven-scope seed parks activation on the auth gate. This is the live
   twin of the merge-queue fixture widening in #7515 itself (the sweep
   there covered tests/, not scripts/). The list now carries the full
   union with a lockstep comment; the live Slack app must also grant the
   write additions to its user token for the reaction/DM ops to succeed
   vendor-side.

2. The same run still flagged skills/local-test placeholder text despite
   #7574, and the redacted report cannot distinguish "the pruning did not
   engage" from "the staged snapshot genuinely diverged" (live cases run a
   real agent inside that home). The strict skill pruning now narrates one
   verdict line per snapshot — pruned marker-verified / pruned
   source-identical / kept with the exact mismatch reason (file set or
   first differing file; names only, never content) — so the next
   scheduled run pins the cause from the step log alone.

Scrub self-tests: 21 pass, with the divergent case now asserting both the
kept-verdict line and the named mismatch. Live-QA runner tests: 218 pass.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:14:43 +00:00
jinxin
93522b7144 feat(webui): add non-admin model preference settings (#7440)
* feat(llm): add tenant model selection policy

* fix(composition): move model policy store to operator

* test(llm): specify per-user model preference behavior

* feat(llm): persist per-user model preferences

* feat(llm): add per-user model commands

* feat(webui): add settings model preference selector

* fix(webui): expose inference settings to members

* test(e2e): cover settings model preference

* fix(webui): add admin model policy controls

* test(e2e): configure model policy through admin UI

* fix(webui): contain long model selector labels

* test(e2e): cover long model selector labels

* fix(webui): stack model selector on narrow layouts

* test(e2e): verify responsive model selector layout

* test(webui): lock member inference navigation

* fix(webui): harden model selector settings flow

* fix(webui): clarify model selector failures

* fix(webui): localize model selector settings
2026-08-13 14:14:41 +00:00
Benjamin Kurrek
b215bf0651 feat(slack): bind the remaining eight core standard messaging ops (#7515)
* feat(slack): bind the remaining eight core standard messaging ops

Slack bound 8 of the 16 core standard messaging operations. The other
eight were a named fast-follow the framework design spec deferred
(§13 "Deliberately not built"); this lands them, so Slack now covers
the full core vocabulary.

Manifest: eight new standard_op-bound [[tools]] — edit_message,
delete_message, add_reaction, remove_reaction, open_dm, get_message,
resolve_user, list_members — appended after the v2-era eight so the
projection-parity gate's positional comparison still holds.

Guest: vendor mechanics over chat.update, chat.delete,
reactions.add/remove/get, conversations.open/members/history/replies,
and users.list. Three behaviours worth a reviewer's attention:

- get_message has no Slack endpoint. It reads the conversation at the
  exact ts and falls back to the thread when the message is a threaded
  reply, accepting only an exact match — a near miss is unknown_message,
  never the neighbouring message.
- remove_reaction's optional emoji is implemented, not rejected. Slack's
  endpoint requires a name, so the omit-emoji variant reads the message's
  reactions and removes each one the connected account added. That is why
  reactions:read joins the grant, and why that variant returns no emoji.
- already_reacted / no_reaction are treated as success. Slack returns
  them only for a message it resolved, so the requested end state holds;
  an error would push the model into retrying a no-op. It also makes the
  removal loop converge on retry after a partial failure.

Scopes: the [auth.slack] union gains reactions:read, reactions:write and
im:write.

COMPATIBILITY: there is no scope-upgrade re-consent flow, so an account
connected before this change holds a token without the new scopes, and
Slack answers those three tools with missing_scope -> mapped to
messaging.permission_denied (a model-visible denial, deliberately NOT an
AuthRequired re-auth gate, since re-running OAuth would request the same
manifest scopes). Widening an existing grant means disconnecting and
reconnecting Slack; the other 13 tools are unaffected. ROLLBACK: reverting
this commit restores the 8-tool surface and the narrower grant; already
-widened user tokens keep unused scopes, which is inert.

Gates: two pins were evolved rather than relaxed. The v2->v3 parity suite
gained a PackageAdditions declaration, so the v2-era eight still project
identically and positionally while the additions and the exact scope delta
are declared explicitly; the catalog scope pin moved from
assert!(matches!(..)) to assert_eq! so a future drift prints which scope
moved.

Tests: 24 guest unit tests — canonical input serde, output shapes, the
reaction authorship filter, exact-ts matching, the idempotent-success
arms, directory matching, the error taxonomy, and capability-id dispatch
— plus manifest-projection assertions that all 16 bind exactly the core
set with host-synthesized schema refs and external_write on writes. The
authorship filter and exact-ts matching were sabotage-verified, as were
both evolved gate assertions.

Not covered: the I/O orchestration inside each operation (which calls
run, in what order, and that a failed auth.test aborts the omit-emoji
removal) is unreachable without a host seam in the WASM guest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(slack): correct four guest edge cases and pin all eight new ops at the dispatch seam

Guest fixes (artifact rebuilt, digest re-recorded):
- reactions.get now sends full=true — without it Slack truncates per-reaction
  users arrays on popular messages and the omit-emoji remove_reaction could
  skip the connected account's own reaction while reporting success.
- open_dm validates user_ref with is_slack_user_id before calling Slack:
  conversations.open takes a comma-separated list, so an unvalidated
  "U1,U2" silently opened a group DM against the 1:1 contract.
- get_message's thread fallback pinches the range to the exact target ts
  (oldest=latest=ts&inclusive=true&limit=5) instead of scanning a fixed
  999-message page — finds a reply at any depth and stops shipping ~1MB
  pages to keep one message.
- resolve_user's users.list page size now equals the match limit (default
  raised to the full 200): Slack cursors are page-granular, so the old
  mid-page break dropped matches between the cap and next_cursor that no
  amount of paging could recover.
- next_cursor extraction deduplicated into one helper across all four
  paging ops; add_reaction's per-tool scopes drop reactions:read (it only
  calls reactions.add — the read scope belongs to remove_reaction alone,
  exactly as the scope table documents).

The manifest's scope-widening compatibility comment now describes the real
mechanism: a pre-widening account is refused by the HOST's provider-scope
gate at credential staging and lands on the AuthRequired re-auth gate, which
reconnects the same account with the widened union (binding skips the scope
gate). Slack's own missing_scope only fires for server-side drift after
staging passed, and that maps to messaging.permission_denied.

Coverage (contradicting the "no host seam exists" claim this PR shipped
with): the existing host-runtime WASM harness drives the committed
slack_user_tool.wasm through invoke_capability with scripted egress, so the
conformance sweep now covers all sixteen ops, plus ten behavioral pins —
missing_scope-vs-AuthRequired layering, both reaction end-state codes, the
omit-emoji orchestration (ordering, full=true, ownership filter, fail-closed
identity), near-miss/threaded get_message flows, zero-egress input
rejections (blank query, empty emoji, malformed open_dm user_ref), missing
provider evidence, the list_members clamp, and loss-free resolve_user
paging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): classify the eight slack ops in the product-surface coverage map

The eight new slack.* capability ids were shipped unclassified, which is
what turned the "Validate product-surface evidence contracts" step red
(summary.missing == 8) and cascaded into the Reborn E2E roll-up.

- classifications.tested gains the eight ids.
- Eleven typed ProviderOperationCases: five writes with provider readback
  and cleanup against the never-reset Slack world (edit and delete seed
  their own subject message; remove_reaction seeds its own reaction as the
  connected account; open_dm proves idempotence by re-opening directly),
  get_message driving the history-miss -> thread-fallback path at the
  seeded reply, resolve_user with a natural empty, and proxy-served
  list_members pages (Emulate answers conversations.members POST-only at
  the pinned ref while the guest reads via GET, as real Slack allows).
- get_message's canonical output requires the message, so its empty class
  is the typed model-visible miss: ProviderOperationCase gains
  expected_status ("completed" default, "failed" for exactly this shape)
  and the runner asserts the declared status instead of hardcoding
  completed.

All three gate files pass under the hermetic wrapper (98 passed), and the
product-surface generator reports 131 capabilities, 0 missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update guidance for the sixteen-tool slack surface

- reborn-extension-surfaces no longer hardcodes a tool count for the slack
  manifest (the grep recipe is the source of truth) and names github as the
  plain schema-declaring exemplar now that every slack tool is
  standard_op-bound.
- standard-operations.md repoints slack_error_to_standard_code at the live
  crates/extensions path instead of the dead pre-restructure assets/ path.
- tests/e2e/CLAUDE.md stops pinning a literal capability count and defers
  to the generated product-surface report (131 as of this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): declare the expected failed tool result on the get_message empty case

The mock-LLM trace replayer treats any failed capability result as a replay
error unless the step's request_hint names the expected failure — the same
mechanism the provider fault cases already use. slack_get_message_empty
declares expected_failed_tool_result_contains="messaging.unknown_message",
and the operation-case runner forwards it onto the synthesized trace; the
adjacent slack_list_members_empty failure in the provider-2-3 lane was
collateral from the poisoned replay state and needs no change of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): widen the seeded slack accounts to the manifest scope union

The merge queue runs the root integration lanes that skip on PRs, and every
one of them seeded the slack account with the pre-widening eleven-scope
union. The manifest's [auth.slack] ceiling now includes reactions:read,
reactions:write, and im:write, so slack installs parked on the auth gate
(BlockedAuth) — the exact provider-scope-gate mechanism this PR documents,
biting its own lockstep fixtures. All six seeds (extension_delivery,
extension_runtime, delivery_user_journeys, tool_call, the slack lifecycle
group scenario, and the QA harness profile that reborn_qa_smoke_scenarios
composes) now carry the full union, and the group scenario's lockstep
comment names the write additions.

Locally green with Docker-backed postgres legs: extension_delivery 23/23,
extension_runtime 25/25, delivery_user_journeys 25/25, group_extensions
16/16, tool_call 38/38, and the bundled-extension-surface QA smoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:34:09 +00:00
Benjamin Kurrek
1f8eb0937d fix(live-canary): prune marker-less bundled skills and follow the session channel message route (#7574)
Two independent regressions have kept every scheduled Live Canary red:

1. Since #7171 moved skill mounts onto one backend-generic tree, the case
   homes exported into artifacts carry no .ironclaw-reborn-bundled.json
   runtime marker (verified: zero markers across all 31 materialized skills
   in the QA-10 artifact of run 31641918366), so the marker-keyed
   bundled-skill pruning from #6453 never engages and the long-committed
   placeholder text in skills/local-test/SKILL.md (docker examples with
   NEARAI_API_KEY=<your-key>) fails the strict scrub in all 12 shards —
   deterministically since the first scheduled run after #7171 (Aug 9,
   21:11 UTC). The scrubber now also prunes a marker-less skill snapshot
   whose file set and bytes are identical to the source-controlled bundle;
   divergent or operator-authored content stays in scanning scope.

2. Since #7477 the WebChat composer posts messages on the session channel
   ingress route (/api/webchat/v2/channels/<extension>/messages), while the
   live-QA submission-identity capture waited on the retired thread-scoped
   route — so QA 10 (the shard whose cases capture submission identity)
   went 9/10 to 0/10 at the first post-#7477 scheduled run (Aug 12,
   21:18 UTC) with every case timing out at expect_response on a healthy,
   streaming turn (the failure screenshots show the correct answers
   mid-stream). The predicate now accepts both routes — the same migration
   the stress client made in #7568 — so one harness spans binaries on
   either side of the split.

Scrub self-tests: 21 pass including two new cases (marker-less identical
snapshot pruned; marker-less divergent snapshot still fails strict), and
the identical-snapshot test fails against the unfixed script. Live-QA
runner unit tests: 218 pass including the new route-pattern regression
test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 12:34:10 +00:00
firat.sertgoz
5308b83d06 fix(loop-host): repair unavailable capability calls without aborting runs (#7551)
* fix(loop-host): resolve deferred capabilities before guard

* fix(loop-host): retain mixed-request suppression (#7551)

* fix(loop-host): repair unavailable capability calls

* test(loop-host): assert gateway repair feedback
2026-08-13 11:59:36 +00:00
firat.sertgoz
dc2581a4d8 fix(memory): ranked recall retrieval + a visible difference between broken and empty memory (#7185) (#7553)
* fix(memory): rank memory retrieval by relevance instead of requiring every term

Memory recall from a conversation almost never matched. `Filter::Fts` builds
an AND over every non-stopword term of the raw user message, so a question
worded even slightly differently from the saved sentence returned nothing: one
missing word was enough. A fact saved as "Sarah prefers the standup meeting
scheduled early on Thursday mornings" was invisible to "when does Sarah like
her standup scheduled" purely because the stored text has no "like".

Add an explicit ranked retrieval mode rather than flipping AND to OR for
everyone. `Filter::FtsRanked { key, query, limit }` matches a record carrying
ANY content term and orders by backend relevance — `bm25()` on libSQL,
`ts_rank` over an OR `tsquery` on PostgreSQL, and distinct-term coverage in the
in-memory reference so the three stay behaviorally consistent. Like
`Filter::VectorNearest` it is a top-k operation: `limit` truncates after
ranking and nesting it inside And/Or is `Unsupported` on every backend, because
a predicate position would discard the ordering.

`Filter::Fts` keeps its every-term semantics untouched. Its only production
consumer is memory-native's search path, which moves to the ranked variant;
the remaining uses are the filesystem crate's own contract tests.

Tests: a three-backend `ranked_fts_contract` in the filesystem contract suite
(libSQL + in-memory run locally, Postgres leg is docker-gated and unrun here)
that opens by asserting the AND filter finds nothing, so it cannot pass under
the old semantics; and a group_memory integration scenario driving the real
composition, verified to fail on the previous behavior with "no captured
system prompt containing \"Thursday mornings\"". It writes to a non-standing
document so the always-on MEMORY.md lane from #7365 cannot satisfy it.

Part of #7185, #7275

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): make a failed memory retrieval distinguishable from an empty one

A memory backend that was down and a user with nothing relevant stored
produced exactly the same thing: an empty prompt section. Every failure on the
retrieval path degraded silently — the host adapter logged the lane error at
`debug!` and returned an empty list, and the loop host cached that empty list
in its per-run `OnceCell`, so one blip blanked memory for the whole run with no
way to tell afterwards whether memory was empty or broken.

Make the outcome typed instead of inferred. `MemoryPromptContextService` now
returns `MemoryPromptContextLoad { snippets, degradations }`, mirroring
`LoopContextBundle`'s existing shape of "successful payload plus an explicit,
typed description of what was lost" (`recent_window_truncation`). A
degradation names the lane (`short_term` / `long_term`) and a closed-vocabulary
failure kind (`input` / `unavailable`) — never a backend message, a query, or a
path. `degradations` is empty exactly when every queried lane answered, so an
empty result from a healthy backend is no longer confusable with an outage.

Retrieval stays best-effort and never fails a turn, and the per-run cache stays
(it exists to stop a slow backend being re-hit on every model step) — but the
cached value now RECORDS that it failed rather than laundering the failure into
"empty".

Operator visibility rides the milestone sink the context port already holds: a
degraded load emits one `LoopDriverNoteKind::Context` driver note per run,
which reaches the live work summary. This is the same route
`publish_personal_context_admitted` uses and the same rationale as
`EventSubscriptionTerminated` — a subsystem that stopped contributing must not
be silently invisible. Deliberately NOT promoted to `warn!`/`info!`: those
levels render in the REPL and corrupt the terminal UI, and this fires from a
background prompt build.

Tests: crate tier pins both directions in the host adapter (an outage records
both lanes, a partial failure records only the failing one, and a healthy empty
result records nothing); integration tier drives the real composition with two
byte-identical turns differing only in whether the bound provider's lanes
return `Err(unavailable)` or `Ok(vec![])`, verified to fail before the change
with "no driver note reporting degraded memory retrieval; saw []".

Part of #7185, #7275

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): address review — backend parity, note retry, scenario ownership

Review feedback on #7553:

- libSQL answered a content-term-free ranked query with `Unsupported`
  when the prefix had no declared FTS index, while PostgreSQL and the
  in-memory backend answered "empty". The answer does not depend on an
  index, so it must not depend on the bound backend: read the query
  before resolving the FTS table. Pinned in the shared
  `ranked_fts_contract`, which runs on all three backends.

- The degraded-retrieval driver note marked itself emitted before the
  publish succeeded, so one transient milestone-sink error suppressed it
  for the rest of the run — restoring the exact ambiguity between broken
  and empty memory this change exists to remove. Adopt the two-step
  guard `publish_personal_context_admitted` already uses (in-flight flag
  + set-on-success). Regression test verified red against the old guard.

- `Filter::Fts` and `Filter::FtsRanked` carried separate copies of the
  in-memory tokenizer; they must agree on what counts as a term
  occurrence, so share one `fts_tokens`.

- `scenario_paraphrased_prompt_recall_libsql` built its own group; the
  group-scenario contract puts that in the binary. It keeps a dedicated
  libSQL group rather than joining the shared one, because scenarios 6
  and 7 assert which snippets reach the prompt and a ranked top-N lane
  over one store would couple those assertions to sibling seed data.

- Register both new scenarios in `tests/CLAUDE.md` §3.4 and update counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:58:33 +00:00
Benjamin Kurrek
d0ba975c10 fix(channels): sticker/voice attachments no longer brick Telegram — kind validator, truthful sticker MIMEs, ingress ack-discard for deterministic failures (#7563)
* fix(extension-contracts): make semantic attachment kinds constructible

Telegram stickers (image/webp + Sticker) and voice notes (audio/ogg +
Voice) failed ProductAttachmentDescriptor validation because
validate_attachment_kind forced any image/audio/video MIME onto its
mirror kind. The whole update then failed adapter parsing, ingress
answered non-2xx, and Telegram's in-order redelivery wedged the chat
behind the poison update.

Agreement now runs one direction only: media-mirror kinds (Image,
Audio, Video) must match the MIME base; semantic kinds (Sticker,
Voice, Document, Other) accept any MIME.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telegram): truthful sticker MIMEs; degrade permanent attachment failures instead of failing the update

Stickers now carry their real content type (image/webp static,
video/webm video, application/x-tgsticker animated) via the Bot API
format flags.

complete_message keeps retryable transfer failures fatal (ingress 503,
vendor redelivery can succeed later) but degrades deterministic
failures to the message minus that attachment — failing the whole
update made Telegram redeliver a payload that could never improve,
wedging the chat's in-order queue. An update degraded to neither text
nor attachments is acknowledged as Ignore instead of starting an empty
turn; batch fragments still ship so sibling media-group parts settle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(extension-host): acknowledge-and-discard deterministic ingress failures instead of handing vendors a redeliverable status

Vendors with strictly ordered webhook redelivery re-send any non-2xx
update and hold every later update in the conversation behind it — one
deterministically unusable update bricked the whole chat until the
update expired. The retired WASM channel acked malformed payloads for
exactly this reason; the generic ingress regressed it to 400.

Ingress now splits adapter and admission failures by what redelivery
can do: transient faults (configuration, vendor wiring, retryable
transfer, retryable sink/staging, panics, deadlines) keep 503 so
redelivery can succeed later; deterministic faults (parse, render,
permanent transfer, out-of-bounds messages, permanent sink rejections,
batch tombstones and budget rejections) return 200 with an
acknowledged_discarded body and a warn log, admitting nothing. The
batch processor keys its terminal state on the discard marker so a
consciously dropped merged message keeps the truthful rejected
tombstone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telegram): dropped-attachment diagnostics use debug! per REPL logging rule

The host-level acknowledged-discard warns in the ingress router remain
the update-level observability hook; the adapter's per-attachment
degrade details are internal diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:43:16 +00:00
Josh Ford
318a6e6748 docs: consolidate docs/reborn/ into docs/internal/reborn/ (#7559)
* docs: consolidate docs/reborn/ into docs/internal/reborn/

Move-only migration; no content changes beyond path references. Executes
the follow-up that PR #7259 left open: docs/.mintignore's reborn/ entry
was kept only because the path was load-bearing, and its comment
documented that it moves under internal/ once its consumers move with it.

- git mv docs/reborn docs/internal/reborn (115 files, history preserved)
- rewrite docs/reborn -> docs/internal/reborn across every consumer
  (crate AGENTS/READMEs and doc-comments, .claude/ skills and rules,
  AGENTS.md, CI scripts, reborn-e2e.yml path filters, Dockerfile, tests,
  docs/internal plans)
- fix six relative internal/adr/ links inside the moved tree for the
  added directory level
- drop reborn/ from docs/.mintignore and FROZEN_MINTIGNORE_PATTERNS in
  scripts/ci/docs_publication_boundary.py (the frozen list only ever
  shrinks); internal/ already fences the new location

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: classify tests/dockerfile_runtime_home.rs and shrink boundary self-test fixture

Two CI gates failed on the docs/reborn consolidation and forced decisions
this commit records:

- The Reborn PR test planner failed closed on tests/dockerfile_runtime_home.rs
  (its path-rewrite edit is functional: the test reads the moved deploy doc).
  The file was deliberately unmapped because no lane inventoried it. Decide it
  now: _root_test_partitions() and run-reborn-root-partition.sh both inventory
  it alongside support_unit_tests.rs, so the hermetic root-partition lanes run
  it (they previously ran it nowhere) and a change to it selects its partition.
  With the reader laned, map the two config.hosted-single-tenant*.toml readers
  it owns in DOCKER_RUNTIME_CONFIG_OWNERS — root-test owners select their root
  partition, completing the per-file decision set the planner comments left
  open. docker/process-sandbox-entrypoint.sh stays fail-closed.
- test_docs_publication_boundary.py's subset fixture still listed reborn/ in
  the frozen mintignore list; use the surviving entries.

Verified: both self-test suites pass (77 planner + boundary), the planner
emits a valid selected plan for this PR's full 342-path diff, shell and
Python inventories agree on partition assignment (index 0), and
dockerfile_runtime_home passes (19 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:39:06 +00:00
jinxin
3ee9314e4a refactor(webui): remove standalone legacy missions surface (#7527)
* refactor(webui): remove legacy missions surface

* test(webui): address missions removal review
2026-08-13 09:32:28 +00:00
firat.sertgoz
528993bcd7 fix(stress): follow session channel message route (#7568) 2026-08-13 09:18:24 +00:00
jinxin
f371eb98bf refactor(webui): remove admin analytics placeholders (#7529) 2026-08-13 08:23:58 +00:00
jinxin
0445e5a6d9 refactor(webui): remove project mission placeholders (#7528) 2026-08-13 08:22:55 +00:00
jinxin
6d210584b8 refactor(webui): remove retired routines surface (#7526)
* refactor(webui): remove retired routines surface

* test(webui): guard retired routines references

* test(webui): cover absolute routines routes
2026-08-13 08:20:15 +00:00
jinxin
ad18aa188e feat(llm): add per-user model preferences and commands (#7439)
* feat(llm): add tenant model selection policy

* fix(composition): move model policy store to operator

* test(llm): specify per-user model preference behavior

* feat(llm): persist per-user model preferences

* feat(llm): add per-user model commands

* fix(llm): address model preference review findings

* fix(logging): preserve model preference error causes

* fix(model): apply user preferences to channel turns

* test(channel): cover model preference handoff

* fix(composition): keep model config wiring concrete

* fix(model): preserve resolved model across inbound replay

* fix: preserve accepted model across replay failures

* test(model): restore caller-scoped preference coverage

* fix(model): close preference review gaps

* fix(model): preserve concurrent replay identity

* test(model): cover cross-tenant preference isolation

* fix(cli): honor saved user model preference
2026-08-13 08:10:10 +00:00
ironclaw-ci[bot]
ed1c19dfd8 chore(agents): refresh codebase knowledge graph (#7564)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-08-13 07:43:21 +00:00
Josh Ford
d4fa8e1f60 feat(extensions): per-field help text on admin configuration forms + channel setup docs rewrite (#7550)
* feat(extensions): add per-field help text to admin configuration forms

Manifest [admin_configuration] fields gain an optional `description` that
renders as a hint under each input on the WebUI Admin -> Configuration form,
so operators see what each value is and where it comes from while filling
the form. Threaded additively through every layer:

- registry: `AdminConfigurationField.description` (serde default — every
  existing manifest and persisted resolved record parses unchanged)
- extension host: carried on `AdminConfigurationFieldState` redacted views
- assistant: `RebornAdminConfigurationField.description` on the wire,
  omitted when empty
- extension manager: mapped through the admin-configuration view provider
- webui frontend: rendered under the input in configuration-tab.tsx

The telegram manifest is the first consumer: one hint per field (BotFather
token, invent-your-own webhook secret with the allowed charset, full webhook
URL with local-tunnel pointer, @-less bot username), and the group
description shrinks to a summary plus the tunnel note.

Coverage at each seam: manifest v3 contract (declared description survives
resolution, undeclared resolves empty), host service contract (help text
reaches the redacted view), manager view unit test (passthrough beside the
secret-redaction guard), frontend vitest (hint renders), and the
webui_v2_product_api integration test asserts every telegram field carries
non-empty help text on the wire through the production stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(channels): rewrite channel setup for the Admin -> Configuration flow

The Telegram and Slack pages, the channels overview, and the onboarding page
still taught the retired "Extensions -> Channels tab -> scroll to the bottom
of the Built-in section -> Configure" flow. Deployment credentials now live
under Admin -> Configuration, with the extension card's Configure handling
only the personal half (pairing / OAuth).

- telegram.mdx: setup rewritten around Admin -> Configuration with a table
  for all four fields; new step with an ngrok walkthrough for local
  installations (static domain, exact webhook URL, hostname-rotation note);
  troubleshooting covers stale tunnel hostnames and fail-closed activation.
- slack.mdx: operator step now points at the Slack deployment configuration
  card; stale "can't find where to configure" answer fixed.
- overview.mdx + onboard.mdx: setup taught as two halves (operator
  deployment config vs personal pairing), dead-end guidance updated.
- zh mirrors of telegram and the overview updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webui): address review findings on admin-config field help

Applies three CodeRabbit findings on #7550:

- a11y: the field help paragraph gets a stable id and the input references
  it via aria-describedby, so assistive technology reads the guidance with
  the control; fields without help text carry no dangling reference
  (asserted both ways in configuration-tab.test.ts).
- docs: the ngrok walkthrough now uses the current free-plan syntax —
  `ngrok http 3000` with the automatically assigned development domain —
  instead of the deprecated `--domain` flag and the retired claim-a-name
  flow; paid-plan reserved domains use `--url`. zh mirror updated.
- test: the integration wire assertion now pins each Telegram handle to a
  distinctive fragment of its own manifest help text (plus a field-count
  guard), so a description copied across fields or attached to the wrong
  handle fails even though all four are non-empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(extensions): keep the description field doc at its schema owner only

The pass-through copies on AdminConfigurationFieldState and the wire DTO
restated the field name and broke their structs' undocumented-sibling idiom;
the empty-means-undeclared convention lives once, on the registry schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:39:41 +00:00
Ron
ee3146af9b fix(webui): soften failed-tool activity summary with a subtle badge (#7302) (#7305)
When a tool call failed mid-run, the whole collapsed activity summary
("Activity - 13 tools, 4 failed") was painted in the danger color, so a
run the agent recovered from still read as an alarming error.

Drop the whole-row danger recolor from both summary rows (ActivityRun and
the collapsed ToolRun) and keep them in the neutral muted text. A recovered
failure is now flagged only by a small warning-tinted `alert` badge — an
informational cue, not a red banner. The collapsed tool-run summary text
omits the failure count, so its badge carries an sr-only note (reusing the
existing activity.failed strings) for assistive technology.

Adds an `alert` glyph to the icon set and a source-level regression test
pinning: no whole-row danger recolor, the gated warning badge in each row,
and the accessible note on the tool-run badge.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 19:33:16 +00:00
Benjamin Kurrek
88fe2d01e9 refactor(channels): normalize ingress and split reply from delivery (#7477)
* fix(webui): only offer the web-app notification channel when a browser is enrolled

The "Web app" row in the notification-channels picker rendered READY with a
selectable checkbox even with zero enrolled browsers (no push subscription),
so a user could pick a channel that has nowhere to deliver. Selectability and
the pill now follow the account's enrollment count: with no enrolled browser
the web-app checkbox cannot be SELECTED and its pill drops from Ready to
Unavailable, while the nested "Enable notifications in this browser" affordance
shows how to fix it. An already-stored selection stays deselectable (disabled
only when unchecked), so a browser that unsubscribes never leaves a locked-on
checkbox.

The web-push row now owns its device hook (WebPushChannelRow) so the account
status query still mounts only when the row is present; the shared row label
was extracted (renderChannelRowLabel) so every other channel renders its
checkbox inline, unchanged.

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

* wip(ingress): declare authenticated-session ingress recipe

Add IngressVerificationRecipe::AuthenticatedSession — the trust class for a
channel whose caller the host's authenticated transport (T1) already verified,
so it needs no webhook signature. Handle it fail-closed in the two webhook host
sites: no evidence mint in channel_host, and a NotWebhookVerifiable rejection in
the ingress verifier (a session channel mounts no webhook route and must never
be attested verified-inbound through the webhook path).

Incremental checkpoint toward the generic-inbound pipeline (PR2).

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

* feat(ingress): make channel route_suffix optional, paired with trust class

A channel's ingress mount now depends on its trust class. Webhook recipes (T2:
hmac/shared-secret/none) mount /webhooks/extensions/{id}/{suffix} and MUST
declare a route_suffix; an authenticated_session recipe (T1) is verified upstream
by the host transport, mounts no webhook route, and MUST NOT declare one.

- route_suffix becomes Option<RouteSuffix> (serde default+skip; existing webhook
  manifests parse unchanged into Some).
- ChannelDescriptor::validate pairs the recipe kind with route_suffix presence,
  fail-closed both ways (SessionIngressWithRouteSuffix / WebhookIngressWithoutRouteSuffix).
- Every mount/route-table consumer (active snapshot build + resolve + conflict,
  deployment channels resolve, lifecycle reserved-route check) fails closed when
  a session channel carries no route_suffix — it can never match a webhook route.

Groundwork for routing the web app's authenticated session through the one
generic inbound pipeline (PR2).

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

* docs(design): materialize the unified channel model (target architecture)

Every channel (web-app, Slack, Telegram) becomes one ChannelAdapter that
implements inbound + outbound/reply + notifications the same way. The only
per-channel variation is the declared ENTRYPOINT (webhook / api_key /
authenticated-session POST) and declared DELIVERY capabilities (reply mode
streaming|batched, optional max_message_chars, markdown, threads). Everything
between entrypoint and delivery is one abstract, channel-agnostic core:
idempotency -> bind(OwnedThread|ExternalRef) -> submit_turn -> durable reply
events -> per-mode reply sink.

Removes the current smell (two post-ingress cores; the web-app special-cased on
both inbound and reply). Records the migration deltas, the trust/security
invariants, and what stays on ProductSurface (the web-app's rich non-messaging
client API). Authoritative target for future agents touching channel code.

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

* docs(design): no channel-specific code — generic notification setup, kill web-push routes

Strengthen the unified channel model per direction: the hard invariant is that
NOTHING in the codebase is specific to a given channel for inbound, outbound, or
notifications. Every route is generic and extension_id-parameterized; channel
behavior lives only in the adapter (packages/*).

- Web-push enrollment becomes GENERIC channel notification setup: a channel
  declares notifications_require_setup; a generic status/enable/disable surface
  (by extension_id) dispatches to the adapter. VAPID/endpoints/subscription store
  move behind the web-app adapter.
- Delete /web-push/{subscribe,unsubscribe,status} and the web-app-specific
  message route; replace with generic session-inbound + notification-setup routes.
- Extend the specificity gate: zero channel names / channel-specific routes in
  generic crates. Rename web-push -> web-app (id/routes/constants) is in-scope.

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

* docs(design): notification send is a generic facade over ChannelAdapter::deliver_notification

Channels implement their notification logic in the adapter
(ChannelAdapter::deliver_notification); the DeliveryCoordinator is the generic,
any-caller facade that dispatches to it by extension_id. Routines are one caller
among several (the model's outbound_deliver already is another) — callers own
WHEN/WHAT, never HOW or which channel. Delivery is already adapter-based, so this
is exposing the facade + adapter method, not a rebuild. Setup stays a separate
generic surface (7b). Migration renumbered 8-11 accordingly.

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

* feat(inbound): trust-class + binding enums on the channel inbound contract

The unified-channel-model inbound vocabulary (§12.1 of
docs/internal/design/2026-08-10-unified-channel-model.md):

- ChannelInboundSurfaceRequest carries a trust-class enum
  (VerifiedInbound { evidence } | SessionCaller { caller }) and a binding
  enum (ExternalRef | OwnedThread { thread_id }) instead of bare webhook
  evidence, plus the session transports' requested_model hint.
- ProductInboundEnvelope carries the same pair (ProductInboundTrust /
  ProductInboundBindingDirective); auth_claim() is now Option, with
  require_verified_auth_claim() failing closed for session envelopes on
  every external-ref path (binding requests, command context, projection
  subjects).
- TrustedInboundContext::from_session_caller mints the session-arm context;
  the webhook constructors are unchanged in behavior.
- ProductInboundAck::Accepted gains optional submit-time metadata
  (AcceptedTurnSubmission) and the busy variants gain an optional
  BusyRunSnapshot, both serde-defaulted so ledger rows settled before this
  change still deserialize (pinned by ack_rows_without_submit_metadata_
  still_deserialize).
- ChannelInboundProductSurface gains a default-fail-closed inline-attachment
  admission door for session transports.
- ProductSurfaceRejectionKind gains DuplicateAction and ReplayUnavailable
  for the session-lane replay taxonomy; every exhaustive matcher classifies
  them explicitly.

Mechanical fallout: constructors updated across extension_host, openai_compat,
composition and the integration/parity harnesses; no behavior change on the
webhook lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb

* feat(inbound): owned-thread session lane inside the one inbound core

The webhook core's InboundTurnService gains the session lane (§12.2): the
envelope's binding directive selects the arm, and everything below
TurnCoordinator::submit_turn stays shared.

- OwnedThread prepare: the authenticated caller is the binding authority —
  ownership-probed through SessionThreadService (missing and foreign threads
  are indistinguishable, no existence oracle), never created implicitly, and
  the external binding resolver never runs.
- Session replay probes the exact persisted browser binding-id schemes
  (caller-scoped primary + thread-scoped legacy) so messages accepted by
  earlier builds replay instead of double-accepting; a client action id
  replayed against a different thread fails as ClientActionReplayMismatch.
- The submit tail is lane-parameterized: webui-src/webui-reply ref prefixes,
  the raw client action id as the coordinator idempotency key, and the WebUi
  product context are preserved byte-for-byte for session turns; webhook
  submissions are unchanged.
- Fresh submissions carry AcceptedTurnSubmission metadata; busy outcomes
  carry the blocking-run snapshot; session busy replays report no run
  metadata (the dedicated browser path's exact shape).
- Session skill-activation hooks record between acceptance and submission
  and clear on busy/error, matching the browser path's ordering.
- New session-lane failures (OwnedThreadUnavailable 404,
  ClientActionReplayMismatch 409/duplicate, ReplayUnavailable 409,
  SkillActivationFailed internal, AttachmentLanderUnavailable 503) never
  settle the idempotency ledger.
- submit_inbound_inner admits only user-message payloads from session
  callers, and build_channel_envelope rejects mixed trust/binding arms fail
  closed: webhook trust/pairing machinery can never run for a browser
  message and vice versa.
- CapacityExceeded submissions now surface non-retryable, matching the
  workflow's own settle decision (turn_error_is_retryable).

Covered by the new session_lane suite in inbound_turn_contract (ownership
probe and cross-thread guards sabotage-verified) plus the serde-compat pins
from the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb

* refactor(inbound): route browser + OpenAI-compat submit_turn through the one core

RebornServices::submit_turn (the SUBMIT_TURN_COMMAND implementation both the
browser route and the OpenAI-compatible transport invoke) now builds the
neutral session inbound request and admits it through the same
DefaultProductSurface core webhook channels ride — durable idempotency
ledger → owned-thread binding → TurnCoordinator::submit_turn (§12.2–3) —
then renders the acks back into the unchanged RebornSubmitTurnResponse wire
shape (fresh Submitted from submit-time metadata; replays as
AlreadySubmitted with the run's current state; busy shapes with their
decision-time snapshots; ledger-replayed busy without run metadata).

The duplicate browser tail is deleted: replay_webui_send_message,
replay_accepted_message, AcceptedWebUiMessage, mark_message_submitted_or_
replay, reconcile_terminal_duplicate, resolve_webui_thread_metadata,
parse_replay_run_id, and the webui binding-id scheme fns now live only as
the session lane of the shared core (the schemes byte-identical, with
legacy replay fallback). The reborn_services module-charter map is updated
in the same change.

Composition wires the durable session ledger
(build_session_inbound_ledger over the extension filesystem, mirroring the
per-extension channel ledgers' mount/bounds/CAS discipline) into every
product-surface instance; standalone/test builds keep the in-memory
default. SessionLaneRejectingBindingResolver guards the session core's
external-ref door fail closed.

The full reborn_services_contract suite (278 tests) passes unchanged
through the re-plumbed path — caller-owns-thread, no implicit thread
creation, client_action_id replay (including legacy binding-id rows),
cross-thread reuse rejection, busy/deferred/steering shapes, attachment
landing, and skill-activation ordering all preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb

* feat(ingress): generic session-inbound route keyed by extension_id

The web-app-specific browser message route is deleted and replaced by the
generic session-inbound door (§12.4, §8):

- POST /api/webchat/v2/channels/{extension_id}/messages replaces
  POST /api/webchat/v2/threads/{thread_id}/messages. No route names a
  channel; the path extension_id overrides the body and the thread rides the
  body (the caller owns it). Route descriptor policy is unchanged
  (14 MiB body, 60/60s per-caller, TurnCoordinator effect path).
- ProductSubmitTurnRequest/SendMessage carry the optional extension_id; the
  product surface validates it against the new
  SessionChannelDirectory port (declared in
  ironclaw_product_contracts::session_ingress, implemented by the extension
  host over the deployment channel registry — manifest-derived, install-state
  free). Unknown or non-session extensions are 404, indistinguishable from an
  absent route; a missing directory fails closed as 503. Transports that
  predate the parameter (OpenAI-compat) submit under the legacy session
  surface identity, unchanged.
- The web-app manifest declares its entrypoint: inbound = true with the
  authenticated_session verification recipe, no route_suffix (a browser
  request can never reach the webhook mount), conversation_model isolated.
  The manifest-lockstep pin now asserts exactly that.
- The deployment's session channel is advertised to the SPA on
  GET /session (session_channel_extension_id, derived from the registry —
  exactly-one resolves, otherwise none and sends fail closed client-side);
  the frontend plugs it into the generic route and carries no channel name.
- e2e harness + raw-route scenarios read the session channel from
  GET /session; Playwright mocks match the generic pattern.

Caller-level coverage: directory-missing 503 / unknown-extension 404 /
declared-channel admit in reborn_services_contract; the session-channel
directory contract in extension_host; route-table, handler, and charter
gates updated in the same change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb

* feat(reply): channel-declared reply mode — streaming sinks never batch

The reply model's declaration half (§12.5–7): every channel declares how its
reply sink consumes the durable reply-event stream.

- ChannelDescriptor gains reply_mode = streaming | batched (default batched;
  validation pairs streaming with the authenticated-session entrypoint —
  a webhook vendor has no projection stream to consume).
- The web-app manifest declares streaming: the existing SSE/WebSocket
  projection forward IS this channel's reply sink, exactly as it runs today —
  a consumer of durable reply events, never a replacement (the gateway-events
  layering rule). Its max_message_chars is now undeclared: a streaming sink
  never batches or splits, so the channel is unlimited (§6). Slack and
  Telegram declare batched explicitly; their declared bounds are unchanged.
- ResolvedChannelDelivery carries the declared mode from the same
  generation-pinned snapshot read, and the DeliveryCoordinator gates both
  delivery doors: conversation-reply intents for a streaming channel return
  NoDelivery before any attempt is persisted (the projection stream is the
  delivery), while notification-class sends (BackgroundRunNotice,
  ModelDelivery) flow regardless of mode so the notifications capability
  keeps working. Pinned by
  streaming_channel_conversation_reply_skips_batched_delivery and
  streaming_channel_still_receives_notification_class_deliveries.
- max_message_chars stays adapter-enforced at render time (channel-specific
  splitting is adapter behavior by charter); the declaration remains the
  model-facing hint. The batched sink itself never splits for a streaming
  channel by construction.

No behavior change for any existing delivery: no streaming channel receives
conversation-reply deliveries today, so the gate is the fail-closed
materialization of the current structure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb

* feat(notify): ChannelAdapter::deliver_notification + the generic notify facade

The §7a send half of notification generalization (§12.8):

- ChannelAdapter gains deliver_notification(envelope, egress) — the
  channel-specific notification send, defaulting to the channel's ordinary
  delivery (a conversational channel's notification is a message; a
  notification-only channel's whole delivery IS this send). Only the generic
  DeliveryCoordinator calls it, never feature code.
- The coordinator classifies each policy-lane delivery before the request is
  consumed: a run-notification that is not source-routed (it targets a
  notification channel, not the originating conversation) and is not an
  explicitly routed final answer rides the adapter's notification send;
  everything else rides ordinary delivery. Pinned by
  notification_class_delivery_rides_the_adapters_notification_send /
  conversation_reply_rides_the_adapters_ordinary_delivery. Zero behavior
  change for shipped adapters — all three inherit the delegating default.
- run_delivery::notifications is the named any-caller facade over the
  coordinator: notify(target, content) for one explicit catalog-resolved
  channel target, notify_user(user, content) fanning out over
  resolve_user_notification_targets (the picker set). The routine driver's
  own notification internals now delegate to it — one send path, with the
  routine lane as one caller among any number. Callers own WHEN/WHAT, never
  HOW, and never name a channel.
- The coordinator's streaming-reply gate now reads the new lightweight
  ChannelDeliveryResolver::channel_reply_mode lookup instead of performing a
  second full resolution, preserving the single generation-pinned
  resolve_channel_delivery read the OUT contract pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb

* feat(notifications): generalize notification setup behind ChannelAdapter (§7b)

Replace the bespoke /web-push/{status,subscriptions,subscriptions/remove}
routes with one generic per-channel surface keyed by extension_id:
GET/POST /api/webchat/v2/channels/{extension_id}/notifications{,/enable,/disable}.

- contracts: web_push descriptor module deleted; notification_setup module
  (status view + enable/disable command descriptors) and the
  RebornNotificationSetup* wire family replace the RebornWebPush* DTOs;
  body extension_id serde-defaulted (route path is canonical).
- assistant: reborn_services/web_push.rs deleted; notification_setup.rs adds
  ChannelNotificationSetupService + fail-closed Unsupported default +
  AdapterChannelNotificationSetupService dispatching to the channel adapter
  via ChannelDeliveryResolver (unknown extension -> 404, no-setup channel ->
  enabled:true + mutation 400, payload/detail byte bounds enforced).
- delivery coordinator: the streaming-reply gate now keys on the ROUTE, not
  the intent — a notification-routed send (RunNotification + non-live-source
  origin) flows to a streaming channel even with a conversation-shaped
  intent; pinned at the contract tier and by the blocked-fire push journey.
- web-push package: adapter implements the three setup operations over the
  slot runtime (scope byte-identical to the retired product service; detail
  carries vapid_public_key/subscription_count/subscriptions with
  endpoint_digest correlation).
- composition: wires AdapterChannelNotificationSetupService over the channel
  delivery resolver; WebPushComposition handle family deleted (the slot
  install inside assemble_web_push is now the single consumer).
- webui: route descriptors/router/handlers swapped to the generic surface;
  CONTRACT.md route table + outbound charter row updated.
- frontend: api.ts gains getNotificationSetupStatus/enable/disable keyed by
  extensionId; web-push.ts -> device-push.ts and useWebPushDevice ->
  useDevicePush re-read the channel-opaque detail; the notification panel's
  device row is matched by the GET /session-advertised session channel id —
  no channel name remains in the frontend; webPush.* i18n keys renamed
  devicePush.* across all 11 locales.
- tests: 5 new setup-dispatch contract tests + streaming-notification
  regression pin; product-api round-trip and delivery journey rewritten onto
  the generic surface; frontend suites updated (1241 pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(channels): rename web-push -> web-app and retire the old spelling (§12.11, §13)

Product identity rename: extension id / channel name / catalog target id are
now 'web-app'; package dir crates/extensions/packages/web-app (crate
ironclaw_web_app_extension); domain crate crates/domains/ironclaw_web_app;
WEB_PUSH_* constants -> WEB_APP_*, WebPush* types -> WebApp*. PROPOSAL §5
tree updated (check-target-tree: 66/66 OK). Space-separated 'Web Push'
protocol prose stays — the protocol keeps its RFC name; the CHANNEL does not.

Persisted coordinates deliberately keep pre-rename bytes, each commented in
place and pinned by the new gate's allowlist:
- secret-store credential handle value 'web_push_vapid' (renaming = VAPID
  rotation = every existing browser subscription breaks cryptographically);
- enrollment document path /web-push/subscriptions.json plus composition's
  /web-push per-user mount alias (the alias resolves to a physical subpath;
  renaming would orphan enrollments);
- binding-ref grammar mints web-app/v1/ and decodes legacy web-push/v1/
  forever (regression test added).
Documented residue, no migration: stored notification-channel selections
carrying the old 'web-push' target id render Unavailable until re-selected
(population ~QA-only; the channel shipped 2026-08-09).

The install-catalog hide for the host's own surface is no longer an id
match: is_builtin_host_surface consults the SessionChannelDirectory (the
manifest-derived authenticated_session fact), failing OPEN on an absent
directory; the production round-trip test covers the hidden-listing behavior
end-to-end.

Enforcement (§13): new architecture gate
reborn_web_push_vocabulary_retired.rs pins web-push/web_push/WebPush/
webPush/WEB_PUSH at zero occurrences across crates/ (frontend sources
included), tests/integration/, and skills/, with an exact-term shrink-only
allowlist over the five persisted-compat files, a stale-sanction check, and
an assertion that the session + notification-setup routes stay
{extension_id}-parameterized. The specificity gate's web-app carve-out doc
records the rename.

E2E journey vocabulary renamed on both the Rust and Python sides
(case ids, test names, delivery-target enum member).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump lru 0.18.1 -> 0.18.2 (RUSTSEC double-free advisory)

cargo-deny advisories began failing on every head when the lru advisory
published; 0.18.2 is the fixed release (lru-rs#238).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ci): raise composition arc_dyn ceiling 816 -> 818 (unified channel model)

Two net-new dyn seams wired at assembly, both genuine inversion ports:
the session-inbound lane's SessionChannelDirectory + durable
IdempotencyLedger, and the generic ChannelNotificationSetupService —
offset by the deleted WebPushComposition handle family. Observed on the
merged branch: 833 = 818 + 15 tolerance exactly, no slack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): migrate the last pre-unification callers and re-pin ratcheted ceilings

Everything here is fallout of surfaces this PR deliberately changed:

- smoke + composition webui_v2_e2e: the raw-HTTP browser-send helpers now
  discover the session channel from GET /session and post the generic
  /channels/{extension_id}/messages route (thread_id in the body) — the
  same flow the SPA ships.
- InboundUserMessageDispatch::Accepted is boxed (clippy large_enum_variant:
  the merged InboundTurnOutcome grew past the threshold; a rejection stays
  slim).
- journey coverage: a channel whose ingress verification is
  authenticated_session has no webhook mount — its inbound IS the WebUI
  session route, so it maps onto the webui journey evidence instead of
  demanding a per-channel label.
- attachments no-lander test pins the sharpened
  AttachmentLanderUnavailable variant (503, never settles the idempotency
  reservation) instead of the old generic rejection.
- body-limit contract test pins webui.v2.session_channel_message (14 MiB)
  after the route rename.
- contracts size ceilings re-pinned to measured merged values with
  rationale: extension_contracts 8_157 (AuthenticatedSession trust class,
  reply modes, §7b setup adapter surface), product_contracts 16_119
  (trust/binding enums, SessionChannelDirectory, setup descriptors + wire
  family), host_api 19_003 (doc churn referencing the renamed crate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): keep the catalog target id persisted, migrate e2e callers, pin notice routing

Review triage for #7477 (IronLoop + multi-agent review).

Persisted identity (IronLoop Medium, also flagged by the review): the
catalog target id now keeps its pre-rename `web-push` bytes. The
notification-channel picker stores its selection as target ids in each
user's communication preferences, so this is a persisted per-user identity
exactly like the VAPID handle, the mount alias, and the binding-ref prefix —
the three the PR already kept. Renaming it resolved every stored selection to
Missing and dropped those users from notification fan-out. Applying the PR's
own rule uniformly removes the documented residue rather than shipping it;
the integration test now pins the split (target id `web-push`, channel
`web-app`) so the two can't be conflated again.

E2E callers of the retired send route (the browser-lane CI failure): four
Playwright interceptions and one API helper still targeted
/threads/{id}/messages, so failure injection never fired. All now use the
generic /channels/{extension_id}/messages route, and every mocked GET
/session advertises session_channel_extension_id the way a real deployment
does — the SPA fail-closes without it.

deliver_notice asymmetry (review Medium, correctness): confirmed correct and
now pinned. Notice-class intents are source-routed, so none is ever
notification-routed and `deliver`'s carve-out cannot apply; for a streaming
channel the originating conversation IS the projection stream, and Retract /
React have no counterpart there (the adapter reports both unsupported). The
new test drives all seven notice intents plus the notification path in one
breath so they cannot drift.

Session surface is built once (review Low/Medium, hot path): submit_turn
rebuilt DefaultProductSurface plus ~5 Arc'd services per browser message;
every input is an immutable builder-wired Arc, so it is memoized behind a
OnceLock.

Docs the rename sweep left stale: tests/CLAUDE.md cited a test name that
never existed, the web-app README cited a VAPID handle value that doesn't
exist (the constant deliberately keeps the old value), the extensions
package-inventory row still called the channel outbound-only with no ingress,
and a merge left a duplicated comment block in inbound_turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(review): cover the notify_user fan-out and the web-app adapter's setup errors

Closes the two review findings that were test gaps rather than follow-ups —
the repo's own rule is that production-wired behavior ships with its
caller-level test, and both of these were new production surface with none.

notify_user (crates/product/ironclaw_assistant/tests/run_delivery_contract.rs):
the driver only ever calls the single-target notify, so the fan-out loop was
untested. Two contracts now pinned through the real facade: an unconfigured
user yields an empty result rather than an error, and a target whose channel
no longer resolves surfaces its own Err while its healthy sibling still
delivers (asserted at the adapter, not just the return value).

web-app adapter (crates/extensions/packages/web-app/tests/notification_setup_contract.rs):
the generic service tests drive a scripted adapter, so the real parse →
validate → store path had no error coverage. Six cases: non-JSON document,
missing key material, undecodable base64url keys, an endpoint on an
undeclared push host, a malformed unenrollment document, and every setup
operation with no runtime installed. Each asserts the store was never
touched, so a rejected payload can't leave the browser believing it is
enrolled with no server record behind it. All six passed on first run — the
arms were correct, just unproven.

observer.rs: the repeated fallible-from_envelope fallback is now one
`degradable_binding` helper — but only for the two sites that genuinely
merge 'no request' and 'no binding' into the same degrade. The delivery path
still propagates (a send with no binding is a fault), and the
rejection-hint path still distinguishes them (posted nothing vs handled by
staying silent); both reasons are documented on the helper rather than
flattened away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channels): close the audit findings — dead per-channel routes, gate gaps, session-surface fallback

Fallout from the four channel-specificity audits, plus two defects those
audits found in MY OWN branch that would have kept CI red.

Introduced by this branch, now fixed:
- a contracts-crate doc comment named Slack/Telegram, an untracked
  specificity-gate violation (the gate strips #[cfg(test)], and this was
  production code)
- deleting the dead telegram frontend modules made five ALLOWLIST rows
  stale; that list is an EQUALITY ratchet (both <= and >=), so the rows are
  removed and the baseline drops 117 -> 112

Pre-existing, found by the audit:
- 871 lines of orphaned telegram-setup frontend code holding the only two
  channel-named URL literals in the SPA, pointing at routes the backend no
  longer serves. Deleted.
- a dead ironclaw_assistant -> ironclaw_web_app dependency edge: a generic
  product crate holding a compile-time edge to one channel's domain crate
  for nothing
- telegram_extension_gates.rs still documented the retired per-channel
  pairing route as live

New gate (§13's structural half): no source file may name a channel in a
/api/webchat/v2/channels/ route. It scans SOURCE rather than the descriptor
table, because the defect it exists to catch lived entirely in callers the
route table never knew about — the table was clean while 871 lines of
channel-named client code sat beside it. Sabotage-tested against the deleted
file, which it flags. Placeholders and test fixtures are exempt (tests may
name channels, per the extension-runtime overview).

Session-surface regression, found by CI on the composition e2e suite:
a deployment that installs no channel extension had NO route to submit a
browser turn, because the old /threads/{id}/messages route is gone and
/session advertised no channel id. That is a supported deployment shape
(assemble_web_app treats its slot as optional), so browser chat must not
depend on an installed extension. BUILTIN_SESSION_SURFACE_ID now lives in
product_contracts::session_ingress; composition advertises it when no channel
claims the surface, the product gate accepts it, and WebuiServeConfig
defaults to it rather than None — the transport defaulting the surface to
'absent' was the actual defect. 15/15 composition e2e tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(internal): channel output has two axes — reply and delivery

Design record for the follow-up train. The unified channel model unified the
pipeline; this reshapes the contract it drives. Recorded here rather than in
the follow-up PR so the decisions survive the conversation that produced them.

The root finding is not the eleven-method ChannelAdapter — that is the
symptom. It is that two independent concepts share one vocabulary:

- reply    = answering the run's input, SOURCE-routed, never without a run
- delivery = reaching someone out-of-band, TARGET-resolved, runs optional

They are orthogonal, not alternatives: one run can stream an answer into an
open tab AND push a notification because the user is not looking. Dispatching
on intent rather than on this axis already produced a real defect on this
branch — a gate prompt is a reply when a human is in the thread and a delivery
when a 3am routine is blocked, and keying the streaming skip on the intent
silently dropped the second case.

Decisions: three manifest sections (ingress/reply/delivery) replacing the
inbound/outbound/notifications booleans; OutboundRoute plus two transport
enums so nonsense combinations are unrepresentable; DeliveryOrigin keeping
model-chosen targets from inheriting user-configured trust; a streaming
delivery returns a projection cursor as evidence instead of NoDelivery,
closing an audit hole where browser replies produce no record at all;
activate/cleanup become an ingress-registration recipe; the attachment fetch
moves AFTER the ack (the durable write currently depends on it, which is what
puts a vendor round-trip on the webhook deadline path); enrollment moves
host-side with no adapter method, keeping one generic pre-storage check that
exists to prevent an SSRF primitive.

Five open questions and a six-step sequencing table are recorded; step one is
the smallest and closes both the no-op and the audit hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): repair fmt, the last retired-route callers, and the contracts ceiling

Three red checks on 3e8d40bc74, all mechanical:

- cargo fmt: the export-list edit in ironclaw_assistant/src/lib.rs left an
  unformatted line (fmt reflows multi-item use blocks; I removed a symbol
  after the last fmt pass).
- composition webui_v2_serve: two tests still posted to the retired
  /threads/{id}/messages route and got 404. Migrated to the generic
  /channels/{extension_id}/messages with thread_id in the body. One of them
  exists specifically to pin 'the shape api.ts builds', so it has to track
  the SPA; the other pins the 14 MiB descriptor cap against Axum's 2 MiB
  Json default, which is unchanged by the route move.
- product_contracts size ceiling 16_119 -> 16_132: BUILTIN_SESSION_SURFACE_ID
  plus its doc, the built-in session surface that keeps the generic session
  route from depending on an installed channel extension.

Verified: cargo fmt --check clean. The suites are left to CI — another agent
is working in this worktree and a local battery would block it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wip(channels): reply and delivery become two declared axes

Contract half of the channel-output redesign
(docs/internal/design/2026-08-11-channel-adapter-contract.md §1-§3).
INCOMPLETE — see the PR body / handoff for the stale-reference list.

- ChannelReplyMode -> ReplyTransport{Stream,Message} +
  DeliveryTransport{Push,Message}. Two enums so Stream-for-delivery and
  Push-for-reply are unrepresentable; a third transport joins as a
  variant rather than a reshape (§10.5).
- [channel.reply] and [channel.delivery] manifest sections replace the
  inbound/outbound/notifications/notifications_require_setup booleans
  and reply_mode. Absence of a section means the axis is unsupported,
  so a declaration can no longer say *that* a channel does something
  without saying *how* (§2, §9).
- max_message_chars moves from [channel.presentation] to
  [channel.reply]: a split bound is a property of the reply transport
  and is meaningless for transport = stream.
- ChannelDeliveryResolver::channel_reply_mode ->
  channel_reply_transport; notifications_require_setup ->
  requires_enrollment.

Fixes a live defect found while reshaping, not a rename: the stream
reply/session-ingress pairing check sat inside 'if let Some(ingress)',
so a channel declaring a stream reply with NO ingress validated
silently. The check now sits outside that block and the no-ingress arm
is pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(channels): one derived projection for channel output facts

Replaces the three loose `max_message_chars` scalars an earlier pass in
this branch added beside `channel_presentation` at each carrier.

The bound legitimately moved out of [channel.presentation] into
[channel.reply] (it is a property of the reply transport, and is
meaningless for transport = stream). But pulling it out of a struct that
was ALREADY threaded through three carriers turned 'one type threaded
three times' into 'one type plus a loose scalar threaded three times' —
re-declaring the value at three layers with nothing keeping them in
agreement (.claude/rules/architecture.md §3).

ChannelOutputFacts is the fix: presentation + the reply bound, assembled
once by ChannelDescriptor::output_facts(), threaded exactly where
ChannelPresentation was. One manifest home per field, one projection,
carrier field count unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* wip(channels): three traits + declarative vendor-call recipes

Steps 2, 4, and 6 STARTED, NOT FINISHED — the ~20 consumers of the
removed ChannelAdapter are not yet updated. Does not compile.

- ChannelAdapter's 11 methods -> ChannelIngress::receive (async),
  ChannelReply::send_reply, ChannelDelivery::deliver/list_targets, held
  as ChannelSurfaces { ingress, reply, delivery }. A None is the same
  fact as a missing manifest section. A stream-reply channel implements
  no reply half at all.
- ChannelVendorCallRecipe: per-channel data, generic execution. Replaces
  activate/cleanup as [channel.ingress.registration]/[deregistration]
  and the attachment fetch as [channel.attachments], run post-ack.
- Telegram's setWebhook/deleteWebhook become manifest data; both method
  bodies go to zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* wip(channels): wire the three-trait split, the two-axis router, and host-owned enrollment

The workspace compiles again. `cd167ab8ed` defined ChannelIngress /
ChannelReply / ChannelDelivery and deleted ChannelAdapter without updating
~60 consumers; this wires them and lands the contract changes those consumers
were waiting on.

Step 6 — the split, with the check that earns it. ChannelSurfaces replaces
`Arc<dyn ChannelAdapter>` on ExtensionBindings, ActiveExtension,
DeploymentChannelBinding, ResolvedChannelDelivery and the composition binding.
`check_binding` now proves each `[channel.*]` section against its implementing
half at activation: webhook ingress <-> ingress half, `transport = "message"`
<-> reply half, `[channel.delivery]` <-> delivery half. Two axes are required
ABSENT and that is the point — a `stream` reply is published by the host and
`authenticated_session` ingress is normalized at the session door, so binding
a half there is dead code that reads as live. Without this check the three
Options would be a second copy of manifest facts with nothing keeping them in
agreement (architecture.md §3); with it, declaration and code cannot disagree
past activation. web-app now binds delivery ONLY.

Step 1 — OutboundRoute. The axis is computed once in DeliveryCoordinator from
the resolved routing decision and threaded through the drive chain in place of
the `as_notification` bool. The streaming gate keys on the route, not on
DeliveryIntent::is_conversation_reply — which is the exact conflation that
silently dropped blocked-routine pushes. A stream reply is no longer a silent
NoDelivery: `record_stream_reply` persists a full attempt row and returns
StreamDelivered { cursor }, so "was the user's answer delivered?" has one
answer and web-app stops being invisible in delivery audits (§4.1, §10.4).
Evidence is the projection ref the turn already wrote — §4.4's verify, not own.

Step 2 — activate/cleanup are gone. `[channel.ingress.registration]` /
`[channel.ingress.deregistration]` are executed generically by
`channel_vendor_calls`: `{handle}` substitution from non-secret config,
unresolved placeholders left for egress credential injection, body_credentials
forwarded by handle, single-pass substitution, JSON keys never templated.
Telegram's two method bodies became zero lines. Their assertions move with the
behavior to the host executor.

Step 5 — enrollment is host-owned. `ironclaw_auth::delivery_registrations`
stores an opaque, size-bounded document keyed (tenant, user, extension) with
the one security-critical check generic and pre-storage: the endpoint must
target a host declared in `[[channel.egress]]`, read from the same resolved
manifest egress policy enforces with. Without it enrollment is an SSRF
primitive. Placement is ironclaw_auth over ironclaw_outbound because the
adapter-facing view must live in extension_contracts and auth already names
it. Registrations ride the envelope and the adapter reports prunes — it holds
no store. A channel with zero registrations is a resolvable "no target" before
any adapter call. Pre-§8 documents migrate forward on read; `/web-push/
subscriptions.json` and its mount alias keep their exact bytes.

Still to do: --all-targets (test doubles, integration suites), step 4's
post-ack attachment fetch, docs, ratchets, PR body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* wip(channels): carry the three-trait split through every test double and fixture

`cargo check --workspace --all-targets` is clean. The prior commit got the
lib and binary compiling; this carries the same change through the test
surface, which is where the behavioural pins live.

- Lifecycle: the three deleted Telegram `activate`/`cleanup` tests are
  re-pinned against the generic recipe executor, verbatim in what they assert
  — the bot token travels as a HANDLE and never as bytes, the shared secret
  rides `body_credentials` so the host inserts its VALUE at the manifest's
  declared pointer, the rendered body carries `url` but never `secret_token`
  nor the handle name, a missing config value and a vendor 5xx both fail
  activation, and deactivation calls deleteWebhook. Adds the arms the adapter
  tests could not reach: deregistration is best-effort and cannot strand a
  deactivation, and a channel declaring no recipes makes no vendor call.
- Binding: per-axis tests drop or add exactly one half against a manifest
  declaring the other two, so a failure names the axis. Plus the two absences
  that are the point — a `stream` reply and `authenticated_session` ingress
  must bind NO half, because the host publishes and the session door
  normalizes.
- web-app: `notification_setup_contract` becomes
  `registration_parsing_contract`, re-aimed at where the behaviour went.
  Endpoint admission and storage bounds are generic now and pinned in
  `ironclaw_auth`; what stays this package's is interpreting the opaque
  document at delivery. New coverage the old shape could not express: one
  unusable registration is pruned WITHOUT costing its siblings their
  notification, because the host owns the list and the adapter no longer
  reads its own store.
- outbound_delivery_contract: the §7b adapter-dispatch block becomes §8
  enrollment coverage. The security-critical arm is explicit — four hostile
  endpoint shapes (undeclared host, http, userinfo smuggling, suffix
  lookalike) are refused BEFORE storage, and the recording store proves
  nothing was written.
- Test doubles across assistant/host/composition/integration bind the halves
  their fixture manifests declare; the Acme fixture gains reply+delivery over
  one shared `send`, as a conversational vendor really behaves.

Reverted in this commit: an in-flight change making `receive` return a
COMPLETE message (attachment bytes + conversation context) so the two fetch
handles could leave the trait entirely. The design is right and is written up
for a fresh pass; landing it 70% done would repeat the breakage this branch
started from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(channels): return complete inbound messages

Make ChannelIngress::receive the only vendor ingress call and return complete attachments and conversation context through manifest-restricted egress. Delete the host/product late-fetch callbacks while preserving exact byte validation, attachment budgets, policy reconciliation, batch recovery, and ack-after-commit semantics.

Keep Slack URL/history and Telegram two-hop file validation inside their packages. Also carry the selected manifest egress credential into host-owned lifecycle calls so Telegram setWebhook receives its declared token injection; the production libSQL journey covers activation, inbound bytes, dedupe, reply, and refresh.

* docs(channels): align capability contracts and ratchets

Document the ingress/reply/delivery capability split, host-owned session/stream modes, complete receive boundary, and delivery-registration ownership across the contract, package, product, and runtime guides. Amend the design record with the measured pre-ack order and the reasons a declarative attachment recipe cannot model Slack or Telegram safely.

Recapture the measured contract ceilings at extension_contracts 8,594 and loop_contracts 13,307, and the composition budget at 41,751 LOC / 837 Arc<dyn> sites. Widen the retired web-push scanner to Cargo.toml, E2E, and Python while preserving exact persisted-coordinate exceptions.

* fix(cli): warn when session channel is unavailable

Emit an operator-visible serve warning on the documented tracing target when composition resolves no session channel. Capture both the target and message in a regression test so the authenticated session route cannot disappear silently.

* fix(channels): align post-merge contracts

* fix(channels): finish normalized channel boundaries

* fix(channels): harden egress and notification setup

* fix(channels): make stream evidence and wiring explicit

* fix(review): close the verified audit and review findings

Critical — OpenAI-compat lane restored. submit_turn hard-404'd
extension_id: None while both compat workflows send None (the documented
lane: headless SDK clients cannot learn a channel id from GET /session).
Restores the None => BUILTIN_SESSION_SURFACE_ID arm exactly as the wire
doc specifies, keeps every Some strictly directory-gated, and pins the
builtin id as not route-addressable. Two-sided seam pins: the surface
half in reborn_services_contract, the caller half in the compat handlers
contract, plus a drift pin equating the contracts constant with the turn
kernel's WEBUI_SOURCE_CHANNEL.

Delivery reliability:
- Adapter report gate is coverage, not equality: vendor chunking reports
  one outcome per chunk (conformance legalizes >=); requiring == settled
  fully delivered chunked replies Unknown/Failed and invited duplicate
  resends. Under-reporting still settles Unknown, never retried.
- A crash-orphaned Prepared row re-validates and re-authorizes on replay
  (no vendor egress happened; the claim CAS stays the one transition
  authority) instead of wedging AlreadyInFlight forever. A revoked
  replay rejects via a distinct audit row, leaving the stable row for
  the claim fence. Sending-row recovery stays explicitly fail-closed
  per OUT-6; startup wiring needs a status index and is deferred with
  rationale in the PR discussion.
- Working-indicator notice refs are monotonic per run: the stable ref
  made every post-gate re-post settle AlreadyDelivered, so the
  indicator vanished after a gate cycle (and nudge refs reset the same
  way). First-post bytes are preserved.
- Reply-context store failures are logged before mapping to the unit
  port error (both the host source and the coordinator site).
- Partial web-app fan-out reasons carry the failing cause; the push
  status classification matrix (401/403/413/429/5xx/transport/mixed)
  is pinned.

OAuth binding compensation follows the credential: a terminally-failed
lifecycle activation revokes the extension credential, so the identity
binding now rolls back on exactly that arm instead of committing a
"connected with no usable credential" state; retryable dispatch
failures keep the binding (the credential remains valid and the replay
path never re-runs the hook). ContinuationDispatchFailure carries the
terminalization fact to the callback site.

Browser push enrollment un-broken (two-sided wire drift): the client
read the retired flat web-push detail shape while the backend emits
registrations/bootstrap — enroll was permanently dead and enrolled
browsers derived "another account". The client now reads the canonical
shape, project() emits per-registration endpoint_digest (lowercase hex
SHA-256 via ironclaw_common::hashing, matching endpointDigestHex), and
incomplete digest coverage reads correlation-unavailable, never
"not mine". Pinned by vitest parsing tests, the api mock now mirroring
the real shape, and a digest assertion in the integration round trip.

Session-ledger and feedback correctness:
- LlmConfigServiceError::Internal no longer settles a durable permanent
  PolicyDenied: a backend fault is transient, and the same
  client_action_id succeeds after recovery (pinned).
- Duplicate/replay rejections settle silently again instead of
  rendering the false DM-only command copy.
- ProductInboundTrust / ProductInboundBindingDirective persist
  snake_case tags (pinned before the first ledger row ships).
- session_inbound_request and sibling sites use the cause-logging
  internal_from constructor instead of dropping constructor errors.
- Attachment kind classification case-folds MIME at the boundary.
- The persisted webui-src/webui-reply prefixes are defined once.

Test-support honesty: the harness StaticSecretStore stores what
put_if_absent claims to create (and leases remember their handle), so
first-time VAPID bootstrap flows are testable; the SSRF reserved-key
strip in delivery_registrations is pinned; the session-channel catalog
hiding now has a directory-present test; web-app manifest label reads
"Web app".

Refuted with evidence (no change): the outbound record layer is already
CAS insert-if-absent + first-write-wins with the lost-race shape pinned
in outbound_state_store_contract; the WebUI session route needs no
route-level channel check because the product surface enforces the
directory fail-closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gates): reword a test comment out of the retired vocabulary scan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): stack headroom for the crate-bucket lane; evidence-driven final-reply pin

The composition-core bucket SIGABRTs on Linux: the composed-runtime skills
turn overflows the 2 MiB default test-thread stack (first seen in
the_model_runs_a_skills_script_from_the_workdir_the_body_advertises).
Give the bucket lane the same 8 MiB headroom the integration lanes document;
deep subtrees stay Box::pin'd — this is headroom, not a substitute.

The webui grouping pin asserted a hardcoded 'isFinalReply: false' literal;
the stream-evidence rework made the marker evidence-driven
(isFinalReply: finalizedText from the durable projection's finalized bit).
Pin the derivation — the same in-flight guarantee, stated against the
stronger shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 18:46:30 +00:00
Benjamin Kurrek
203cbecf15 ci: tolerate sccache install outages (#7552)
* docs: design sccache install fallback

* ci: tolerate sccache install outages

* ci: classify shared sccache action tests
2026-08-12 18:20:43 +00:00
Benjamin Kurrek
173f078bba Gate & ratchet audit: full-inventory report, five fail-opens armed, dead gates deleted (#7373)
* test(architecture): drop the dead ironclaw_storage row and arm the substrate list

Gate-audit finding (open-and-shut): SUBSTRATE_CRATES in
reborn_composition_boundaries.rs carried three rows of rot, all invisible
because the loop's `let Some(..) else { continue }` silently skipped any
entry that resolves to no workspace package:

- "ironclaw_storage": no such package exists (verified against
  `cargo metadata --no-deps`; the only MISSING name of the 29 listed).
- "ironclaw_approvals" and "ironclaw_assistant" were each listed twice.

The silent skip is replaced with a panic naming the stale entry, so the
list can no longer rot invisibly. Verified by sabotage: adding a bogus
"ironclaw_zzz_probe" row now fails the test with
"is listed in SUBSTRATE_CRATES but is not a workspace package"; the
clean list passes (23/23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): prune the dead sanctioned path from the specificity gate

Gate-audit finding (open-and-shut): SANCTIONED_PATHS in
reborn_extension_specificity.rs still exempted
`extension_host/extension_installation_store.rs` — a file deleted by
#6430. No scanned path matches the fragment (verified with rg across
crates/), so the entry exempted nothing; it is also the one exclusion
surface in this gate with no staleness check, which is how it outlived
its file. Full specificity suite green after removal (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): drop the v1 ironclaw_gateway/static exclusions from the telegram gates

Gate-audit finding (open-and-shut): both cross-tree scans in
telegram_extension_gates.rs still carved out `ironclaw_gateway/static`
— the v1 monolith's embedded UI, whose crate was deleted with the src/
monolith (no crates/*/ironclaw_gateway directory exists). The exclusions
matched nothing; scans now cover the whole tree with no dead carve-outs.
Suite green after removal (12/12).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): make the dto-collapse gate's header describe the gate that exists

Gate-audit finding (open-and-shut doc rot): the module doc still
described the pre-#6447 freeze design — a dangling doc-link to
FROZEN_COLLAPSE_DTOS (renamed RETIRED_COLLAPSE_DTOS in #6447), a
promised delete-without-trimming failure and an empty-allowlist
assertion that do not exist in the file, and a named owner for a
collapse that completed. The mechanism itself is armed and untouched;
the header now describes the permanent zero-gate it became, and records
the two originally-frozen names that deliberately left governance
(CapabilityOutcome via #6299 deletion, CapabilityDispatchRequest blessed
as the canonical port type). Suite green (2/2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): repoint the manifest-reparse allowlist note at the colocated asset

Gate-audit finding (open-and-shut doc rot): the BundledAsset allowlist
entry's justification still cited include_str! of
assets/memory_native/manifest.toml — a path retired when WS2 (#7037)
colocated packages; the live include in memory_native_extension.rs
reaches crates/extensions/packages/memory-native/manifest.toml. Comment
only; the gate's mechanism and counts are untouched. Suite green (2/2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): give the memory-vocabulary gate the partial-tree floor its twin has

Gate-audit finding: reborn_memory_retired_vocabulary.rs had no
MIN_SCANNED_FILES floor, unlike its explicit twin
reborn_retired_taxonomy.rs — so a partially-moved tree (the CHECKLIST
WS0 / #6963 'green while measuring nothing' shape) would scan a
fraction of the files and still report the vocabulary clean. The gate
was in fact born with an already-dead sanctioned path (its own header
records this), so the rot class is not hypothetical for this file.

Adds the same 500-file floor (real count ~4000), asserts it in the main
gate, and pins the premise on a fixture: a 10-file partial tree scans
clean and is rejected by the floor. Suite green (4/4); clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): close the transport gate's nested-use-group fail-open

Gate-audit finding (sabotage-verified): product_symbols_in's braced-group
branch closed at the FIRST '}' (group.find('}')), so a nested group —
use ironclaw_assistant::{m::{X}}; — truncated mid-element and recorded
zero symbols. Probed live before the fix: appending
use ironclaw_assistant::{zzz_audit::{ZzzProbe}}; to webui's lib.rs left
transports_name_only_the_frozen_residue_of_product_symbols GREEN, while
the plain-path spelling of the same import correctly failed. The same
truncation dropped qualified elements inside flat groups
({qualified_module::X} recorded nothing).

The group branch now does a balanced-brace walk, splits elements at
depth-0 commas only, and records a qualified/nested element's leading
path segment — the same key the single-path branch records for
ironclaw_assistant::module::X. Flat-element semantics are byte-for-byte
unchanged, so the frozen 100-row webui inventory is untouched (suite
green 6/6 on the live tree). Regression fixtures added to
import_scanner_reads_symbols_out_of_real_use_shapes; the original
sabotage now fails with the gate's own message (re-verified).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: delete check-e2e-matrix-files.sh — a gate for a workflow that no longer exists

Gate-audit finding (provably inert): the script's default target is
.github/workflows/e2e.yml, deleted when the v1 e2e suites were retired
(git log --diff-filter=D shows the removing commit); no workflow, script,
hook, doc, or guidance file references check-e2e-matrix-files.sh
(verified with rg across the repo including .github and .githooks).
A checker nothing runs, pointed at a file nothing provides, is dead
weight that reads as coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: delete the measured-broken check-boundaries.sh and its guidance references

Gate-audit finding (provably inert, previously measured): crates/AGENTS.md
recorded on 2026-08-05 that the script fails on a clean tree (check 5
false-positives on live test files) and that checks 1/2/3/6 target the
deleted v1 src/ tree, passing vacuously. No workflow or hook runs it; its
only callers were guidance files, two of which claimed it 'enforces'
root-tests feature gating — an enforcement claim the skill-maintainer
rules forbid for a check nothing executes.

Removed the script and every live reference: the crates/AGENTS.md warning
row becomes a tombstone note; the testing skill + exemplar reference drop
the false enforcement parenthetical; the architecture-review skill's
Verify line drops the dead command; deslop-reborn's allowed-tools drops
the permission; .coderabbit.yaml's driver-leak instruction now points at
the live enforcement (reborn_persistence_driver_boundary). Two dated
docs/internal/ plan snapshots keep their historical mentions.

Verified: python3 scripts/ci/check-guidance.py OK (2084 path references)
and its self-test OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(product): stop hardcoding charter sub-owner counts in the family map

Gate-audit finding (stale prose): crates/product/AGENTS.md said
'19-sub-owner reborn_services charter map' — the enforced map has had 20
sub-owners since #7235 added the inspector row (counted from the live
table). Rather than chase the number, drop both inline counts: the
owning maps and their gates are authoritative, and the re-verify
commands are already inline (skill-maintainer rule: no counts without a
regeneration recipe). check-guidance.py OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): correct the scanner-fixture file's name-filter claim

Gate-audit finding (doc rot with a false coverage claim): the header
said naming the FILE reborn_* makes code_style.yml's
'cargo test -p ironclaw_architecture_tests reborn' see it — but that
argument is a test-NAME filter (the measurement is documented in
reborn_contracts_vendor_census.rs), and none of this file's test fns
contains the substring, so that smoke lane runs 0 of them (11 collected
by the full plan). Comment-only; the note now records the real semantics
so file names are not trusted for lane coverage. Suite green (11/11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(internal): gate & ratchet audit report + proposed preflight gauntlet

The audit the owner asked for after PR #7157 went red six times across
four gates: every architecture-test gate, module charter, CI script, and
committed baseline inventoried with a verdict and evidence; the handful
worth acting on ranked by friction x weakness; the CI-ergonomics analysis
(why failures surface one per ~1h round-trip: no --no-fail-fast anywhere
in CI, cancel-in-progress on push, sequential fast-checks steps —
measured: two broken gates report 1 failure in 18s under the CI shape vs
both in 211s with --no-fail-fast); and the sabotage log for every probe.

scripts/preflight-gates.sh is the concrete pre-push proposal: the
deterministic-gate classes only (script gates ~10s + architecture suite
--no-fail-fast + changed-crate charter tests), covering all four #7157
gate classes locally in one command. Unwired — nothing invokes it.
Validated end-to-end on this branch: exit 0, 'every deterministic gate
green', 402.8s including gate-binary recompiles.

Placement verified: python3 scripts/ci/docs_publication_boundary.py OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(planner): classify preflight-gates.sh and the deleted check-boundaries.sh

The gate audit's own PR hit the planner's fail-closed arm — 'unmapped
test or CI path: scripts/check-boundaries.sh' — exactly the class the
arm exists to force a decision on (and the audit's report documents).
Per the PR_STATIC_CONTROL_PATHS membership rule (no Reborn test lane
exercises either file):

- scripts/preflight-gates.sh — the audit's proposed local pre-push
  gauntlet; referenced by no workflow.
- scripts/check-boundaries.sh — deleted by the audit; the entry lets the
  deletion diff (and any revert) classify instead of failing every
  downstream Reborn lane.

Verified: the planner now produces mode=selected with the
architecture-misc bucket for this branch's diff, and
python3 scripts/ci/test_reborn_pr_test_plan.py is OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(internal): add the fold-tripped asymmetric-tolerance exhibit to the audit

The strongest single exhibit for shortlist item 2, contributed by the
#7157 branch steward after this audit's cutoff and verified against the
gate's code: TOLERANCE = 400 is consulted in exactly one direction (the
banked-slack check, ceiling.saturating_sub(lines) > TOLERANCE); the
growth check is a bare lines > ceiling. With the in-file 'set to
current, not padded' instruction, every ceiling is a hard cap at the
observed count — so one line landing on main in any contracts crate
reds every open branch at its next fold until someone re-captures.

Measured recurrence on #7157: loop_contracts re-captured four times,
~once per fold (14,479 -> 13,850 -> 13,949 -> 13,115 -> 13,181), the
last tripped by main's #7361/#7363 adding 66 lines to
instruction_bundle.rs — nothing the branch wrote. All four deltas were
<= 105 lines: either repair shape in §3.2 (one-line upward tolerance
using the existing constant, or mid-window pinning) would have absorbed
every one with zero red builds. This audit's own sabotage already
proved the jaws (+1 line host_api red / -1 line common red); the fold
history shows the operational cost. The repair stays a recommendation —
adding growth headroom to a ratchet is the owner's call, not this PR's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(architecture): give the contracts size ceiling upward working slack

Owner-directed repair of the audit's sharpest finding (report §3.2): the
gate's TOLERANCE = 400 was consulted in exactly one direction — the
banked-slack check — while the growth check was a bare lines > ceiling.
Combined with 'set to current, not padded' pins, every ceiling was a hard
cap at the exact observed count, so one line landing on main in any
contracts crate redded every open branch at its next fold until someone
re-captured. Measured on #7157: four loop_contracts re-captures, roughly
once per fold, every delta <= 105 lines — the gate generating its own
busywork.

The growth check now allows GROWTH_TOLERANCE = 150 of working slack
above each pin (sized to composition-budget precedent; the reviewed
raises this gate has caught were +1,069 and +1,214 lines, far above it),
and all six ceilings are re-pinned to the counts the test itself
reported with every ceiling at 0 — which also removes the +400 seed
padding on common/loop_contracts/prompt_envelope that contradicted the
capture rule and put those crates one deleted line from the banked jaw.

Sabotage-verified both ways: +1 line in host_api and -1 line in common —
both red before this change — now pass; a +151-line probe still fails
with the effective-ceiling arithmetic in the message. Full
reborn_dependency_boundaries binary green (41/41); clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(budget): re-equalize composition pins to observed — restore the working window

Owner-directed companion to the contracts-ceiling repair (same annoying
class, other mass gate): merged main-side growth since the 2026-08-05
equalization had drifted +101 LOC and +5 Arc<dyn> sites through the
tolerance windows, leaving 49 LOC / 10 sites of live headroom — the next
routine composition PR would have gone red on wiring alone (the gate
audit measured this the same day it was pinned).

Per the TOML's own maintenance instructions: loc_ceiling/loc_observed
40423 -> 40524 and arc_dyn 814 -> 819, measured with the gate's --print,
set to current not padded, dated notes appended (not overwritten), and
the arch-test record (COMPOSITION_ABSOLUTE_SRC_LOC) moved in the same
commit as its file requires. ceiling_bp stays 658 — the WS0 floor is
deliberately not re-set.

Verified: check-composition-budget.sh OK; its 76-case self-test green;
reborn_restructure_baselines green; probe +100 LOC now passes (was red
at 49 headroom), probe +160 LOC still fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(internal): record the landed zero-slack repairs in the audit report

The §3.2 repair moved from recommendation to landed at owner direction;
the report's answer, inventory rows, and §7 ledger now say so, with the
counting-rule fix promoted to the top remaining recommendation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* gates: pin the ceiling-window arithmetic; fail preflight discovery closed

Two review-round hardenings (the open CodeRabbit Majors):

- reborn_dependency_boundaries.rs: extract the size-ceiling comparison into
  contracts_ceiling_verdict() and pin its four window edges with a committed
  regression test (contracts_size_ceiling_window_edges_hold) — accept at
  ceiling+GROWTH_TOLERANCE, reject one line past, accept at
  ceiling-TOLERANCE, reject one banked line further, and a zero-measure scan
  reads Banked, never a silent pass. The pre-repair asymmetry (tolerance
  consulted only downward) can no longer return silently. Live-gate behavior
  re-probed unchanged after the rewiring: +1 line to host_api passes, +151
  fails with the same effective-ceiling message.
- preflight-gates.sh: setup and changed-file discovery now fail closed — a
  missing repo root exits 2, and a failed merge-base/diff widens the charter
  run to all five crates instead of silently skipping them (the same
  fallback the missing-base branch already used). A broken setup may cost
  compile time, never a silent skip.

Full boundary binary 42/42 green; clippy clean; preflight-gates.sh
end-to-end green on this tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:04:35 +00:00
firat.sertgoz
09ac3c8af0 feat(memory): memory-save guidance + always-on MEMORY.md prompt lane (#7185) (#7365)
* feat(memory): tell the model when to save durable user facts

Nothing in the system prompt explains that persistent memory exists, that
the memory block it sees came from earlier conversations, or when a stated
user preference is worth saving. Add a `memory_protocol.md` prompt asset,
appended in memory on every resolve like the self-knowledge section, so
existing installs get it rather than only freshly seeded ones (#7185).

Also point `ironclaw.memory.write`'s prompt doc and both provider manifest
descriptions at the durable-fact use case, so the tool description agrees
with the protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(memory): inject MEMORY.md into every prompt without an FTS match

Both proactive-recall lanes are full-text search over the current turn's
query, so a fact saved in conversation A only reached conversation B when
B's opening message happened to share vocabulary with it (#7185).

Add a `read_curated` memory lifecycle hook and `MemoryService::read_curated`,
implemented by the native provider as a plain read of the scope's `MEMORY.md`
(absent or empty = an empty lane, not an error). The host queries it on every
run with no query at all and admits it FIRST, ahead of the search lanes.

The lane is split on line boundaries into per-snippet-sized chunks rather
than admitted as one oversized snippet: a snippet's model-visible text is
validated as a 512-byte `LoopSafeSummary`, which is also where the prompt
denylist runs, so a single wide snippet would mean denylist-checking only the
head of the document. Chunks are capped at 4 snippets / 2 KiB — half the
aggregate — so the standing document can neither starve nor be starved by the
search lanes, and a clipped document is marked truncated.

The hook is opt-in per manifest, so mem0 (no standing-document concept) is
never called on it and is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(memory): pin cross-conversation recall and the memory-write gate

Integration (real composition + libSQL): conversation A's model saves a
durable fact with append mode, and conversation B — opening on an unrelated
topic, sharing no content word with the fact, making no memory tool call —
still finds it in its system prompt. Another user's MEMORY.md on the same
composite does not. This is the case the existing proactive-recall scenario
cannot cover, because both search lanes need the query to match.

The lifecycle scenario now counts read_curated too, so "a full declaration
drives every hook" stays true rather than silently skipping the new one.

Composition: pin the approval posture the issue is really about — with the
shipping default settings (global auto-approve on) ironclaw.memory.write is
allowed, so saving a fact does not stop the turn on a prompt; with
auto-approve deliberately off it still gates. The approval seam is
per-capability (the gate policy sees the descriptor, never the invocation
input), so "ungated for curated targets only" is not expressible there, and
an unconditionally ungated save tool would override the choice of exactly
the users the gate currently protects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(architecture): raise the extension-contracts size ceiling for read_curated

The `read_curated` lifecycle hook adds one enum variant, its wire token, its
`ALL` entry, and a doc comment to ironclaw_extension_contracts — 13 lines of
declaration vocabulary. The lane's behavior (reading the standing document,
line-aligned chunking, budgets, sanitization) lives in the native provider
and ironclaw_host_runtime, so no logic reached the contracts tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(memory): teach the model how to phrase a memory worth keeping

The memory protocol told the model *when* to save a durable user fact but
nothing about what a good one looks like, and three failure modes showed up
in practice:

- A memory saved as an imperative ("Always respond concisely") is re-read as
  part of the prompt on every later turn — and the new always-on lane
  re-injects it every turn — so it becomes a standing directive that can
  override what the user is asking for right now. Memories must be
  declarative facts ("User prefers concise responses").
- Task progress, session outcomes, PR numbers, and commit SHAs are stale
  within days and crowd out the durable facts the lane exists to surface.
- With no priority framing the model saves whatever is nearest to hand
  rather than the fact that stops the user repeating themselves.

Adds those three rules to `memory_protocol.md` and the declarative-form and
staleness rules to the shared `ironclaw.memory.write` prompt doc, and
clarifies that honoring a forget request means actually rewriting the
document rather than only acknowledging it (raised in review — `write` with
`append` unset replaces the document, so the guidance is executable).

Two asset tests pin it: one for each doctrine phrase models actually copy
(including both halves of the good/bad example pair), one keeping the
protocol inside an 18-line budget, since it is appended to every prompt on
every turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): address review findings on the always-on recall lane

Four bot-review findings on #7365, all of them real:

**The memory protocol claimed a surface a disabled deployment does not
have.** `MEMORY_PROTOCOL_PROMPT` was appended unconditionally, but a
`Disabled` memory binding resolves to no provider and registers no memory
package, so the model sees no `ironclaw.memory.*` tools at all — the prompt
was telling it persistent memory exists and to call tools absent from its
surface. Composition now carries resolved memory availability into prompt
assembly, gated the same way the tool-disclosure protocol is gated on the
bridge tools existing. The three positional `bool`s on
`DefaultSystemPromptIdentitySource::try_new` become a named
`SystemPromptProtocols` struct: each one licenses the prompt to claim a
capability, and a swapped pair would fail silently. Pinned by a
production-caller test asserting the unbound prompt carries neither the
section nor a memory tool id, with a bound arm so it cannot pass vacuously.

**Two consecutive saved facts ran together.** The backend append is
byte-exact and the protocol asks for one self-contained line per fact, so
"likes tea" then "lives in Berlin" persisted as `likes tealives in Berlin`
— and the curated lane splits `MEMORY.md` on line boundaries, so both facts
reached later turns as one corrupted fact. The native service now
terminates every appended entry with exactly one newline. Regression test
drives two guided appends through the real service and asserts the curated
lane reads back two lines.

**The curated split chunked the whole document before truncating.**
`MEMORY.md` is user-controlled and re-read on every run, so a large standing
document allocated a `String` plus a snippet clone per ~400 bytes on the
retrieve-before-run path and then discarded all but four. `split_curated_text`
now takes a chunk cap and stops there; the caller passes `budget + 1`, the
one extra chunk being what proves the document was longer than admitted.

**`tests/CLAUDE.md` omitted the new scenario.** Added to the Memory section;
group totals corrected 51 → 53 against the tree.

Not applied: CodeRabbit also asked for a `coverage-floor.toml` row for the
new scenario, but that file gates per-crate coverage and has no per-scenario
rows. IronLoop asked to bound the curated *read* itself; that needs a
byte-limited primitive on the repository/filesystem contract and is a
separate change — the host-side cap above bounds the work that follows it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): compose the always-on lane over the document-store read op

The bespoke MemoryLifecycleHook::ReadCurated hook was unearned API: it
duplicated a read every document-backed provider already serves as the
ironclaw.memory.read tool, just under a new lane-specific contract. Replace
it with a general MemoryService::read_document trait method (fail-closed
`unavailable` default) that native and mem0 implement by delegating to
their existing `read`. The host composes the always-on curated lane itself
out of an ordinary document read of MEMORY.md, run unconditionally
(gated only by the existing memory-disabled context-profile check, never by
a manifest declaration) — the whole point of #7185 is that a user's
standing facts do not depend on a provider opting in to a curated-specific
hook. ReadCurated is removed everywhere: the enum variant, both manifests'
lifecycle tokens, and the extension-contracts size-ceiling bump it required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(memory): rename curated-lane test off retired vocabulary

The reborn_memory_retired_vocabulary ratchet (landed on main) pins the
literal `document_store` at zero occurrences; the curated-lane test name
tripped it after the merge. Rename only — behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): name the rewrite mode on the forget path, cover the gate at the caller

Three review findings on #7365, all narrow:

- `memory_protocol.md` established `append: true` as the save mode, then
  told the model to "rewrite the memory document" for a forget request
  without naming the mode. A model carrying `append: true` forward appends
  the correction and leaves the entry the user asked to drop, which the
  always-on lane then re-injects alongside it. Say `append: false`, and
  pin both modes in the asset test.
- The persistent-memory gate is composed in `runtime.rs` from whether a
  provider actually resolved; the unit tests only prove the flag is
  honored once someone sets it. Assert at the production construction
  site that a runtime with a bound provider really does inject the
  protocol.
- `tests/CLAUDE.md`: the §3 summary still counted five memory scenarios
  against the seven §3.4 lists, and the lifecycle row did not mention
  that the curated standing-document read runs ungated by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): serve the standing document inside native's long-term lane (#7185)

A fact the user states in one conversation was invisible in the next one
that opened on an unrelated subject. Both proactive lanes were full-text
search over the current turn's message, so recall depended on the reader
happening to reuse the writer's vocabulary.

The native provider now serves its standing `MEMORY.md` at the head of its
own `read_long_term`, ahead of the full-text hits and independent of the
query. That is the lane's stated job — "the user's general, durable
memory" — and search alone cannot deliver it.

Provider-internal, deliberately. An earlier revision made this a third
host lane composed over a new `MemoryService::read_document` trait method;
both are gone. `MemoryService` is byte-identical to main again and the
host's prompt-context service is back to two lanes with no document paths
in it. What the host sees is ordinary lane snippets it cannot tell from
search hits, which is what keeps the whole safety envelope — scope filter,
untrusted envelope, prompt denylist, per-snippet cap — applying unchanged.

Moving with the retrieval, because they are the same decision:

- The line-boundary splitter, the 4-snippet budget, and the truncation
  marker are now this provider's policy. A whole document as one wide
  snippet would be denylist-checked only at its head, so it is cut into
  chunks that each pass the host's per-snippet contract; a line carrying a
  denylisted secret is dropped on its own and the surrounding facts still
  reach the model.
- The marker reserves its own room instead of pushing a chunk past its
  cap, so a clipped document cannot read as a complete one.
- Append-mode writes terminate each entry with exactly one newline. The
  backend append is byte-exact, so without it two correct guided saves
  ("drinks tea", then "lives in Berlin") persist as one run-on line and
  reach later turns as one corrupted fact.
- A memory-disabled context profile short-circuits before the document
  read, not only before the search, so a disabled profile still issues no
  provider read at all.

mem0 is untouched: it serves no standing document, so its lane keeps its
existing behavior and issues no extra read. That also takes the #7505
target-alias divergence off this path — it remains a real tool-path
contract issue, but no lane code is involved in it any more.

The libSQL integration scenario is unchanged and is the behavior-neutrality
proof: the same user-facing property, asserted through real composition,
survived the mechanism moving from the host into the provider. The
lifecycle scenario returns to pure declared-hook counting, because there is
no host-composed document read left to except from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(memory): let the memory extension ship its own model guidance (#7185)

Nothing ever told the model that persistent memory exists or when a stated
preference is worth saving, so it usually never called
`ironclaw.memory.write` at all. That guidance now ships with the memory
extension instead of the loop tier.

`[memory]` gains one optional field, `guidance_doc`, naming a bundled asset
in the provider's own package. memory-native declares
`prompts/memory-guidance.md`; composition appends whatever the BOUND
provider declares and writes none of it.

The text was previously `ironclaw_loop_host`'s `memory_protocol.md`, which
was the wrong owner: it names concrete `ironclaw.memory.*` tools and
describes one provider's recall behavior. mem0's recall is search-first and
it serves no standing document, so native's "save it to `memory`, it comes
back every turn" advice would be actively misleading under a mem0 binding —
and there was no way to say so short of host code branching on provider
identity. Declaring the doc in the manifest that already declares the tools
puts the wording next to the thing it describes, and makes "ships no
guidance" a first-class answer. mem0 declares none, with the reason in its
manifest; absent means nothing is appended, never a fallback to another
provider's wording.

Consequences worth naming:

- `SystemPromptProtocols` carries provider CONTENT (`Option<String>`), not
  a host flag, and `ironclaw_loop_host` is byte-identical to main again —
  the loop tier owns no memory prompt text.
- Two conditions gate the append, both necessary: a provider must actually
  be resolved (a `Disabled` binding registers no package, so the model sees
  no memory tools and must not be told they exist), and that provider must
  declare guidance.
- The ref is the existing validated `CapabilityProfileSchemaRef`, the same
  newtype `prompt_doc_ref` uses, so a path escaping the package fails the
  manifest parse. A valid ref no bundled provider claims is fail-quiet:
  guidance carries no authority and gates nothing, so an unknown or future
  declaration appends nothing rather than failing a boot.
- The host resolves the ref through `ironclaw_memory_native`'s public API
  rather than `include_str!`-ing its asset tree. The first cut copied the
  inline-schema precedent and `reborn_cross_crate_include_scan` correctly
  rejected it — §11.2.7 is shrink-only. Exporting the ref and the text from
  one file also makes it impossible for the manifest and the asset to drift
  apart.
- `ironclaw_extension_contracts`' §11.2.3 size ceiling is raised 7_892 ->
  7_947 for the field, its doc, and two parse tests. Declaration vocabulary
  only: the text ships with the package, resolution lives in
  ironclaw_host_runtime, assembly stays in composition.

Guidance content is unchanged from the reviewed version, including the
`append: false` rewrite mode on the forget path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(memory): pin the guidance content where the guidance lives

The guidance-content tests moved with the asset. Composition now asserts
only what composition owns — that the bound provider's declared text is
appended verbatim, survives a user-edited SYSTEM.md, is never seeded into
the user's file, and is absent with no provider bound — against its own
fixture rather than any real provider's wording. Asserting native's exact
sentences there was testing one provider through the layer that is supposed
not to know about it, and it silently became the only thing keeping the
write-quality doctrine alive.

The content pins themselves are re-homed in the memory-native package,
where the text ships: the tool ids it names, the curated target and both
write modes, the never-save carve-out, the declarative-form rule with its
worked example pair, the staleness skip-list, the priority framing, and the
heading + line budget it has to keep to be worth appending on every turn.
Each of those is a failure the compiler cannot see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): keep the standing document out of its own lane's search half

Two review findings on the curated prefix, both real:

- A query whose words happen to match `MEMORY.md` re-admitted the standing
  document as a full-text hit behind the curated copy the model can already
  see. That spent a second snippet slot on a duplicate and displaced a
  different document that matched. The lane now excludes `MEMORY_PATH` from
  the search remainder for the same reason it excludes thread scratch: the
  prefix already owns it. Regression drives `max_snippets = 2` with both
  documents matching, so the displacement would be visible.
- A single line longer than a chunk became an oversized chunk. Previously
  the host clipped it and stamped the marker; now that the prefix rides the
  ordinary lane, the host sanitizes it exactly like a search hit and
  truncates SILENTLY — so an over-long line reached the model shortened
  with nothing saying so, and the documented per-chunk limit was not
  actually enforced. The splitter now clips such a line at a char boundary
  and marks it before admission, and `clip_and_mark` is the one place that
  reserves room for the marker.

One existing fixture moved off `MEMORY.md` onto an ordinary path:
`native_context_retrieve_excludes_thread_scratch_from_long_term` used the
standing document as its generic "durable doc", which after the first fix
would have tested the new exclusion instead of the thread-scratch one it
exists to pin. Intent and assertions unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): keep composition's measured mass honest by splitting its prompt tests

`Fast deterministic checks` was red on the composition budget gate: 41868
production LOC against an effective ceiling of 41732, 136 over. The gate
counts LOC and cannot parse an inline `#[cfg(test)]` module out of an
otherwise-production file, so this branch's prompt-assembly tests were
being counted as assembly code.

Split `root/default_system_prompt.rs`'s inline test module verbatim into a
`root/default_system_prompt/tests.rs` sibling, which the gate excludes —
the same move #7151-era fixes used for `runtime_context.rs` and
`host/run_context.rs`. No ceiling raise: composition now measures 41393,
which is 302 LOC BELOW main, so the branch ratchets the crate down rather
than spending budget on test code.

Not a rename of the problem: the tests are unchanged and all 10 still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory): resolve guidance from the bound provider's own bundled assets

Review finding on #7365: guidance_doc was declared per provider but the
host resolved it through a hard-coded match against memory-native's
exported constants — a non-native provider declaring guidance would
silently resolve to nothing.

Resolution is now generic host code over provider-supplied data: each
provider crate exports its own MEMORY_GUIDANCE_ASSETS table, the shared
bundle constructor resolves the declared ref against the bundled
provider's own table at construction time, and BundledMemoryProvider
carries the resolved content. host_runtime cannot name the mem0 crate
(sanctioned-residue dependency ratchet), so mem0's bundle constructor
takes the table as a parameter and composition — which legitimately
depends on both providers — supplies it at the call site.

Failure semantics strengthen from fail-quiet to fail-loud: a declared
ref that does not resolve within the provider's own assets is a
manifest/asset desync and fails bundle construction, same posture as
the existing missing-[memory] check. Absent guidance_doc remains a
normal no-guidance state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ci): ratchet the composition mass ceiling down to this tree

The memory-save guidance and its content pins moved out of composition
into the memory-native package that owns them, and the prompt tests were
split out of the production file. The gate's NUDGE fired at 283 LOC of
slack, and its C11 self-test fails a ceiling that no longer binds — so
lock the improvement in rather than bank it as headroom.

Measured on the merged tree with scripts/ci/check-composition-budget.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): give the QA replay job the stack its own suite documents

reborn_qa_recorded_behavior's module docs require RUST_MIN_STACK=67108864:
its replay tests build a full Reborn runtime and drive whole turns, so the
composed debug async frames sit within a few percent of whatever stack they
get. The root-tests and group-suite jobs already set exactly this value and
cite this binary's docs; the job that actually runs it never did.

Measured on this suite (macOS, debug): it overflows at 1984 KiB and clears
at 2048 KiB — main and this branch to the byte, so the libtest default IS
the boundary and any layout shift decides it. That is the same 'unrelated
layout shift' the group-suite comment already records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): move the composition mass record with its ceiling

The absolute-mass ratchet is two committed numbers that must agree: the
gate manifest's loc_ceiling and the arch-test record that asserts it still
binds. The previous commit lowered the ceiling for this branch's eviction
but left the record at its pre-eviction value, so the record then exceeded
the effective ceiling and reborn_restructure_baselines went red.

Both now sit at 41533, re-measured on the merged tree with
scripts/ci/check-composition-budget.sh after merging current main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(memory): refresh golden payloads for the memory.write description

The memory tool's description now names when to save a durable user fact,
so the two golden payloads carrying the tool surface move with it — the
sentence itself, and the surface sha256 derived from those descriptions.

No other bytes changed in either snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 14:38:59 +00:00
firat.sertgoz
ba5e07cb8b fix(safety): redact model-bound secrets without rejecting turns (#7509)
* fix(loop): allow security prose in recovered context

* test(turns): align prompt safety contract coverage

* fix(loop): address review feedback on prompt recovery (#7434)

* fix(loop): reject filler-separated credentials (#7434)

* fix(safety): redact model-bound secrets without rejecting turns

* fix(safety): preserve non-secret sha256 fingerprints

* test(safety): align channel context with gateway redaction

* fix(gateway): address coderabbit review — preserve redacted JSON shape (#7434)

* fix(safety): redact quoted structured credentials

* fix(safety): scan encoded tool result content

* fix(safety): redact structured credential values

* fix(safety): redact character-dump credentials

* fix(safety): close provider-bound redaction gaps (#7509)

* fix(safety): close structured redaction review gaps (#7509)

* fix(safety): redact nested schema and URL fragment secrets (#7509)

* test(integration): align prompt trust expectation (#7509)
2026-08-12 14:37:57 +00:00
firat.sertgoz
202b97255f fix(automations): reject unusable scheduled output (#7530)
* fix(automations): reject unusable scheduled output

* fix(ci): enable tokio sync for loop test support

* fix(agent-loop): address review feedback (#7530)
2026-08-12 12:29:01 +00:00
firat.sertgoz
a3424a9d97 feat(agent-loop): add opt-in parallel tool batches (#7416)
* feat(agent-loop): add opt-in parallel tool batches

* fix(agent-loop): address parallel batch review

* fix(agent-loop): close parallel batch review gaps (#7416)

* fix(agent-loop): preserve sibling exit state (#7416)

* fix(agent-loop): preserve terminal batch sibling state

* fix(agent-loop): drain bounded parallel terminal outcomes

* fix(turn-runner): seal parallel batch configuration

* style(turn-runner): format merged imports

* test(agent-loop): pause parallel ordering clock

* test(agent-loop): bound paused launch wait

* fix(deps): update lru past unsound advisory

* fix(agent-loop): preserve hooked batch semantics

* fix(loop): address CodeRabbit review — fail closed batch ordering (#7416)

* fix(ci): rebaseline loop contracts size ratchet (#7416)
2026-08-12 10:54:11 +00:00
jinxin
9da74f1876 feat(llm): add tenant-scoped model selection policy (#7428)
* feat(llm): add tenant model selection policy

* fix(composition): move model policy store to operator
2026-08-12 09:59:21 +00:00
firat.sertgoz
07fe11b4ef fix(loop): compact context on window eviction (#7504)
* fix(loop): retain accepted task across context eviction

* fix(threads): validate paged summary ranges

* test(turns): pin truncation metadata propagation

* fix(loop): compact context on window eviction

* fix(loop): preserve steering across eviction compaction
2026-08-12 09:52:16 +00:00
Coffee
d97b35658a chore: enable IronLoop tester (#7518) 2026-08-12 08:12:33 +00:00
ironclaw-ci[bot]
3795ed7b1d chore(agents): refresh codebase knowledge graph (#7519)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-08-12 07:44:52 +00:00
firat.sertgoz
307521f155 fix(processes): lease expiry recovers safe runs instead of failing them; isolate the journal heartbeat pool (#7471)
* fix(processes): resume runs whose lease expired at a safe checkpoint

A hosted run that lost its lease died as a user-visible failure, even when
it had committed nothing and was sitting idle waiting on the model. Lease
recovery could only tell "has a checkpoint" from "has none", so every
checkpointed run was treated as possibly-mid-side-effect and failed
terminally with `lease_expired`.

Record what the checkpoint actually was. `ProcessCheckpointKind`
(`BeforeModel` / `BeforeSideEffect` / `BeforeBlock`) now rides on the
process snapshot beside `checkpoint_ref`, because a recovery sweep reads
process rows without loading checkpoint rows. Recovery requeues a run whose
latest checkpoint replays no external effect, under the same bounded
crash-reclaim budget; `BeforeSideEffect` and unknown kinds stay terminal,
since no durable idempotency exists for a replayed capability call.

The requeue waits one full lease TTL past expiry before acting. A worker
starved of heartbeats and a dead worker look identical from the journal;
a worker still running would have renewed its lease inside that window, so
anything still expired afterwards is genuinely gone. Nothing else changes
timing: cancellation and the checkpointless requeue stay immediate.

Old journals deserialize with no kind, and an unrecognized kind degrades to
"unknown" rather than failing the whole snapshot — both read as
side-effecting, so the fail-closed path is the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(composition): give the process journal its own Postgres pool

The journal heartbeat is the liveness signal a run's lease depends on, and
it was sharing one max-size-2 connection pool with every other Postgres
consumer — the event store, triggers, result reads. One turn's read burst
could saturate that pool and starve the run's own heartbeat until the lease
expired underneath it.

Two changes, both about not putting the heartbeat behind other traffic:

- A Postgres deployment opens a small second pool (2 connections) and
  mounts the journal's filesystem over it. The mount set is byte-identical
  to the data plane's, so the journal addresses the same rows over a
  different connection — only the pool differs. libSQL and in-memory arms
  are untouched; libSQL is single-writer by design.
- The default data-plane pool goes 2 -> 8. Two was small enough that one
  turn queued behind itself.

Operators sizing connections should budget `pool_max_size + 2` per
instance; docs, the shipped Docker config, and its smoke assertion follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(turn_runner): let a turn survive one slow store checkout

Hosted turn runs heartbeat every 5 seconds, and the supervisor uses that
interval as each heartbeat's timeout, so the generic budget of 3
consecutive failures tolerated about 30 seconds of stall — no more than a
single Postgres connection-checkout timeout. One slow checkout was enough
to abandon a healthy run.

Turn runs now get 8, which clears a checkout stall while still giving up
inside the 90-second lease TTL. That bound is the point: a worker that
stops on its own leaves a live lease behind, whereas one still retrying
past the TTL gets its run reclaimed out from under it. The budget is
therefore derived from the configured heartbeat interval rather than fixed,
so widening the interval shrinks the budget instead of producing an abandon
window that outlives the lease.

The generic `ProcessSupervisorConfig` default stays at 3 — the capability
path heartbeats every 30 seconds, where 8 would run far past the TTL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): route the shipped Docker configs to the test that pins them

`Detect Reborn test scope` failed closed on
`docker/reborn/config.production.toml`, skipping every downstream Reborn
lane. The planner's comment claimed the runtime configs have no owning
lane, but `crates/app/ironclaw_cli/tests/smoke.rs` parses both
`config.toml` and `config.production.toml` and pins their boot profile,
storage backend, pool sizing and runtime policy — so a lane does read
them, and static control would have skipped exactly the assertions such
an edit can break.

Add `ROOT_FIXTURE_TEST_OWNERS`, the read-at-test-time counterpart of
`EMBEDDED_ASSET_OWNERS`, mapping each config to the `smoke` test target
that asserts it. The two hosted-single-tenant configs stay unclassified:
no test parses them, so they must keep failing closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(processes): pin the serde and grace-window edges review flagged

Three review findings on the recovery path, none behavioural:

- `lease_duration_millis` now converts the TTL once instead of three
  verbatim copies in claim, heartbeat and expiry recovery, so the bound
  and its rejection message cannot drift between them.
- The in-grace sweep now runs strictly after expiry. Expiry is inclusive
  (`lease_expires_at <= now`), so the old instant did select the process
  and the assertion was not vacuous — but pinning the grace hold on a
  boundary instant made that a property of the comparison rather than of
  the grace window.
- A persisted snapshot carrying an unrecognized `checkpoint_kind`, or
  none at all, now has coverage at the serde boundary: it degrades to
  `None` rather than failing the whole read, and `None` already recovers
  as side-effecting, so an older host fails closed.

The retry-projection fixture also derives `kind` the way
`put_loop_checkpoint` does instead of claiming `BeforeModel` in metadata
while storing `None`, and now asserts the retried snapshot inherits the
kind — the propagation was previously unasserted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(turn_runner): cap the heartbeat interval the lease cannot afford

Both reviewers found the same hole. `heartbeat_failure_budget_within_lease`
computed how many failures the lease TTL could pay for and then clamped
the result up to 1 — but at an interval past half the TTL, even one
failure costs more than the lease. An operator setting
`heartbeat_interval_secs = 120` against the 90s default got a budget of
1 whose abandon window was 240s: the journal would declare the lease
expired while the worker was still waiting on its first heartbeat, which
is exactly the "worker is provably gone" premise recovery relies on.

Cap the interval at half the TTL instead of clamping the budget, so
`budget >= 1` is honest for every configurable value and the scheduler
errs toward heartbeating more often than asked. The explicit budget
setter is capped by the same lease-derived ceiling — it was the other
way to construct a config past the TTL.

The test's `|| budget == 1` escape hatch was the hole itself; it is gone,
and the 120s case plus a 10x-TTL case are now asserted for real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(composition): prove the journal pool reaches the data plane's rows

The only coverage of the journal's separate Postgres pool built two
independent `InMemoryBackend`s and wrote through each, which cannot
prove the two pool-backed handles reach the same rows — a wrong
connection config or a shared-pool fallback passed it unchanged.

Drive `postgres_from_config_and_env` (the only public constructor that
resolves a connection config, and so the only one that opens the second
pool at all), submit a turn through the turn coordinator so the journal
writes its process row over its own pool, and read that row back over a
connection neither build pool owns. Docker-gated through the existing
`postgres_pool_or_skip` harness; the in-memory test stays as the cheap
mount-set parity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): describe the expired-lease machine that ships

Both contracts said an expired lease transitions to `RecoveryRequired`.
Recovery converges directly to a settled state instead: cancellation to
`Cancelled`; no checkpoint or a replay-safe one (`BeforeModel`,
`BeforeBlock`) back to `Queued`, the checkpointed case only after a full
lease TTL of grace; a side-effecting or unrecognized checkpoint, or an
exhausted reclaim budget, to `Failed`. Code is the deliberately-reviewed
behavior here, so the docs follow it.

`turn-runner.md` §3 carries the full transition table and notes that the
legacy `RecoveryRequired` status still exists in the vocabulary but is no
longer produced by expiry; `turn-persistence.md` §6 gets the summary and
points at it, so the two cannot drift into two half-descriptions again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(planner): classify the shipped docker/reborn configs by their parsing test

`Detect Reborn test scope` failed closed on
`docker/reborn/config.production.toml` (`unclassified pull-request path`),
cascading into the whole `Tests (Reborn)` roll-up on #7471.

The planner's comment asserted these configs have no owning lane. They do:
`crates/app/ironclaw_cli/tests/smoke.rs` parses both `config.toml` and
`config.production.toml` through `RebornConfigFile::parse_text` and asserts on
the profile, storage backend and policy. So they are not static control (whose
membership rule is "no Reborn test lane reads the file") and not prose —
either would silently under-select the one lane that catches a broken
production config. `DOCKER_RUNTIME_CONFIG_OWNERS` routes each to that test
target instead.

The two `config.hosted-single-tenant*.toml` siblings stay fail-closed: their
reader is `tests/dockerfile_runtime_home.rs`, which `_root_test_partitions()`
does not inventory, so no lane can be selected for them.

Verified against #7471's real diff: the planner exited 1 before and exits 0
after, naming the owner in its reasons; all 75 planner self-tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(processes): fence stale executors on lease reclaim; address review comments

- supervisor: never start a replacement executor while the reclaimed
  process's prior executor is still running; a definitive lease-lost
  heartbeat (InvalidLease/InvalidTransition) now cancels the stale task
  at the next tick instead of waiting out the failure budget
- turn scheduler: clamp heartbeat intervals past half the lease TTL
  (budget-of-one no longer masks an unaffordable interval); CLI config
  rejects them with a clear error
- extract lease_duration_millis helper shared by claim/heartbeat/recovery
- tests: fence regression (no executor overlap), unknown checkpoint-kind
  wire degradation, in-grace sweep strictly past expiry, retry kind
  propagation, stale-worker reply assertions, Postgres pool isolation,
  planner owner pin, smoke test for the interval bound
- docs: turn-runner expired-lease contract aligned with the state machine

* chore(deps): bump lru 0.18.1 -> 0.18.2 (RUSTSEC-2026-0253)

cargo-deny fails fast-checks on the lru 0.18.1 panic-safety advisory
(use-after-free in LruCache::pop, patched in 0.18.2, issued 2026-08-11).
All four dependents (composition, webui, extension_host, hooks) already
require `lru = "0.18"`, so this is a lock-only patch bump.

* fix(loop): lease-fence transcript writes so a reclaimed worker cannot ghost-reply

Lease recovery requeuing a safe checkpoint opened a window the journal alone
cannot close: run transitions are lease-fenced (ensure_lease), but transcript
writes were not, so a worker whose lease recovery already reclaimed — starved
of heartbeats while blocked in a model call, or suspended past the grace
window — could wake and append a second assistant answer beside the
replacement worker's. Time-based fencing (abandon window + one TTL of grace)
bounds only a worker whose runtime is live to observe its heartbeat failures;
it can never be total.

The other half of the guarantee: ThreadBackedLoopTranscriptPort now carries
the lease the run was claimed under and asks the journal — the only authority
on ownership — before every transcript write (draft begin/update, finalize,
capability-result append). A stale or unverifiable lease refuses the write as
an explicit transcript-write failure; the zombie's loop exit then fails
through its own lease-fenced claim, so nothing it produces can land on the
run the replacement completed. The turn runner's host factory installs the
fence for every claimed run.

The recovery branch comment in the process journal now states the two-part
guarantee honestly instead of over-claiming that the old executor has
"provably given up".

Regression coverage: the lease_wedge integration test releases the stale
worker after the recovered run completes and asserts its output never reaches
the transcript; a seam test pins that the production host build installs the
fence at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(processes): drop duplicated lease_duration_millis from the merge

Both reconciled lines extracted the same helper; the merge kept both
copies and E0592'd. One definition remains, with the fuller doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtime): address lease recovery review feedback (#7471)

* ci: reseed composition budget for merged tree (#7471)

* docs(turns): clarify legacy recovery lock release (#7471)

* fix(architecture-tests): move the absolute-mass record with the reseeded ceiling

The 2026-08-11 budget reseed (41582 -> 41810 for #7471's merged tree)
updated the manifest ceiling but not the paired record constant this
ratchet compares it against, leaving 228 LOC of apparent headroom — past
the 200-LOC nudge window, so the merge-queue run failed
reborn_restructure_baseline_ratchets_stay_armed.

Re-measured on this tree: 41731 (`check-composition-budget.sh`). The
41810 ceiling was seeded from the merge-queue commit, where concurrent
mainline growth adds ~79 LOC on top of this branch — inside the nudge
window of the corrected record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:54:34 +00:00
firat.sertgoz
c1491c3013 fix(loop): retain accepted task across context eviction (#7503)
* fix(loop): retain accepted task across context eviction

* fix(threads): validate paged summary ranges

* test(turns): pin truncation metadata propagation
2026-08-11 19:42:53 +00:00
Illia Polosukhin
aa86b50b49 feat(llm): explicit Anthropic cache_control breakpoints on both transports (#6997)
* feat(llm): explicit Anthropic cache_control breakpoints on both transports

Closes #6984 (P0 of the pi-harness adoption program, docs/research/
pi-agent-deep-dive.md §7.3).

The rig transport previously relied solely on Anthropic automatic
caching via a top-level cache_control field, and the OAuth transport
emitted no cache markers at all. Now both place explicit breakpoints
so the tool/system prefix and the growing conversation cache
independently:

- OAuth transport: apply_cache_breakpoints marks the system prompt
  block, the last tool definition, and the last content block of the
  last message, all carrying the retention TTL. Retention None keeps
  the legacy wire shape (plain-string system, no markers).
- rig transport: build_rig_request marks the last tool by moving it
  into rig's raw additional_params.tools (appended after typed tools,
  order preserved, Anthropic-native input_schema shape) and keeps the
  top-level automatic marker; Short retention additionally enables
  rig's typed system/last-message breakpoints. Long must not enable
  the typed breakpoints: rig markers cannot carry a TTL and a 5m
  block marker beside a 1h automatic marker is an API error.

All markers in a request share one TTL, satisfying Anthropic's
longer-TTL-first ordering rule. Unsupported models downgrade to None
via supports_prompt_cache on both paths.

Wire shape is pinned by loopback capture-server tests in both files
(three per transport: short, long, none), plus build_rig_request seam
tests for the tool move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(llm): close changed-line coverage gaps on cache breakpoints

The Reborn integration-tier changed-coverage gate flagged uncovered
branches in #6997: the unsupported-model retention downgrade on both
transports, the Image/ToolUse marker arms, the empty-text guard, and
the create_anthropic_from_registry wiring.

- Extract the duplicated downgrade logic into
  rig_adapter::effective_cache_retention, shared by lib.rs and the
  OAuth constructor, with a direct unit test over all branches.
- Wire test: an unsupported model (claude-2.1) with Short retention
  keeps the legacy no-caching shape end-to-end.
- Direct apply_cache_breakpoints tests: tool_use tail without
  system/tools, image tail, empty-text tail, empty transcript.
- Construction test driving create_anthropic_from_registry across all
  retention modes including the downgrade path.
- Move the OAuth transport test suite to src/anthropic_oauth/tests.rs
  (same idiom as rig_adapter/tests/) to stay inside the file-size
  budget, with the matching coverage exemption entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(llm): cover remaining cache-breakpoint branch arms

The changed-coverage gate flagged three remnants: the Text arm of
set_cache_control, the empty-blocks tail, and the non-Text side of the
system take-and-rebuild — which was also a latent drop: a system value
already in block form was taken and never restored. Restore it
untouched and pin all three paths with direct tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(llm): cover the empty-tools arm of apply_cache_breakpoints

The last uncovered branch on the changed-coverage gate: Some(tools)
with an empty vec (only constructible directly — complete_with_tools
maps empty to None). Extend the empty-cases test to pin the no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-11 19:38:01 +00:00
jinxin
d83495889f fix(webui): reveal long conversation titles on hover (#7480)
* fix(webui): reveal long conversation titles on hover

* test(webui): restore marquee resize observer

* fix(webui): scope markdown CSS regression check
2026-08-11 17:53:34 +00:00
firat.sertgoz
1b559f296e fix(webui): bound SSE reconnect storms (#7284)
* fix(webui): bound SSE reconnect storms

* chore(webui): restore chat bundle headroom

* fix(webui): route mid-stream SSE failures through the reconnect coordinator (#7284)

event-source-plus 0.1.x retries mid-stream body/network failures on its
own 2ms clock: after a 200 handshake, a body error bypasses
onRequestError/onResponseError and reopens via the package's internal
retry path, exhausting the 30/minute stream budget before a 429 is ever
handled.

Disable that path (maxRetryCount: 0) so it surfaces as one 'error'
abort event, and route it through the same jittered coordinator backoff
as every other reconnect source. Regression tests cover the coordinator
routing and the packaged client's behavior on an errored body after a
successful handshake.

* fix(webui): restore CLAUDE.md alias lost in the main merge (#7284)

The guidance unification on main turned the crate CLAUDE.md into an
AGENTS.md plus a CLAUDE.md -> AGENTS.md symlink alias. Resolving the
merge conflict by deleting both conflicted copies dropped the alias;
the guidance gate requires it for Claude Code auto-injection.

* fix(webui): address coderabbit review — reset quiet stream backoff (#7284)

* fix(deps): update lru for RUSTSEC-2026-0253
2026-08-11 16:28:21 +00:00
firat.sertgoz
766ac92bfb fix(disclosure): parallelize read-only bridge lookups (#7500)
* fix(disclosure): parallelize read-only bridge lookups

* fix(disclosure): execute parallel bridge batches
2026-08-11 16:22:02 +00:00
firat.sertgoz
2d6ec0a2f4 fix(triggers): add unattended scheduled-run protocol (#7497)
* fix(triggers): add unattended scheduled-run prompt

* fix(deps): update lru past RUSTSEC-2026-0253
2026-08-11 13:25:52 +00:00
firat.sertgoz
5ce21472bd fix(loop-host): unify token estimation (#7502)
* fix(loop-host): unify token estimation

* chore(deps): update lru to 0.18.2
2026-08-11 13:10:20 +00:00
jinxin
499394df4a fix(llm): prefer the authenticated NEAR AI session for default probes (#7492)
* fix(llm): reuse NEAR AI session for model probes

* fix(llm): prefer runtime session for default probes

* fix(llm): allow public NEAR AI model discovery

* fix(llm): scope NEAR AI model auth by endpoint
2026-08-11 10:54:13 +00:00
firat.sertgoz
2b87cf53df Install the packages the catalog already publishes (#7442)
* Install the packages the catalog already publishes

Skills publish a files list for the scripts and assets they ship, but the catalog entry never deserialized it and the install path passed an empty bundle, so only SKILL.md landed. Files now install alongside it, digest-verified through the same download path and bounded by the limits ironclaw_skills already enforces, and they feed the skill artifact digest while a skill with no files keeps the digest it has today. Tools using HTTP Basic could not publish an extension manifest, so they listed and failed at install; the new basic target carries only the username and the host owns the join and the base64, with a colon or control character rejected at the host boundary, at the channel descriptor, and again at injection.

* fix(ironhub): address package install review findings (#7076)

* fix(ironhub): address review round — header-collision rejection, constant-derived caps, egress contract tests (#7076)

* refactor(skills): drop unused validate_install_bundle_relative_path wrapper (#7076)

* fix(runtime): harden derived credential redaction (#7076)

* fix(skills): reject bundle path collisions at domain boundary

---------

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>
2026-08-11 09:55:45 +00:00
firat.sertgoz
81045020dc test(memory): cover bounded search edge cases (#7494) 2026-08-11 09:43:53 +00:00
firat.sertgoz
2938f24e07 fix(memory): bound native search result snippets (#7436)
* fix(memory): bound native search result snippets

* fix(memory): preserve bounded exact-match excerpts

* fix(memory): bound conventional search output at shared boundary

* docs(memory): add Mintlify validation to output-bounding plans (#7436)
2026-08-11 08:35:22 +00:00
firat.sertgoz
ce67ddaff6 test(ci): restore main coverage gates (#7493) 2026-08-11 08:34:01 +00:00
firat.sertgoz
6f1ae709d5 feat(tool-search): complete fair discovery and benchmark arms (#7410)
* test(tool-search): add large-catalog baseline

* feat(tool-search): return and use bounded signatures

* feat(tool-search): add fair discovery benchmark arms

* test(tool-discovery): add live benchmark harness

* feat(tool-discovery): default to namespace summaries

* ci(tool-discovery): classify benchmark harness

* feat(tool-discovery): use semantic namespaces

* fix(tool-search): harden discovery benchmark and mode wiring

* docs(tool-search): record corrected benchmark verdict

* fix(tool-search): address review feedback
2026-08-11 08:23:03 +00:00