diff --git a/CHANGELOG.md b/CHANGELOG.md
index eaa2449e..5bb99026 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,23 @@
-## 0.2.24 — Unreleased
+## 0.2.26 — Unreleased
+
+- Present bounded Claude text, tool requests and reported results without treating provider messages as command receipts or final QA.
+- Keep unsupported structured output behind an explicit verified-content reader, with honest archive-limit and omitted-block messages.
+- Configure separate GitHub polling continuation consent through Mapping, Scope and Review, preserving the original request through uncertain saves and exact receipt recovery.
+- Recheck current polling account, application and native authority after credential waits before clarification continuation and feedback.
+- Preserve all existing MCP wire contracts while adding four polling-continuation tools, for 162 tools and 258 versioned prompt resources.
+
+## 0.2.25 — 2026-10-20
+
+- Retain complete sanitized coding-provider events within explicit archive limits, with atomic storage and integrity-checked, currently authorized reads through HTTP, SDK, CLI and MCP.
+- Present recognized provider commands and outcomes in Studio, with a separate on-demand full-content reader and current desktop/mobile walkthroughs.
+- Distinguish recovery attempts in source-control milestone feedback while preserving historical delivery identities and unresolved-write holds.
+- Recheck the exact polled GitHub comment and its author's current repository access after preparing the issue token, before posting feedback.
+- Let interrupted execution tasks finish cancellation cleanup when several consumers detect a lost lease or the shutdown deadline expires; retain the storage fence until effects drain.
+
+## 0.2.24 — 2026-10-20
- Bind an explicitly adopted current CLI executable to one failed-run recovery task while preserving its original model, timeout, permissions, budgets and snapshot.
- Refuse changed or inaccessible executable metadata before continuation, and recheck the admitted entrypoint before process creation and result acceptance.
diff --git a/Dockerfile b/Dockerfile
index cc1715d7..dfa62af6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,8 @@ WORKDIR /build/studio
COPY studio/package*.json ./
RUN npm ci
COPY studio/ ./
+COPY tests/fixtures/activity-archive-contract-v1.json /build/tests/fixtures/activity-archive-contract-v1.json
+COPY tests/fixtures/activity-claude-contract-v1.json /build/tests/fixtures/activity-claude-contract-v1.json
RUN npm run build
FROM python:3.13-slim-trixie
diff --git a/README.md b/README.md
index b05a5b56..c6c99fac 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ A passing run records its verified source and local checkpoint. Publication rech
Review an [incremental product change](docs/product-lifecycle.md) against its saved revision before adding a feature or integration. Studio retains the original save request and any later draft separately when the outcome is uncertain. Journey references show linked behavior and acceptance criteria; review them against the product's intended experience before implementation.
+Read the run's [retained evidence](docs/run-evidence.md) to distinguish coding-provider activity from final factory verification. Studio distinguishes Codex command results from Claude tool requests, reported results and provider messages. Complete sanitized event content is available on demand within archive limits; provider reports remain separate from factory verification. SDK, CLI and MCP readers use the same current permissions and exact content descriptor.
+
## Choose how the factory executes work
The default combined service admits and executes work. A split installation separates API replicas from headless workers: one execution leader runs the jobs while other workers wait as standbys. Processes share the PostgreSQL ledger and retained private storage. Standbys provide takeover; they do not add a distributed pool of active workers. See [runtime roles](docs/runtime-roles.md) and [factory infrastructure](docs/factory-infrastructure.md).
@@ -53,9 +55,9 @@ A factory administrator can review source-control delegation under **Project acc
SDK, CLI and MCP clients can inspect those original-command candidates through the same authenticated read. Inspection never creates a continuation request or dispatches work. API-only installations report local admission and execution separately in health and metrics; accepting a request does not establish that a remote worker is ready.
-For a local factory without an incoming webhook route, an administrator can enable [GitHub App issue polling](docs/scm.md#use-github-issue-comments-from-a-local-factory). Complete the baseline and separate account consent before posting a new status or answer comment. The mapped user's current permissions still apply, recorded answers retain their provenance, and answering leaves the existing run paused. Product edits and continuation use their separate signed-ingress grants; polling does not create projects or provide full GitFlow.
+For a local factory without an incoming webhook route, an administrator can enable [GitHub App issue polling](docs/scm.md#use-github-issue-comments-from-a-local-factory). Complete the baseline and separate account consent before posting a new status or answer comment. The mapped user's current permissions still apply, recorded answers retain their provenance, and answering leaves the existing run paused. For an answered clarification, a separate polling continuation consent lets the mapped person request continuation of that same run. Review its account, components and acknowledgment under **Project access**, then use the exact continuation cut in a new GitHub comment. See the [continuation walkthrough](docs/console-tour.md#review-github-continuation-consent). Polling does not create projects or provide full GitFlow.
-For a new product, a separately consented signed GitHub conversation can use `/forge intake` to register its first project and functional definition. This requires current native administrator authority and the repository's explicit intake consent. Registration does not start implementation. Follow [repository intake](docs/repository-workflow.md#register-a-first-product-from-github) for the contract; local issue polling remains limited to status and answers on existing runs.
+For a new product, a separately consented signed GitHub conversation can use `/forge intake` to register its first project and functional definition. This requires current native administrator authority and the repository's explicit intake consent. Registration does not start implementation. Follow [repository intake](docs/repository-workflow.md#register-a-first-product-from-github) for the contract; local issue polling supports status, answers and separately consented clarification continuation on existing runs.
Use the [run evidence reader](docs/run-evidence.md) to inspect or export a consistent cut of activity, tasks, WAL and tool results. A separately granted implementation agent can read bounded evidence from its own run. Reading evidence never replays a command or clears an unresolved outcome. Optional [Bridge original-request recovery](docs/bridge.md) also retains the first submission and its pending or terminal observations; it keeps the graph-continuation hold until a supported checkpoint exists.
diff --git a/docs/agent-integrations.md b/docs/agent-integrations.md
index 97c92d3d..88bb9302 100644
--- a/docs/agent-integrations.md
+++ b/docs/agent-integrations.md
@@ -36,6 +36,12 @@ forge run --repository REPOSITORY_ID --project PROJECT_ID --component stock \
A standalone frontend can select `--backend none`. Explicit stack choices must match a configured component. `--roles-file role-selection.json` applies validated role overrides; a run retains the resulting snapshot.
+## Continue an answered clarification through local GitHub polling
+
+`ForgeClient.configure_scm_polling_resume_grant`, `scm_polling_resume_grant` and `current_scm_polling_resume_grant` save or read the separate version 3 transport-bound consent. `review_scm_polling_resume(project_id, repository_id, binding_request_id, run_id)` reads the current answered-clarification cut without dispatching it. MCP exposes `configure_scm_polling_resume_grant`, `get_scm_polling_resume_grant`, `get_current_scm_polling_resume_grant` and `review_scm_polling_resume` with the same strict request contract.
+
+Use `forge project scm-polling-resume-grants configure|get|current|review` for those explicit operations. Retain the caller request identity before a single consent POST and read the exact original request after a lost response. Existing status/answer or signed-ingress consent never becomes polling-resume authority. These clients cannot fabricate a signed event, post a GitHub command, change a provider or increase a run budget. Follow the [polling clarification continuation](scm.md#continue-an-answered-clarification-through-polling) guide to review and send a separate repository comment.
+
## Delegate repository accounts
SCM delegation connects a recorded provider account to an existing Forge user for explicit project components and actions. It does not create a user, grant project membership or turn a webhook sender into a personal login. The mapped user must remain enabled and retain the required project permission.
@@ -385,3 +391,26 @@ A mapped SCM account needs separate administrator consent to change an existing
Use `forge project scm-product-change-grants configure PROJECT REPOSITORY consent.json` for one reviewed write. The document is the strict `SCMProductChangeGrantInputV1` body described in [SCM product changes](scm.md#change-an-existing-functional-product). Retain its original request ID before sending it. After an uncertain response or conflict, use `get PROJECT REPOSITORY REQUEST` under the same CLI group. `current PROJECT REPOSITORY BINDING_REQUEST` reads the current consent for that mapping; it does not resolve a different original request.
The signed `/forge product_change` command still arrives through verified provider ingress. These clients do not fabricate its signature, infer an actor or send a product edit on behalf of the provider. Read the original result with `forge project scm-product-changes get PROJECT REPOSITORY CHANGE_REQUEST`. A missing receipt does not authorize a resend or replacement identity. Its recorded product revision may differ from the current product.
+
+
+## Read complete retained provider activity
+
+New provider stdout and stderr observations retain a bounded visible preview and, when the native JSON shape is recognized, a version 1 typed `activity_summary`. Codex summaries preserve the command, known exit code and output preview even when the original JSON exceeds the timeline's display budget. Claude summaries describe assistant text, requested tools, reported tool results and final provider reports. Plain logs remain escaped text. Unsupported or malformed structured previews get a generic card with an explicit content reader. Provider output remains untrusted observed data.
+
+Claude recognition follows its documented [CLI stream-json output](https://code.claude.com/docs/en/headless#stream-responses) and [complete content-block messages](https://code.claude.com/docs/en/agent-sdk/streaming-output#message-flow). The optional `provider: claude` summary field names this recognized format; it is not authenticated provider attribution. `tool_name` belongs only to an observed tool request. `tool_outcome` records `reported_result` or `reported_error`, without inferring a process exit code from `is_error`. A `result` message is a provider report, including when its subtype is `success`.
+
+For a mixed message, `content_blocks` contains at most four typed text/tool observations with 128-byte previews, and `omitted_blocks` counts blocks not displayed. Recognition is bounded to messages with at most 64 blocks; unsupported shapes use the generic display. Single-block previews retain their existing 768-byte limit. Tool input objects and attachments are not serialized into the summary. The original sanitized message, descriptor version, hash and archive limits stay unchanged. No cross-event tool correlation or factory verification is inferred from these fields.
+
+Each new observation has an `activity_content` descriptor bound to its run, event, stage, UTF-8 size and sanitized-message SHA256. `available` means the sanitized message was retained atomically with its index in PostgreSQL. The limits are 4 MiB per message, 64 MiB and 4,096 retained messages per run. `budget_exceeded` explicitly means complete content was not retained. Older events keep their historical text and have no invented content descriptor or backfill. The hash identifies sanitized retained bytes, not the provider's original wire output or an immutable execution receipt.
+
+Read an available descriptor through the same current authority as the run:
+
+```sh
+forge evidence activity RUN_ID EVENT_ID --expected-descriptor DESCRIPTOR_SHA256 --chunk 0
+```
+
+The equivalent SDK call is `client.run_activity_content(run_id, event_id, {"expected_descriptor": digest, "chunk": 0})`; MCP exposes `get_run_activity_content`. HTTP uses `GET /api/v1/runs/{run_id}/activity/{event_id}/content?expected_descriptor=SHA256&chunk=0`. Responses contain version 1, the descriptor digest, chunk ordinal and byte offset, total byte count, sanitized-message digest, `data_base64` and `next_chunk`. Each chunk is at most 16 KiB; follow the returned ordinal through `null`, concatenate decoded bytes, and verify the complete size and SHA256 before treating the message as complete. UTF-8 characters can span chunks. Clients do not retry effects or persist authentication in content files.
+
+Every chunk read validates the whole retained payload, current repository and frozen project scope, current reader identity, and credential admission again immediately before returning. A late registered credential anywhere in the message withholds every chunk, including when it crosses a chunk boundary. Changed descriptors, missing or corrupt content, exceeded budgets or changed scope return explicit unavailable responses; they never return a partial success. Reads are private and not cacheable.
+
+Run-evidence pages and ZIP exports keep their existing 1 MiB record/page and 16 MiB snapshot limits. They include only the small observed activity index and bound descriptors, never the BYTEA payload. New manifests mark activity content as excluded with bound references. Historical exports without new descriptors retain their prior format. The UI labels previews separately from complete content and verifies the full digest before offering complete content for copying.
diff --git a/docs/architecture.md b/docs/architecture.md
index 24c6c4f5..fa4b6111 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -45,6 +45,54 @@ Flycanon's server runs in a separate environment, with its own migration and ing
Source: [`api.py`](https://github.com/fireflyframework/firefly-forge/blob/main/src/firefly_forge/api.py) (`FactoryConfiguration`, `create_runtime`, `create_application`), [`worker_runtime.py`](https://github.com/fireflyframework/firefly-forge/blob/main/src/firefly_forge/worker_runtime.py) (`WorkerSupervision`).
+### Observed provider activity
+
+`activity_models.py` projects known Codex JSON into small typed summaries; `activity_claude.py` recognizes Claude stream-json content blocks and result messages. Both describe observed output. A requested tool, reported tool result and final provider report remain distinct from the factory's WAL receipts and verification. Claude format recognition supplies no authenticated provider attribution, cross-event tool correlation or inferred exit code.
+
+`Store.event()` retains the sanitized message separately from that bounded projection. Studio renders supported summaries, leaves plain logs readable and offers an explicit verified reader for complete content. Unsupported structured previews receive a generic card. The optional Claude summary fields preserve the existing descriptor version and byte/hash binding; the archive writer, current-authority checks, credential fences and D04 export limits are shared. See [the activity contract](agent-integrations.md#read-complete-retained-provider-activity) and [the operator walkthrough](run-evidence.md#read-complete-provider-activity).
+
+### GitHub polling clarification continuation
+
+`SCMPollResumeGrantInputV3` separates REST-polling continuation consent from status/answer bindings and signed-delivery grants. `SCMPollResumeAuthority` owns current binding, project and native-user checks, immutable consent receipts and bounded history. Its registered pyfly controller, `ForgeClient`, CLI and four MCP tools share the same typed contract. Studio's Mapping, Scope and Review wizard retains one input before submission and reads its exact receipt after an uncertain response.
+
+A new `/forge resume` comment carries the existing version 2 continuation input under the distinct `github_rest_poll_v1` grant. `SCMPollResumeAuthority`, extending `SCMResumeAuthority`, rechecks the current account and application outside the database transaction, then native authority and the original clarification cut under the retained locks. Credential admission waits trigger fresh remote and local checks before action and feedback. An unknown intent remains held; queuing the original run records neither provider execution nor final product acceptance.
+
+
+
+```mermaid
+---
+config:
+ flowchart:
+ wrappingWidth: 96
+---
+flowchart LR
+ A[Review account and scope] --> B[Retain consent receipt]
+ B --> C[Read clarification cut]
+```
+
+
+
+Consent prepares the boundary. A new comment requests the actual continuation:
+
+
+
+```mermaid
+---
+config:
+ flowchart:
+ wrappingWidth: 96
+---
+flowchart LR
+ D[New resume comment] --> E[Recheck remote and native authority]
+ E --> F[Retain continuation receipt]
+ F --> G[Queue original run]
+ E -->|Unknown or revoked| H[Hold or refuse]
+```
+
+
+
+
+
+
+
+Scope: select the components and review whether continuation is allowed or revoked.
+
+
+
+
+
+
+Review: acknowledge the selected account and scope before Save permission. This records one consent revision.
+
+
+
+
+
+
+
+
+The receipt confirms the recorded permission revision. It does not prove that a run resumed or completed.
+
+
+If a save response is lost, conflicts or cannot be verified, keep the original request. **Check original consent** reads that request's exact receipt after reload; a missing receipt keeps the write unresolved. **Read current GitHub consent** answers a different question: which consent is current now. Current access loss clears private details while preserving the unresolved request. Use HTTPS or localhost in a browser with Web Locks so tabs can coordinate writes.
+
+After consent is confirmed, read the paused run's [current clarification continuation cut](scm.md#continue-an-answered-clarification-through-polling) and send one new `/forge resume` issue comment with those exact references. The factory rechecks the current account, application and native permissions before queuing the original run. A saved historical consent, a queued run and a finished implementation are separate outcomes.
+
## Keep captures current
For project tool permissions and provider prerequisites, follow the [external tools walkthrough](external-tools-tour.md).
@@ -577,7 +623,7 @@ UPDATE_DOC_SCREENSHOTS=1 FORGE_DOCS_PORT=5227 \
--config studio/playwright.docs.config.ts
```
-The configuration captures the product journey, learning decisions and source recovery, external tool setup, queued-command review and campaign evidence. For complementary views against unchanged frontend and capture inputs, set `CAPTURE_STAGES` to a comma-separated list of state names. Existing captures keep their bytes and recorded provenance. After a frontend, Studio version or capture-input change, refresh all complete journeys.
+The configuration captures the product journey, learning decisions and source recovery, external tool setup, queued-command review, campaign evidence, provider activity and GitHub continuation consent. For complementary views against unchanged frontend and capture inputs, set `CAPTURE_STAGES` to a comma-separated list of state names. Existing captures keep their bytes and recorded provenance. After a frontend, Studio version or capture-input change, refresh all complete journeys.
Choose an unused local port. The configuration starts its own server and refuses to reuse another checkout. Captures wait for fonts, use the configured viewport and check visible wizard actions against the footer and click target. Native scrollbar painting stays enabled. Review the actual images and rendered guides after regeneration. Fixture screenshots remain presentation evidence, even when the browser checks pass.
diff --git a/docs/diagrams/architecture-components-narrow.svg b/docs/diagrams/architecture-components-narrow.svg
index a1b414f0..8cf5a198 100644
--- a/docs/diagrams/architecture-components-narrow.svg
+++ b/docs/diagrams/architecture-components-narrow.svg
@@ -91,4 +91,4 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-Native services behindthe factoryClients submit contracts; theexecution leader owns effects.01Operator surfacesStudio, CLI, ForgeClient and157 factory MCP tools useauthenticated contracts.02Identity and project accessOperatorAuth validatesidentity. AccessRoutes checksthe requested resource andoperation.03Typed controllersNative pyfly REST mappings bindinputs. Admission servicesfreeze the run's selectedinputs.04Execution servicesEngine, Journal and deliveryconsumers coordinate retainedwork under one leader.05Typed repositoriesStore usesSessionProvider.unit(). Nativerepositories persist entitiesin the selected schema.06Durable state and effectsPostgreSQL owns records.Private storage ownscredentials, Git workspaces andartifacts.Default combined process or splitAPI replicas with one executionleader.
\ No newline at end of file
+Native services behindthe factoryClients submit contracts; theexecution leader owns effects.01Operator surfacesStudio, CLI, ForgeClient and162 factory MCP tools useauthenticated contracts.02Identity and project accessOperatorAuth validatesidentity. AccessRoutes checksthe requested resource andoperation.03Typed controllersNative pyfly REST mappings bindinputs. Admission servicesfreeze the run's selectedinputs.04Execution servicesEngine, Journal and deliveryconsumers coordinate retainedwork under one leader.05Typed repositoriesStore usesSessionProvider.unit(). Nativerepositories persist entitiesin the selected schema.06Durable state and effectsPostgreSQL owns records.Private storage ownscredentials, Git workspaces andartifacts.Default combined process or splitAPI replicas with one executionleader.
\ No newline at end of file
diff --git a/docs/diagrams/architecture-components.svg b/docs/diagrams/architecture-components.svg
index 5aa91b62..debd95bc 100644
--- a/docs/diagrams/architecture-components.svg
+++ b/docs/diagrams/architecture-components.svg
@@ -91,4 +91,4 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-Native services behind the factoryClients submit contracts; the execution leader owns effects.01Operator surfacesStudio, CLI, ForgeClient and 157 factory MCPtools use authenticated contracts.02Identity and project accessOperatorAuth validates identity. AccessRouteschecks the requested resource and operation.03Typed controllersNative pyfly REST mappings bind inputs.Admission services freeze the run's selectedinputs.04Execution servicesEngine, Journal and delivery consumerscoordinate retained work under one leader.05Typed repositoriesStore uses SessionProvider.unit(). Nativerepositories persist entities in the selectedschema.06Durable state and effectsPostgreSQL owns records. Private storage ownscredentials, Git workspaces and artifacts.Default combined process or split API replicas with one execution leader.
\ No newline at end of file
+Native services behind the factoryClients submit contracts; the execution leader owns effects.01Operator surfacesStudio, CLI, ForgeClient and 162 factory MCPtools use authenticated contracts.02Identity and project accessOperatorAuth validates identity. AccessRouteschecks the requested resource and operation.03Typed controllersNative pyfly REST mappings bind inputs.Admission services freeze the run's selectedinputs.04Execution servicesEngine, Journal and delivery consumerscoordinate retained work under one leader.05Typed repositoriesStore uses SessionProvider.unit(). Nativerepositories persist entities in the selectedschema.06Durable state and effectsPostgreSQL owns records. Private storage ownscredentials, Git workspaces and artifacts.Default combined process or split API replicas with one execution leader.
\ No newline at end of file
diff --git a/docs/diagrams/architecture.svg b/docs/diagrams/architecture.svg
index 5aa91b62..debd95bc 100644
--- a/docs/diagrams/architecture.svg
+++ b/docs/diagrams/architecture.svg
@@ -91,4 +91,4 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-Native services behind the factoryClients submit contracts; the execution leader owns effects.01Operator surfacesStudio, CLI, ForgeClient and 157 factory MCPtools use authenticated contracts.02Identity and project accessOperatorAuth validates identity. AccessRouteschecks the requested resource and operation.03Typed controllersNative pyfly REST mappings bind inputs.Admission services freeze the run's selectedinputs.04Execution servicesEngine, Journal and delivery consumerscoordinate retained work under one leader.05Typed repositoriesStore uses SessionProvider.unit(). Nativerepositories persist entities in the selectedschema.06Durable state and effectsPostgreSQL owns records. Private storage ownscredentials, Git workspaces and artifacts.Default combined process or split API replicas with one execution leader.
\ No newline at end of file
+Native services behind the factoryClients submit contracts; the execution leader owns effects.01Operator surfacesStudio, CLI, ForgeClient and 162 factory MCPtools use authenticated contracts.02Identity and project accessOperatorAuth validates identity. AccessRouteschecks the requested resource and operation.03Typed controllersNative pyfly REST mappings bind inputs.Admission services freeze the run's selectedinputs.04Execution servicesEngine, Journal and delivery consumerscoordinate retained work under one leader.05Typed repositoriesStore uses SessionProvider.unit(). Nativerepositories persist entities in the selectedschema.06Durable state and effectsPostgreSQL owns records. Private storage ownscredentials, Git workspaces and artifacts.Default combined process or split API replicas with one execution leader.
\ No newline at end of file
diff --git a/docs/prompt-templates.md b/docs/prompt-templates.md
index 1534dce4..68383a14 100644
--- a/docs/prompt-templates.md
+++ b/docs/prompt-templates.md
@@ -5,9 +5,9 @@ description: Maintain the versioned YAML instructions rendered by native Firefly
# Maintain model instructions
-Forge's built-in model instructions live in `src/firefly_forge/resources/prompts/v1/`. Python selects a known template and supplies already validated text. Firefly Agentic 26.10.1 loads the YAML, registers its exact version and renders it with its native Jinja2 implementation.
+forge's built-in model instructions live in `src/firefly_forge/resources/prompts/v1/`. Python selects a known template and supplies already validated text. Firefly Agentic 26.10.1 loads the YAML, registers its exact version and renders it with its native Jinja2 implementation.
-The resources cover implementation profiles, default roles and skills, product dialogue, independent review, repair, task guidance and model-facing tool descriptions. The catalog contains 251 templates: the original 235 resources, two factory verification instructions and fourteen source-control feedback templates. The original resources include 77 instructions, 157 factory MCP descriptions and the opt-in native execution-evidence tool description. The seven community adaptations remain in their existing licensed JSON catalog. Runtime commands, SQL, general UI labels, provider credentials and user messages are not template source.
+The resources cover implementation profiles, default roles and skills, product dialogue, independent review, repair, task guidance and model-facing tool descriptions. The catalog contains 258 templates: 77 instructions, 162 factory MCP descriptions, the opt-in native execution-evidence tool description, two factory verification instructions and sixteen source-control feedback templates. The seven community adaptations remain in their existing licensed JSON catalog. Runtime commands, SQL, general UI labels, provider credentials and user messages are not template source.
## Find the right resource
@@ -24,7 +24,7 @@ The resources cover implementation profiles, default roles and skills, product d
`manifest.json` lists each ID, version, relative YAML path, source SHA256, native output field and string-variable contract. Forge checks that manifest before registering or rendering a resource. The catalog digest identifies those packaged bytes; it is not a receipt proving which prompt a historical model call received.
-`implementation/automatic-verification` describes the typed capability and its finite continuation allowance. `implementation/verification-result` projects known command receipts into bounded factory feedback and marks execution output as untrusted data. The fourteen `collaboration/` resources format recorded actions, questions, checks, recovery states and publication references without another model call. These additions leave the 157 factory MCP tools and their existing policy wire contract unchanged; the automatic-verification policy setting is additive on HTTP.
+`implementation/automatic-verification` describes the typed capability and its finite continuation allowance. `implementation/verification-result` projects known command receipts into bounded factory feedback and marks execution output as untrusted data. The sixteen `collaboration/` resources format recorded actions, questions, checks, recovery states and publication references without another model call. Provider recovery cards identify the native reserved attempt and distinguish current checks from retained results with older or incomplete attempt provenance. These verification and feedback instructions preserve existing factory MCP policy wire contracts; the automatic-verification policy setting is additive on HTTP. Polling continuation operator tools have their own explicit transport-bound consent contracts.
## Edit a template
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 41047f5c..81f2aa54 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -5,6 +5,26 @@ description: Review firefly forge release changes, runtime requirements and the
# firefly forge release notes
+## 0.2.26
+
+Studio displays Claude stream-json activity as bounded text, tool requests, reported tool results and final provider reports. Mixed messages show their omitted-block count. Unknown structured output has a generic card and an explicit reader when complete sanitized content was retained. The existing byte/hash descriptor, archive limits and current access checks stay intact. Tool requests and provider reports establish no command receipt or final factory verification. See [provider activity](run-evidence.md#read-complete-provider-activity).
+
+A factory administrator can separately authorize a mapped GitHub account to request continuation of an answered clarification through REST issue polling. **Project access** guides Mapping, Scope and Review, retains one request before **Save permission**, and confirms its exact receipt. A lost response or conflict keeps the original request held for readback. Each new continuation still checks current application, account, project and execution authority. Saving consent starts no work. See [the walkthrough](console-tour.md#review-github-continuation-consent) and [polling continuation](scm.md#continue-an-answered-clarification-through-polling).
+
+The HTTP, SDK, CLI and MCP contracts share this separate transport-bound consent. The factory now exposes 162 MCP tools and 258 versioned prompt resources. Local controlled fixtures illustrate the UI and admission boundaries; they do not establish a live GitHub continuation, provider recovery or completed CRM.
+
+## 0.2.25
+
+New provider stdout and stderr records keep a bounded index and complete sanitized content within explicit per-event and per-run limits. Studio presents recognized commands, output previews and known exit codes. **Read full recorded event** reads and verifies the retained bytes before exposing the full content. HTTP, SDK, CLI and MCP use the same current access checks and exact descriptor. Existing activity remains readable; historical content is not reconstructed. See [complete provider activity](run-evidence.md#read-complete-provider-activity).
+
+Source-control milestone feedback distinguishes separate admitted recovery attempts, including failures that end with the same run status. Existing runs without a recovery retain their original fingerprint. Current authority, bounded dispatch and unknown-write holds remain required. A provider's check result does not become final factory QA.
+
+For GitHub polling feedback, forge repeats the exact comment and author-access checks after preparing the issue token, then checks native authority again before the POST. A changed source or withdrawn permission stops the reply and retains the unresolved write for review. Repeated ticks do not resend it. Remote GitHub changes and the local transaction remain separate boundaries. See [polling feedback](scm.md#6-inspect-the-reply-and-preserve-unknown-outcomes).
+
+Execution shutdown protects an effect already draining after lease loss from a second cancellation. An expired shutdown deadline retains the storage fence until the effect finishes; it does not authorize a standby to repeat an uncertain command. See [worker takeover](runtime-roles.md).
+
+These changes preserve observed activity and recovery decisions. They do not establish successful live provider recovery or a completed generated product.
+
## 0.2.24
An administrator can explicitly adopt the currently configured official CLI executable for one admitted failed-run recovery. The server binds its canonical path, file identity and SHA-256 to the reserved implementation task, then checks it again before dispatch, immediately before process creation and before accepting the result. Changed or inaccessible executable metadata refuses continuation. The original model, timeout, permissions, budgets and snapshot remain frozen; later normal repairs use the original executable configuration. Default recovery keeps its frozen executable. See [adopt an updated CLI](recovery.md#adopt-an-updated-cli-for-one-recovery).
diff --git a/docs/repository-workflow.md b/docs/repository-workflow.md
index 22697d92..e9eac9af 100644
--- a/docs/repository-workflow.md
+++ b/docs/repository-workflow.md
@@ -292,6 +292,16 @@ explicit status request can establish a new subscription. The peer lookup holds
when a conversation has more than 200 retained receipts; it does not guess which
receipt is current.
+A valid native provider recovery adds its request ID and reserved implementation
+attempt to the milestone. Two failed recoveries of the same run remain distinct
+even when polling misses the queued and running states between them. The card
+identifies the recovery attempt and the recorded attempts behind any retained
+check totals. Checks from another attempt, or checks without complete attempt
+provenance, do not verify the recovery. Runs without a valid provider recovery
+keep their existing milestone fingerprints, so this change does not repost
+unchanged legacy runs. Updates still depend on polling; every transient stage is
+not guaranteed to be observed or delivered.
+
A typed automatic-verification handoff is shown as factory verification pending,
running, completed or held. Pending and running system work does not ask an owner
to run commands. Completed means command evidence was recorded, while held
diff --git a/docs/run-evidence.md b/docs/run-evidence.md
index 3a3d3698..2fb6a127 100644
--- a/docs/run-evidence.md
+++ b/docs/run-evidence.md
@@ -40,6 +40,54 @@ The WAL retains its original payloads and chain hashes. Activity and task rows c
`artifacts.json` contains references. A reference is `receipt_bound` only when its SHA256 appears in a matching immutable receipt. Older `changes.zip` records have no historical byte hash and are labeled `not_historically_bound`. Their bytes are not copied, rebuilt or certified by this export. The existing source ZIP endpoint remains available separately.
+## Read complete provider activity
+
+New stdout and stderr events retain a short preview and, for recognized provider JSON, a typed activity summary. Codex command records show their observed exit code and output. Claude records distinguish text, tool requests, reported tool results and final provider reports. **Read full recorded event** retrieves the retained sanitized message explicitly. Factory verification and product acceptance have their own records.
+
+Claude tool requests name the requested tool and show a Bash command preview when available. A request does not prove that a tool ran. Tool-result cards show the provider's reported result or error without deriving an exit code, and keep the original call ID under **Tool call identity**. forge does not infer a tool name from another event. A successful final provider report supplies no factory QA approval.
+
+A Claude message can contain several content blocks. Studio displays up to four bounded blocks and states how many blocks were omitted from the preview. When the archive retained the full event, you can read those blocks there. An archive-limit notice means only the preview was retained. Tool input objects, attachments and unsupported block formats stay in the retained event. Unknown, malformed or oversized JSON previews receive a short generic card; plain text logs remain readable. Full content is loaded only when requested and within its archive budget. These display rules recognize an observed JSON format; they do not authenticate the provider or replace a command receipt.
+
+
+
+
+
+
+
+This controlled Claude message requested a command. Its request card supplies no execution receipt.
+
+
+
+
+
+
+The provider reported a tool error. Read the full retained event explicitly when needed; factory verification is recorded separately.
+
+
+
+
+
+
+This controlled walkthrough shows a preview. Reading complete content is a separate action.
+
+
+
+
+The client verifies all returned chunks before offering the complete sanitized content for copying. These fixture screens establish presentation, not live provider execution or product acceptance.
+
+
+Each new event carries an `activity_content` descriptor binding the run, event, stage, byte count and sanitized-message SHA256. Available content is limited to 4 MiB per message, 64 MiB and 4,096 messages per run. `budget_exceeded` means the full message was not retained. Historical events are not backfilled. D04 run-evidence pages and ZIP exports retain only the activity index and bound descriptors; they exclude complete content bytes and keep their existing limits.
+
+Read one available message using its exact descriptor:
+
+```bash
+forge evidence activity RUN_ID EVENT_ID --expected-descriptor DESCRIPTOR_SHA256 --chunk 0
+```
+
+HTTP uses `GET /api/v1/runs/{run_id}/activity/{event_id}/content` with `expected_descriptor` and `chunk`. The SDK method is `ForgeClient.run_activity_content(run_id, event_id, query)`; MCP exposes `get_run_activity_content`. Each response returns at most 16 KiB in `data_base64`, with byte offset, total size, sanitized-message digest and `next_chunk`. Follow that ordinal until `null`, concatenate decoded bytes, and verify the complete size and SHA256 before decoding UTF-8: a character may span chunks.
+
+Every chunk requires current native read authority over the run's repository and frozen project scope, and checks the whole retained message for newly registered credentials. Revocation, descriptor or scope changes, missing or corrupt content and exhausted budgets withhold content with an explicit error. Private responses are not cacheable. These reads do not replay commands or change a run's verification state.
+
## Current access and withheld evidence
The reader needs a current native identity, project read permission and read access to all historical repositories named by the verified project revision. Unassigned executions require a factory administrator. A delegated SCM status or answer permission does not grant evidence export.
diff --git a/docs/runtime-roles.md b/docs/runtime-roles.md
index f35353b8..3a43f50f 100644
--- a/docs/runtime-roles.md
+++ b/docs/runtime-roles.md
@@ -31,7 +31,7 @@ In a split installation, additional headless workers remain in `standby` while t
After a leader exits, a standby can acquire the released fences, recover retained work and become the new leader. Recovery rechecks ownership between services. Interrupted work with an intent but no durable receipt requires reconciliation; promotion does not repeat uncertain commands or remote effects. Losing the database ownership session drains the old leader and stops its consumers before its storage fence is released. A stuck process or an unavailable shared filesystem can therefore delay promotion; supervisors must stop the old process and verify retained work.
-Shutdown closes dispatch before capturing every execution consumer and its active effect tasks. If a consumer exceeds the native shutdown deadline or suppresses cancellation, the storage fence remains retained even when the PostgreSQL connection closes. The standby cannot recover that work while the old process can still produce effects. Promotion then requires actual process exit or an explicit stop after every retained consumer and effect task finishes. A timeout alone does not prove that execution drained.
+Shutdown closes dispatch before capturing every execution consumer and its active effect tasks. When several consumers detect the same lost lease, shutdown lets an effect already handling cancellation finish draining its process and recording its interrupted status; it does not cancel that cleanup again. If a consumer exceeds the native shutdown deadline or suppresses cancellation, the storage fence remains retained even when the PostgreSQL connection closes. The standby cannot recover that work while the old process can still produce effects. Promotion then requires actual process exit or an explicit stop after every retained consumer and effect task finishes. A timeout alone does not prove that execution drained.
`FORGE_CONCURRENCY` sets the active leader's coding/test-job budget, from 1 to 64 (default 2). The scheduler also admits at most one active job per repository. Standbys add availability, not execution capacity: two replicas still share one leader's budget. This is not an active distributed worker pool, and it does not provide independent storage replicas or deployment-target failover.
diff --git a/docs/scm.md b/docs/scm.md
index ce34b92e..3304a37e 100644
--- a/docs/scm.md
+++ b/docs/scm.md
@@ -409,7 +409,7 @@ Product changes accept at most 80,000 UTF-8 input bytes. Saved product revisions
Authenticated polling lets an execution-owning worker read GitHub issue comments through the configured App installation when GitHub cannot reach the factory. It uses the server-derived transport `github_rest_poll_v1`, without a webhook signature or personal OAuth sign-in. GitLab and Bitbucket continue to use their existing webhook paths.
-The polling workflow supports an existing run with an explicit project and component. `status` reads its current state; `respond` answers its exact pending worker question and leaves the run paused. Run creation, resume, cancellation, model/source changes, publication and merge are outside this transport. The [same-run resume grant](#opt-in-same-run-resume-v2) and [product-change capability](#change-an-existing-functional-product) require their separate signed-ingress contracts.
+The polling workflow supports an existing run with an explicit project and component. `status` reads its current state; `respond` answers its exact pending worker question and leaves the run paused. A [separate polling continuation consent](#continue-an-answered-clarification-through-polling) can authorize an explicit same-run clarification continuation. Run creation, cancellation, model/source changes, publication and merge remain outside this transport. Signed-ingress [resume grants](#opt-in-same-run-resume-v2) and [product-change capabilities](#change-an-existing-functional-product) cannot authorize polling actions.
@@ -567,7 +567,38 @@ After a timeout, 403, 409 or missing exact configuration/consent receipt, retain
For feedback, forge records `writing` before the provider request and `sent` only after a confirmed result. A lost reply, interrupted process or late authority/credential refusal can leave `feedback_status: needs_reconciliation`. Repeated ticks do not repost that write. Inspect the provider conversation, then use [explicit feedback reconciliation](repository-workflow.md#receipts-and-feedback) with `posted` or `not_posted` and an observed-outcome note. Reconciliation records `sent` or `not_sent` and sends no remote request. If no reply was posted, a new explicit status comment is the supported next request; replaying the unknown write is not.
-Current consent, native permission and provider access are checked before reading private feedback. After token preparation and the final local authority check, a synchronous check rejects any payload containing a currently registered credential before POST. Historical receipts are not rewritten. To revoke consent, submit a new reviewed request with `active: false` and the exact previous digest. Authorized historical reads remain available; the old receipt does not assert that authority is still active.
+Current consent, native permission and provider access are checked before reading private feedback. After preparing the issue token, forge repeats the exact GitHub comment and author-access checks, then checks native authority again. A changed comment or withdrawn access stops the POST and retains the unresolved feedback for review; repeated ticks do not resend it. A final synchronous check rejects any payload containing a currently registered credential. These checks cannot make GitHub revocation atomic with the local transaction. Historical receipts are not rewritten. To revoke consent, submit a new reviewed request with `active: false` and the exact previous digest. Authorized historical reads remain available; the old receipt does not assert that authority is still active.
+
+### Continue an answered clarification through polling
+
+In Studio, open **Project access**, read the current GitHub accounts and select an active mapped account. **Review GitHub continuation** opens the Mapping, Scope and Review steps. Acknowledge the selected account and components, then choose **Save permission** once. Saving records consent; it does not start execution. The [illustrated walkthrough](console-tour.md#review-github-continuation-consent) follows each step and exact-receipt recovery.
+
+Status and answer consent never grants continuation. A factory administrator must separately review a version 3 polling-resume grant under the exact current API-observed account binding, project cut, native user and components. Its transport is `github_rest_poll_v1`, its only action is `resume`, and `acknowledge_same_run_resume` must be explicitly `true`. The mapped user must retain execute access; the original execution actor must also remain authorized. Neither a historical signed V2 grant nor an administrator label in the comment substitutes for this consent.
+
+Use the separate CLI group to read current consent, submit one reviewed request and recover its exact result:
+
+```sh
+forge project scm-polling-resume-grants current PROJECT_ID REPOSITORY_ID POLL_BINDING_REQUEST_ID
+forge project scm-polling-resume-grants configure PROJECT_ID REPOSITORY_ID reviewed-consent.json
+forge project scm-polling-resume-grants get PROJECT_ID REPOSITORY_ID ORIGINAL_REQUEST_ID
+forge project scm-polling-resume-grants review PROJECT_ID REPOSITORY_ID POLL_BINDING_REQUEST_ID RUN_ID
+```
+
+The strict `SCMPollResumeGrantInputV3` body retains `request_id`, `expected_previous_digest`, `expected_project_binding_digest`, `expected_binding_request_id`, `expected_binding_digest`, `component_ids`, `allowed_actions: ["resume"]`, `active`, version, transport and acknowledgment. The endpoints are under `/api/v1/projects/{project}/repositories/{repository}/scm-polling/resume-grants`: POST saves consent; GET `/requests/{request_id}` reads its immutable original receipt; GET `/current/{binding_request_id}` reads the current head. GET `/review?binding_request_id=...&run_id=...` returns the current bounded clarification cut without writing a comment or resuming the run.
+
+Review requires the same paused run, a known answered clarification, its exact task attempt, original plan/source/configuration and a valid question/answer journal chain. Pending cancellation, publication, unknown command or sandbox effects, changed scope and lost authority refuse continuation. Provider failures and provider-recovery budgets are outside this path. Consent adds no run, provider substitution, repair budget or automatic retry.
+
+After reviewing that cut, publish one new issue comment using its exact values:
+
+```text
+/forge resume {"version":2,"run_id":"RUN_ID","expected_grant_digest":"GRANT_DIGEST","expected_question_id":"QUESTION_ID","expected_response_digest":"RESPONSE_DIGEST","expected_cut_digest":"CUT_DIGEST"}
+```
+
+The version 2 command keeps the existing typed continuation semantics; the distinct version 3 polling grant authorizes this transport. forge rechecks the observed comment, current account access, App/repository/connection cut, current binding and native permissions before native admission. After credential admission waits, it repeats the remote checks outside the transaction and checks local authority again under its transaction locks. Feedback authorization repeats these checks after credential admission, including the final check before the provider POST. Remote GitHub revocation cannot be atomic with those locks.
+
+A successful action records the native resume and its bound `scm_resume_receipt` once, retains the same execution contract and queues the original run. Queueing is not model execution or product acceptance. Reprocessing the same delivery verifies and returns its original completion without resuming again. Unknown intent or feedback outcomes remain held for explicit reconciliation; a lost response or missing receipt never permits a replacement request. To revoke consent, submit a new reviewed `active: false` grant with the exact previous digest. Historical receipts remain readable subject to current authority.
+
+The new grant history is separate from status/answer and signed-ingress consent. It permits 100 heads, 50 revisions per head, 5,000 requests, 64 KiB per receipt and 16 MiB of retained events. Native PostgreSQL/API tests use recorded GitHub HTTP and no coding model; they do not establish an actual account roundtrip, an executed continuation or a completed product.
### Polling limits and acceptance boundaries
diff --git a/docs/tooling-prerequisites.md b/docs/tooling-prerequisites.md
index c0529d6c..7d0f60a2 100644
--- a/docs/tooling-prerequisites.md
+++ b/docs/tooling-prerequisites.md
@@ -8,7 +8,7 @@ description: Check which tools a factory phase can use, prepare each provider's
Start with the task, then enable the tools it needs. A product conversation, an implementation worker and an application health probe have different permissions. Adding a provider to the catalog does not authorize an account, grant a project or prove that a tool call worked.
-The native agent registry has sixteen handlers. The factory's own MCP server has 157 operator tools for the authenticated API. Those operator tools manage factory resources; they are not automatically available inside an implementation worker.
+The native agent registry has sixteen handlers. The factory's own MCP server has 162 operator tools for the authenticated API. Those operator tools manage factory resources; they are not automatically available inside an implementation worker.
## Follow the phase boundary
diff --git a/pyproject.toml b/pyproject.toml
index 46fc2d07..c9429c47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "firefly-forge"
-version = "0.2.24"
+version = "0.2.26"
description = "Firefly software factory: brief to verified product with interchangeable coding agents"
readme = "README.md"
requires-python = ">=3.13"
diff --git a/scripts/build_architecture_diagrams.py b/scripts/build_architecture_diagrams.py
index 60415eb5..32f3ea24 100644
--- a/scripts/build_architecture_diagrams.py
+++ b/scripts/build_architecture_diagrams.py
@@ -29,7 +29,7 @@
"title": "Native services behind the factory",
"subtitle": "Clients submit contracts; the execution leader owns effects.",
"cards": [
- ("Operator surfaces", "Studio, CLI, ForgeClient and 157 factory MCP tools use authenticated contracts."),
+ ("Operator surfaces", "Studio, CLI, ForgeClient and 162 factory MCP tools use authenticated contracts."),
(
"Identity and project access",
"OperatorAuth validates identity. AccessRoutes checks the requested resource and operation.",
diff --git a/scripts/docs_scene_hook.py b/scripts/docs_scene_hook.py
index 10e3530e..a78fa655 100644
--- a/scripts/docs_scene_hook.py
+++ b/scripts/docs_scene_hook.py
@@ -30,6 +30,10 @@
"studio/tests/worker-dialogue-contract-fixture.json",
"studio/tests/agent-checkpoint-recovery-fixture.ts",
"studio/tests/run-creation-cancellation-fixture.ts",
+ "studio/tests/provider-activity-guide-fixture.json",
+ "studio/tests/scm-delegation-fixture.ts",
+ "studio/tests/scm-delegation-contract-fixture.json",
+ "studio/tests/scm-poll-resume-contract-fixture.json",
}
CAPTURE_PRODUCERS = {"studio/tests/docs-walkthrough.spec.ts", "studio/tests/external-tools.spec.ts"}
diff --git a/scripts/test_docs_scene_hook.py b/scripts/test_docs_scene_hook.py
index 7f442bbd..222d76f5 100644
--- a/scripts/test_docs_scene_hook.py
+++ b/scripts/test_docs_scene_hook.py
@@ -65,6 +65,10 @@ def test_shared_helper_config_and_each_producer_bind_the_exact_capture(self):
"studio/tests/worker-dialogue-contract-fixture.json": "fixture operator guide input",
"studio/tests/agent-checkpoint-recovery-fixture.ts": "fixture checkpoint recovery",
"studio/tests/run-creation-cancellation-fixture.ts": "fixture request cancellation",
+ "studio/tests/provider-activity-guide-fixture.json": "fixture Claude projection",
+ "studio/tests/scm-delegation-fixture.ts": "fixture SCM routes",
+ "studio/tests/scm-delegation-contract-fixture.json": "fixture SCM mapping",
+ "studio/tests/scm-poll-resume-contract-fixture.json": "fixture polling consent",
"studio/tests/docs-walkthrough.spec.ts": "fixture console producer",
"studio/tests/external-tools.spec.ts": "fixture MCP producer",
"studio/playwright.docs.config.ts": "fixture capture config",
@@ -104,6 +108,10 @@ def fingerprint(value):
"studio/tests/worker-dialogue-contract-fixture.json",
"studio/tests/agent-checkpoint-recovery-fixture.ts",
"studio/tests/run-creation-cancellation-fixture.ts",
+ "studio/tests/provider-activity-guide-fixture.json",
+ "studio/tests/scm-delegation-fixture.ts",
+ "studio/tests/scm-delegation-contract-fixture.json",
+ "studio/tests/scm-poll-resume-contract-fixture.json",
)
}
producers = {name: sha(root / name) for name in paths if name.endswith(".spec.ts")}
@@ -316,8 +324,13 @@ def test_rejects_a_changed_capture_or_frontend_source(self):
"studio/tests/worker-dialogue-contract-fixture.json",
"studio/tests/agent-checkpoint-recovery-fixture.ts",
"studio/tests/run-creation-cancellation-fixture.ts",
+ "studio/tests/provider-activity-guide-fixture.json",
+ "studio/tests/scm-delegation-fixture.ts",
+ "studio/tests/scm-delegation-contract-fixture.json",
+ "studio/tests/scm-poll-resume-contract-fixture.json",
]
for name in operator_inputs:
+ (root / name).parent.mkdir(parents=True, exist_ok=True)
(root / name).write_text("fixture operator guide input")
mcp_capture = root / "studio/tests/external-tools.spec.ts"
mcp_capture.write_text("fixture MCP producer")
diff --git a/src/firefly_forge/__init__.py b/src/firefly_forge/__init__.py
index 44ae5d24..35835951 100644
--- a/src/firefly_forge/__init__.py
+++ b/src/firefly_forge/__init__.py
@@ -1,4 +1,4 @@
# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
"""firefly forge software factory."""
-__version__ = "0.2.24"
+__version__ = "0.2.26"
diff --git a/src/firefly_forge/activity_claude.py b/src/firefly_forge/activity_claude.py
new file mode 100644
index 00000000..ed72988e
--- /dev/null
+++ b/src/firefly_forge/activity_claude.py
@@ -0,0 +1,131 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Recognize bounded Claude stream-json observations without inferring execution."""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from firefly_forge.activity_models import _preview
+
+Data = dict[str, Any]
+
+
+def _identifier(value: Any) -> str:
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", value):
+ raise ValueError("Invalid observed tool identity")
+ return value
+
+
+def _text_content(value: Any) -> str | None:
+ if isinstance(value, str):
+ return value
+ if (
+ isinstance(value, list)
+ and len(value) <= 64
+ and all(
+ isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str)
+ for part in value
+ )
+ ):
+ return "\n".join(part["text"] for part in value)
+ return None
+
+
+def _block(value: Any, role: str, limit: int) -> Data | None:
+ if not isinstance(value, dict):
+ raise ValueError("Invalid observed content block")
+ kind = value.get("type")
+ if kind == "text" and role == "assistant":
+ if not isinstance(value.get("text"), str):
+ raise ValueError("Invalid observed text")
+ return {"item_type": "agent_message", "output_preview": _preview(value["text"], limit)}
+ if kind == "tool_use" and role == "assistant":
+ name = value.get("name")
+ if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", name):
+ raise ValueError("Invalid observed tool name")
+ if not isinstance(value.get("input"), dict):
+ raise ValueError("Invalid observed tool input")
+ result: Data = {"item_type": "tool_use", "item_id": _identifier(value.get("id")), "tool_name": name}
+ command = value["input"].get("command")
+ if name == "Bash" and isinstance(command, str):
+ result["command_preview"] = _preview(command, limit)
+ return result
+ if kind == "tool_result" and role == "user":
+ if "is_error" in value and type(value["is_error"]) is not bool:
+ raise ValueError("Invalid observed tool error flag")
+ result = {
+ "item_type": "tool_result",
+ "item_id": _identifier(value.get("tool_use_id")),
+ "tool_outcome": "reported_error" if value.get("is_error") is True else "reported_result",
+ }
+ output = _text_content(value.get("content"))
+ if output is not None:
+ result["output_preview"] = _preview(output, limit)
+ return result
+ return None
+
+
+def claude_summary(event: Data) -> Data | None:
+ kind = event.get("type")
+ base: Data = {"provider": "claude", "preview_omitted": True}
+ if kind == "result":
+ error, subtype = event.get("is_error"), event.get("subtype")
+ if (
+ type(error) is not bool
+ or not isinstance(subtype, str)
+ or subtype
+ not in {
+ "success",
+ "error_during_execution",
+ "error_max_turns",
+ "error_max_budget_usd",
+ "error_max_structured_output_retries",
+ }
+ or (subtype == "success") == error
+ ):
+ return None
+ output = event.get("result")
+ if not isinstance(output, str):
+ errors = event.get("errors")
+ if (
+ not error
+ or not isinstance(errors, list)
+ or len(errors) > 64
+ or not all(isinstance(e, str) for e in errors)
+ ):
+ return None
+ output = "\n".join(errors)
+ return {
+ **base,
+ "event_type": "claude.result_error" if error else "claude.result",
+ "item_type": "agent_message",
+ "output_preview": _preview(output, 768),
+ }
+ if not isinstance(kind, str) or kind not in {"assistant", "user"}:
+ return None
+ message = event.get("message")
+ if not isinstance(message, dict) or message.get("role", kind) != kind:
+ return None
+ blocks = message.get("content")
+ if not isinstance(blocks, list) or not 1 <= len(blocks) <= 64:
+ return None
+ try:
+ if len(blocks) == 1:
+ block = _block(blocks[0], kind, 768)
+ return {**base, "event_type": "claude." + kind, **block} if block else None
+ projected = []
+ for block in blocks[:4]:
+ if selected := _block(block, kind, 128):
+ projected.append(selected)
+ if not projected:
+ return None
+ return {
+ **base,
+ "event_type": "claude." + kind,
+ "item_type": "provider_message",
+ "content_blocks": projected,
+ "omitted_blocks": len(blocks) - len(projected),
+ }
+ except ValueError:
+ return None
diff --git a/src/firefly_forge/activity_controllers.py b/src/firefly_forge/activity_controllers.py
new file mode 100644
index 00000000..1f52dbaa
--- /dev/null
+++ b/src/firefly_forge/activity_controllers.py
@@ -0,0 +1,42 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Native authenticated reads of exact sanitized activity chunks."""
+
+from pydantic import ValidationError
+from pyfly.container import rest_controller
+from pyfly.web import PathVar, QueryParam, get_mapping, request_mapping
+from starlette.responses import JSONResponse, Response
+
+from firefly_forge.activity_models import ActivityContentQuery, ActivityUnavailable
+from firefly_forge.activity_service import ActivityArchive
+
+
+@rest_controller
+@request_mapping("/api/v1/runs")
+class ActivityContentController:
+ def __init__(self, archive: ActivityArchive) -> None:
+ self.archive = archive
+
+ @get_mapping("/{id}/activity/{event_id}/content")
+ async def content(
+ self,
+ id: PathVar[str],
+ event_id: PathVar[str],
+ expected_descriptor: QueryParam[str],
+ chunk: QueryParam[int] = 0,
+ ) -> Response:
+ try:
+ query = ActivityContentQuery(expected_descriptor=expected_descriptor, chunk=chunk)
+ body = await self.archive.content(id, event_id, query)
+ return JSONResponse(body, headers={"Cache-Control": "private, no-store"})
+ except ValidationError:
+ return JSONResponse(
+ {"state": "unavailable", "reason": "activity_query_invalid"},
+ status_code=422,
+ headers={"Cache-Control": "no-store"},
+ )
+ except ActivityUnavailable as exc:
+ return JSONResponse(
+ {"state": "unavailable", "reason": str(exc)},
+ status_code=409,
+ headers={"Cache-Control": "private, no-store"},
+ )
diff --git a/src/firefly_forge/activity_models.py b/src/firefly_forge/activity_models.py
new file mode 100644
index 00000000..148bcf0a
--- /dev/null
+++ b/src/firefly_forge/activity_models.py
@@ -0,0 +1,237 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Bounded projections and exact sanitized provider-message content contracts."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+Data = dict[str, Any]
+MAX_EVENT_BYTES = 4_194_304
+MAX_RUN_BYTES = 67_108_864
+MAX_EVENTS = 4096
+MAX_INDEX_BYTES = 4096
+CHUNK_BYTES = 16384
+
+
+class ActivityUnavailable(ValueError):
+ """No activity content may accompany an unavailable observation."""
+
+
+def canonical(value: Any) -> bytes:
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode()
+
+
+class _Strict(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True, frozen=True, hide_input_in_errors=True)
+
+
+class ActivityDescriptor(_Strict):
+ version: Literal[1]
+ status: Literal["available", "budget_exceeded"]
+ run_id: str = Field(pattern=r"^[a-f0-9]{32}$")
+ event_id: str = Field(pattern=r"^[a-f0-9]{32}$")
+ stage: Literal["stdout", "stderr"]
+ encoding: Literal["utf-8"]
+ sanitized_message_bytes: int = Field(ge=0)
+ sanitized_message_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
+ descriptor_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
+
+ @field_validator("version", mode="before")
+ @classmethod
+ def exact_version(cls, value: Any) -> Any:
+ if type(value) is not int or value != 1:
+ raise ValueError("Unsupported activity content version")
+ return value
+
+
+class ActivityContentBlock(_Strict):
+ item_type: Literal["agent_message", "tool_use", "tool_result"]
+ item_id: str | None = Field(default=None, max_length=128)
+ tool_name: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
+ tool_outcome: Literal["reported_result", "reported_error"] | None = None
+ command_preview: str | None = None
+ output_preview: str | None = None
+
+ @field_validator("command_preview", "output_preview")
+ @classmethod
+ def preview_bytes(cls, value: str | None) -> str | None:
+ if value is not None and len(value.encode()) > 128:
+ raise ValueError("Activity block preview exceeds its byte budget")
+ return value
+
+
+class ActivitySummary(_Strict):
+ version: Literal[1] = 1
+ event_type: str = Field(max_length=64)
+ item_type: str | None = Field(default=None, max_length=64)
+ item_id: str | None = Field(default=None, max_length=128)
+ command_preview: str | None = None
+ output_preview: str | None = None
+ exit_code: int | None = Field(default=None, ge=-2147483648, le=2147483647)
+ provider: Literal["claude"] | None = None
+ tool_name: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
+ tool_outcome: Literal["reported_result", "reported_error"] | None = None
+ content_blocks: list[ActivityContentBlock] | None = Field(default=None, min_length=1, max_length=4)
+ omitted_blocks: int | None = Field(default=None, ge=0, le=64)
+ preview_omitted: bool
+ sanitized_message_bytes: int = Field(ge=0, le=MAX_EVENT_BYTES)
+ sanitized_message_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
+
+ @field_validator("command_preview", "output_preview")
+ @classmethod
+ def preview_bytes(cls, value: str | None) -> str | None:
+ if value is not None and len(value.encode()) > 768:
+ raise ValueError("Activity preview exceeds its byte budget")
+ return value
+
+
+class ActivityContentQuery(_Strict):
+ expected_descriptor: str = Field(pattern=r"^[a-f0-9]{64}$")
+ chunk: int = Field(default=0, ge=0, le=255)
+
+
+def _preview(value: str, limit: int) -> str:
+ result = value.encode("utf-8", errors="replace")[:limit].decode("utf-8", errors="ignore")
+ while len(canonical(result)) > limit + 2:
+ result = result[: len(result) // 2]
+ return result
+
+
+def _unique_object(items: list[tuple[str, Any]]) -> Data:
+ result: Data = {}
+ for key, value in items:
+ if key in result:
+ raise ValueError("Duplicate provider event field")
+ result[key] = value
+ return result
+
+
+def _summary(message: str, raw: bytes) -> Data | None:
+ if len(raw) > MAX_EVENT_BYTES:
+ return None
+ try:
+ event = json.loads(message, object_pairs_hook=_unique_object)
+ except (ValueError, RecursionError):
+ return None
+ if not isinstance(event, dict):
+ return None
+ if event.get("type") in ("assistant", "user", "result"):
+ from firefly_forge.activity_claude import claude_summary
+
+ claude_values = claude_summary(event)
+ if claude_values is None:
+ return None
+ claude_values.update(sanitized_message_bytes=len(raw), sanitized_message_sha256=hashlib.sha256(raw).hexdigest())
+ return ActivitySummary.model_validate(claude_values).model_dump(exclude_none=True)
+ event_type = event.get("type")
+ if not isinstance(event_type, str) or event_type not in {
+ "thread.started",
+ "turn.started",
+ "turn.completed",
+ "turn.failed",
+ "error",
+ "item.started",
+ "item.updated",
+ "item.completed",
+ }:
+ return None
+ item = event.get("item")
+ item = item if isinstance(item, dict) else {}
+ item_type = item.get("type")
+ if item_type is not None and (
+ not isinstance(item_type, str)
+ or item_type not in {"command_execution", "agent_message", "reasoning", "file_change"}
+ ):
+ return None
+ values: Data = {
+ "event_type": event_type,
+ "item_type": item_type,
+ "preview_omitted": _preview(message, 1024) != message,
+ "sanitized_message_bytes": len(raw),
+ "sanitized_message_sha256": hashlib.sha256(raw).hexdigest(),
+ }
+ if isinstance(item.get("id"), str):
+ values["item_id"] = _preview(item["id"], 128)
+ if item_type == "command_execution":
+ if isinstance(item.get("command"), str):
+ values["command_preview"] = _preview(item["command"], 768)
+ values["preview_omitted"] |= values["command_preview"] != item["command"]
+ if type(item.get("exit_code")) is int and -2147483648 <= item["exit_code"] <= 2147483647:
+ values["exit_code"] = item["exit_code"]
+ output = item.get("aggregated_output")
+ else:
+ output = item.get("text")
+ if isinstance(output, str):
+ values["output_preview"] = _preview(output, 768)
+ values["preview_omitted"] |= values["output_preview"] != output
+ summary = ActivitySummary.model_validate(values).model_dump(exclude_none=True)
+ summary["item_type"] = item_type
+ return summary
+
+
+def project_activity(
+ run_id: str, event_id: str, stage: str, message: str, *, retain: bool = True
+) -> tuple[Data, bytes | None]:
+ raw = message.encode("utf-8", errors="replace")
+ descriptor: Data = {
+ "version": 1,
+ "status": "available" if retain and len(raw) <= MAX_EVENT_BYTES else "budget_exceeded",
+ "run_id": run_id,
+ "event_id": event_id,
+ "stage": stage,
+ "encoding": "utf-8",
+ "sanitized_message_bytes": len(raw),
+ "sanitized_message_sha256": hashlib.sha256(raw).hexdigest(),
+ }
+ descriptor["descriptor_sha256"] = hashlib.sha256(canonical(descriptor)).hexdigest()
+ descriptor = ActivityDescriptor.model_validate(descriptor).model_dump()
+ result: Data = {"message": _preview(message, 1024), "activity_content": descriptor}
+ if summary := _summary(raw.decode(), raw):
+ result["activity_summary"] = summary
+ if len(canonical(result)) > MAX_INDEX_BYTES - 256:
+ raise ActivityUnavailable("activity_index_limit")
+ return result, raw if descriptor["status"] == "available" else None
+
+
+def validate_descriptor(value: Any) -> Data:
+ try:
+ descriptor = ActivityDescriptor.model_validate(value).model_dump()
+ except ValueError as exc:
+ raise ActivityUnavailable("activity_integrity") from exc
+ selected = {key: value for key, value in descriptor.items() if key != "descriptor_sha256"}
+ if hashlib.sha256(canonical(selected)).hexdigest() != descriptor["descriptor_sha256"]:
+ raise ActivityUnavailable("activity_integrity")
+ return descriptor
+
+
+def content_chunk(descriptor: Data, payload: bytes, query: ActivityContentQuery) -> Data:
+ descriptor = validate_descriptor(descriptor)
+ if descriptor["descriptor_sha256"] != query.expected_descriptor:
+ raise ActivityUnavailable("activity_cut_changed")
+ if descriptor["status"] != "available":
+ raise ActivityUnavailable("activity_budget_exceeded")
+ if (
+ len(payload) > MAX_EVENT_BYTES
+ or len(payload) != descriptor["sanitized_message_bytes"]
+ or hashlib.sha256(payload).hexdigest() != descriptor["sanitized_message_sha256"]
+ ):
+ raise ActivityUnavailable("activity_integrity")
+ offset = query.chunk * CHUNK_BYTES
+ if offset >= len(payload) and (payload or query.chunk):
+ raise ActivityUnavailable("activity_chunk_out_of_range")
+ selected = payload[offset : offset + CHUNK_BYTES]
+ return {
+ "version": 1,
+ "descriptor_sha256": descriptor["descriptor_sha256"],
+ "chunk": query.chunk,
+ "offset": offset,
+ "total_bytes": len(payload),
+ "sanitized_message_sha256": descriptor["sanitized_message_sha256"],
+ "data_base64": base64.b64encode(selected).decode("ascii"),
+ "next_chunk": query.chunk + 1 if offset + len(selected) < len(payload) else None,
+ }
diff --git a/src/firefly_forge/activity_persistence.py b/src/firefly_forge/activity_persistence.py
new file mode 100644
index 00000000..9733598c
--- /dev/null
+++ b/src/firefly_forge/activity_persistence.py
@@ -0,0 +1,71 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Private event payloads kept outside run and evidence JSON projections."""
+
+from uuid import UUID
+
+from pyfly.container import repository
+from pyfly.data.relational.sqlalchemy import BaseEntity, Repository
+from sqlalchemy import CheckConstraint, ForeignKey, LargeBinary, String, func, select
+from sqlalchemy.orm import Mapped, mapped_column
+
+from firefly_forge.activity_models import MAX_EVENT_BYTES, ActivityUnavailable
+
+
+class ForgeEventContentEntity(BaseEntity):
+ __tablename__ = "forge_event_content"
+ __table_args__ = (
+ CheckConstraint("version = 1"),
+ CheckConstraint("byte_count >= 0 AND byte_count <= 4194304"),
+ CheckConstraint("octet_length(raw_bytes) = byte_count"),
+ CheckConstraint("sha256 ~ '^[a-f0-9]{64}$'"),
+ )
+ event_id: Mapped[UUID] = mapped_column(ForeignKey("forge_events.id"), unique=True)
+ run_id: Mapped[UUID] = mapped_column(ForeignKey("forge_runs.id"), index=True)
+ version: Mapped[int] = mapped_column()
+ byte_count: Mapped[int] = mapped_column()
+ sha256: Mapped[str] = mapped_column(String(64))
+ raw_bytes: Mapped[bytes] = mapped_column(LargeBinary)
+
+
+@repository
+class ForgeEventContentRepository(Repository[ForgeEventContentEntity, UUID]):
+ async def budget(self, run_id: UUID) -> tuple[int, int]:
+ row = (
+ await self._require_session().execute(
+ select(
+ func.count(),
+ func.coalesce(func.sum(func.octet_length(ForgeEventContentEntity.raw_bytes)), 0),
+ ).where(ForgeEventContentEntity.run_id == run_id)
+ )
+ ).one()
+ return int(row[0]), int(row[1])
+
+ async def payload(self, run_id: UUID, event_id: UUID, size: int, sha256: str) -> bytes:
+ if type(size) is not int or not 0 <= size <= MAX_EVENT_BYTES:
+ raise ActivityUnavailable("activity_content_limit")
+ entity = ForgeEventContentEntity
+ predicates = (entity.run_id == run_id, entity.event_id == event_id)
+ session = self._require_session()
+ header = (
+ await session.execute(
+ select(entity.version, entity.byte_count, entity.sha256, func.octet_length(entity.raw_bytes)).where(
+ *predicates
+ )
+ )
+ ).one_or_none()
+ if header is None:
+ raise ActivityUnavailable("activity_content_missing")
+ if tuple(header) != (1, size, sha256, size):
+ raise ActivityUnavailable("activity_integrity")
+ payload = await session.scalar(
+ select(entity.raw_bytes).where(
+ *predicates,
+ entity.version == 1,
+ entity.byte_count == size,
+ entity.sha256 == sha256,
+ func.octet_length(entity.raw_bytes) == size,
+ )
+ )
+ if not isinstance(payload, bytes) or len(payload) != size:
+ raise ActivityUnavailable("activity_integrity")
+ return payload
diff --git a/src/firefly_forge/activity_service.py b/src/firefly_forge/activity_service.py
new file mode 100644
index 00000000..ed0ef24d
--- /dev/null
+++ b/src/firefly_forge/activity_service.py
@@ -0,0 +1,233 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Exact, currently authorized reads of observed sanitized activity content."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+from dataclasses import dataclass
+from typing import Any
+
+from pyfly.container import service
+from pyfly.data.transaction import (
+ Isolation,
+ Propagation,
+ TransactionTemplate,
+ TransactionTimedOutError,
+ current_unit_of_work,
+)
+from sqlalchemy import func, select
+
+from firefly_forge.activity_models import (
+ MAX_INDEX_BYTES,
+ ActivityContentQuery,
+ ActivitySummary,
+ ActivityUnavailable,
+ canonical,
+ content_chunk,
+ validate_descriptor,
+)
+from firefly_forge.activity_persistence import ForgeEventContentRepository
+from firefly_forge.journal import digest
+from firefly_forge.persistence import ForgeEventEntity, ForgePlanEntity, ForgeRunEntity
+from firefly_forge.project_access import CURRENT_PRINCIPAL, AccessDenied
+from firefly_forge.project_models import ProjectDefinition
+from firefly_forge.project_persistence import ForgeProjectRevisionEntity
+from firefly_forge.run_evidence import RunEvidence
+from firefly_forge.store import Store, identifier
+
+Data = dict[str, Any]
+
+
+@dataclass(frozen=True)
+class ActivityObservation:
+ run: Data
+ repositories: frozenset[str]
+ event: Data
+ scope_digest: str
+
+
+@service
+class ActivityArchive:
+ def __init__(
+ self, store: Store, records: ForgeEventContentRepository | None = None, evidence: RunEvidence | None = None
+ ) -> None:
+ self.store = store
+ self.records = records or getattr(store, "event_content_records", None) or ForgeEventContentRepository()
+ self.evidence = evidence or RunEvidence(store)
+
+ async def _header(self, run_id: str) -> Data:
+ async with self.store._unit() as session:
+ header = (
+ await session.execute(
+ select(
+ ForgeRunEntity.id,
+ ForgeRunEntity.repository_id,
+ func.left(ForgeRunEntity.data["project_id"].as_string(), 33),
+ func.left(ForgeRunEntity.data["component_id"].as_string(), 101),
+ ).where(ForgeRunEntity.id == identifier(run_id))
+ )
+ ).one_or_none()
+ if header is None:
+ raise KeyError("Run not found")
+ return {
+ "id": header[0].hex,
+ "repository_id": header[1].hex,
+ "project_id": header[2],
+ "component_id": header[3],
+ }
+
+ async def _capture(self, run_id: str, event_id: str) -> ActivityObservation:
+ try:
+ async with self.store._unit():
+ unit = current_unit_of_work()
+ assert unit is not None
+ manager = unit.manager
+ async with (
+ TransactionTemplate(
+ manager,
+ propagation=Propagation.REQUIRES_NEW,
+ isolation=Isolation.REPEATABLE_READ,
+ read_only=True,
+ timeout=10,
+ ).transaction(),
+ self.store._unit() as session,
+ ):
+ header = await self._header(run_id)
+ remaining = [1048576]
+ plans = await self.evidence._rows(
+ session,
+ select(ForgePlanEntity)
+ .where(ForgePlanEntity.run_id == identifier(run_id))
+ .order_by(ForgePlanEntity.revision.desc())
+ .limit(1),
+ 1,
+ remaining,
+ )
+ repositories = {header["repository_id"]}
+ plan_digest = None
+ if plans:
+ plan = plans[0]
+ if (
+ digest(plan.data) != plan.digest
+ or plan.data.get("run_id") != run_id
+ or plan.data.get("repository_id") != header["repository_id"]
+ or plan.data.get("project_id") != header["project_id"]
+ or plan.data.get("component_id") != header["component_id"]
+ ):
+ raise ActivityUnavailable("activity_integrity")
+ plan_digest = plan.digest
+ if header["project_id"]:
+ if not plans:
+ raise ActivityUnavailable("activity_scope_unavailable")
+ component = plans[0].data.get("component_snapshot") or {}
+ definition = component.get("project_definition") or {}
+ ProjectDefinition.model_validate(definition)
+ revisions = await self.evidence._rows(
+ session,
+ select(ForgeProjectRevisionEntity)
+ .where(
+ ForgeProjectRevisionEntity.project_id == identifier(header["project_id"]),
+ ForgeProjectRevisionEntity.revision == component.get("project_revision"),
+ )
+ .limit(1),
+ 1,
+ remaining,
+ )
+ expected = hashlib.sha256(json.dumps(definition, sort_keys=True).encode()).hexdigest()
+ selected = next((c for c in definition["components"] if c["id"] == header["component_id"]), None)
+ if (
+ len(revisions) != 1
+ or revisions[0].data.get("definition") != definition
+ or revisions[0].data.get("digest") != expected
+ or component.get("project_digest") != expected
+ or not selected
+ or selected["repository_id"] != header["repository_id"]
+ ):
+ raise ActivityUnavailable("activity_integrity")
+ repositories.update(identifier(c["repository_id"]).hex for c in definition["components"])
+ repositories.add(identifier(definition["product_repository_id"]).hex)
+ rows = await self.evidence._rows(
+ session,
+ select(ForgeEventEntity).where(
+ ForgeEventEntity.id == identifier(event_id), ForgeEventEntity.run_id == identifier(run_id)
+ ),
+ 1,
+ [MAX_INDEX_BYTES],
+ )
+ if not rows:
+ raise KeyError("Activity event not found in this run")
+ event = copy.deepcopy(rows[0].data)
+ if not isinstance(event, dict) or event.get("id") != event_id:
+ raise ActivityUnavailable("activity_integrity")
+ descriptor = validate_descriptor(event.get("activity_content"))
+ if (descriptor["run_id"], descriptor["event_id"], descriptor["stage"]) != (
+ run_id,
+ event_id,
+ event.get("stage"),
+ ):
+ raise ActivityUnavailable("activity_integrity")
+ if "activity_summary" in event:
+ summary = ActivitySummary.model_validate(event["activity_summary"])
+ if (
+ type(event["activity_summary"].get("version")) is not int
+ or summary.sanitized_message_bytes != descriptor["sanitized_message_bytes"]
+ or summary.sanitized_message_sha256 != descriptor["sanitized_message_sha256"]
+ ):
+ raise ActivityUnavailable("activity_integrity")
+ scope = {
+ "run": header,
+ "repositories": sorted(repositories),
+ "plan_digest": plan_digest,
+ "event": event,
+ }
+ return ActivityObservation(
+ header, frozenset(repositories), event, hashlib.sha256(canonical(scope)).hexdigest()
+ )
+ except ActivityUnavailable:
+ raise
+ except TransactionTimedOutError as exc:
+ raise ActivityUnavailable("activity_snapshot_timeout") from exc
+ except (ValueError, TypeError) as exc:
+ raise ActivityUnavailable("activity_integrity") from exc
+
+ async def content(self, run_id: str, event_id: str, query: ActivityContentQuery) -> Data:
+ if current_unit_of_work() is not None:
+ raise ActivityUnavailable("activity_caller_transaction")
+ run_id, event_id = identifier(run_id).hex, identifier(event_id).hex
+ reader = CURRENT_PRINCIPAL.get()
+ if reader is None:
+ raise AccessDenied("Activity content requires a current native reader")
+ header = await self._header(run_id)
+ await self.evidence._authorize(header, frozenset({header["repository_id"]}), reader)
+ observation = await self._capture(run_id, event_id)
+ await self.evidence._authorize(observation.run, observation.repositories, reader)
+ descriptor = validate_descriptor(observation.event.get("activity_content"))
+ if descriptor["descriptor_sha256"] != query.expected_descriptor:
+ raise ActivityUnavailable("activity_cut_changed")
+ if descriptor["status"] != "available":
+ raise ActivityUnavailable("activity_budget_exceeded")
+ async with self.store._unit():
+ payload = await self.records.payload(
+ identifier(run_id),
+ identifier(event_id),
+ descriptor["sanitized_message_bytes"],
+ descriptor["sanitized_message_sha256"],
+ )
+ result = content_chunk(descriptor, payload, query)
+ try:
+ text = payload.decode("utf-8")
+ await self.store.admit_credentials(text)
+ except ValueError as exc:
+ raise ActivityUnavailable("activity_credential_admission") from exc
+ await self.evidence._authorize(observation.run, observation.repositories, reader)
+ current = await self._capture(run_id, event_id)
+ if current.scope_digest != observation.scope_digest:
+ raise ActivityUnavailable("activity_cut_changed")
+ await self.evidence._authorize(current.run, current.repositories, reader)
+ try:
+ self.store.reject_credentials(text)
+ except ValueError as exc:
+ raise ActivityUnavailable("activity_credential_admission") from exc
+ return result
diff --git a/src/firefly_forge/api.py b/src/firefly_forge/api.py
index c5924595..79e1845b 100644
--- a/src/firefly_forge/api.py
+++ b/src/firefly_forge/api.py
@@ -28,6 +28,9 @@
from firefly_forge.access_controllers import AccessController
from firefly_forge.access_persistence import ForgeAccessEventRepository, ForgeMembershipRepository
from firefly_forge.access_routes import AccessRoutes
+from firefly_forge.activity_controllers import ActivityContentController
+from firefly_forge.activity_persistence import ForgeEventContentRepository
+from firefly_forge.activity_service import ActivityArchive
from firefly_forge.agent_checkpoints import AgentCheckpoints
from firefly_forge.agent_restore_controllers import AgentCheckpointCandidateController, AgentRestoreController
from firefly_forge.agent_restores import AgentRestores
@@ -199,6 +202,8 @@
from firefly_forge.scm_actor_authority_controllers import SCMActorAuthorityController
from firefly_forge.scm_connections import SCMConnections
from firefly_forge.scm_poll_controllers import SCMPollController
+from firefly_forge.scm_poll_resume import SCMPollResumeAuthority
+from firefly_forge.scm_poll_resume_controllers import SCMPollResumeController
from firefly_forge.scm_polling import SCMPolling
from firefly_forge.scm_product_change_controllers import SCMProductChangeController
from firefly_forge.scm_product_change_service import SCMProductChangeAuthority
@@ -542,6 +547,7 @@ def ledger(
repository_records: ForgeRepositoryRepository,
run_records: ForgeRunRepository,
event_records: ForgeEventRepository,
+ event_content_records: ForgeEventContentRepository,
deployment_records: ForgeDeploymentRepository,
product_records: ForgeProductDefinitionRepository,
campaign_records: ForgeCampaignRepository,
@@ -554,6 +560,7 @@ def ledger(
repository_records=repository_records,
run_records=run_records,
event_records=event_records,
+ event_content_records=event_content_records,
deployment_records=deployment_records,
product_records=product_records,
campaign_records=campaign_records,
@@ -725,6 +732,21 @@ def scm_resume_authority(
) -> SCMResumeAuthority:
return SCMResumeAuthority(store, authority, journal, settings_records, inbox, events)
+ @bean
+ def scm_poll_resume_authority(
+ self,
+ store: Store,
+ authority: SCMActorAuthority,
+ journal: Journal,
+ settings_records: ForgeCollaborationSettingsRepository,
+ inbox: ForgeWebhookInboxRepository,
+ events: ForgeAccessEventRepository,
+ polling: SCMPolling,
+ ) -> SCMPollResumeAuthority:
+ service = SCMPollResumeAuthority(store, authority, journal, settings_records, inbox, events)
+ service.polling = polling
+ return service
+
@bean
def scm_product_change_authority(
self,
@@ -889,6 +911,12 @@ def project_campaigns(
def run_evidence(self, store: Store) -> RunEvidence:
return RunEvidence(store)
+ @bean
+ def activity_archive(
+ self, store: Store, records: ForgeEventContentRepository, evidence: RunEvidence
+ ) -> ActivityArchive:
+ return ActivityArchive(store, records, evidence)
+
@bean
def campaign_reports(self, store: Store, campaigns: ProjectCampaigns, journal: Journal) -> CampaignReports:
return CampaignReports(store, campaigns, journal)
@@ -982,6 +1010,7 @@ def collaboration_manager(
analysis: ReverseEngineering,
assistant: ProductAssistant,
resume_authority: SCMResumeAuthority,
+ poll_resume_authority: SCMPollResumeAuthority,
product_change_authority: SCMProductChangeAuthority,
intake: SCMProductIntake,
polling: SCMPolling,
@@ -997,6 +1026,7 @@ def collaboration_manager(
product_change_authority,
intake,
)
+ collaboration.poll_resume_authority = poll_resume_authority
collaboration.polling = polling
polling.collaboration = collaboration
collaboration.analysis_manager = analysis
@@ -1089,6 +1119,7 @@ class Application:
ForgeRepositoryRepository,
ForgeRunRepository,
ForgeEventRepository,
+ ForgeEventContentRepository,
ForgeDeploymentRepository,
ForgePlanRepository,
ForgeTaskRepository,
@@ -1118,12 +1149,14 @@ class Application:
ProviderRecoveryController,
BridgeCompletionController,
RunEvidenceController,
+ ActivityContentController,
ForgeCollaborationSettingsRepository,
ForgeWebhookInboxRepository,
ForgeProductDefinitionRepository,
CollaborationController,
SCMActorAuthorityController,
SCMResumeAuthorityController,
+ SCMPollResumeController,
SCMPollController,
SCMProductChangeController,
SCMProductIntakeController,
diff --git a/src/firefly_forge/cli.py b/src/firefly_forge/cli.py
index 7091c6de..7a34b365 100644
--- a/src/firefly_forge/cli.py
+++ b/src/firefly_forge/cli.py
@@ -550,6 +550,52 @@ def get_current_scm_resume_grant(project_id: str, repository_id: str, binding_re
display(connection.current_scm_resume_grant(project_id, repository_id, binding_request_id))
+@project.group("scm-polling-resume-grants")
+def scm_polling_resume_grants() -> None:
+ """Review and save explicit consent for API-observed GitHub same-run resume requests."""
+
+
+@scm_polling_resume_grants.command("configure")
+@click.argument("project_id")
+@click.argument("repository_id")
+@click.argument("document", type=click.Path(exists=True, dir_okay=False, path_type=Path))
+def configure_scm_polling_resume_grant(project_id: str, repository_id: str, document: Path) -> None:
+ """Submit one reviewed consent JSON; retain its request ID after an uncertain response."""
+ with client() as connection:
+ display(connection.configure_scm_polling_resume_grant(project_id, repository_id, json_document(document)))
+
+
+@scm_polling_resume_grants.command("get")
+@click.argument("project_id")
+@click.argument("repository_id")
+@click.argument("request_id")
+def get_scm_polling_resume_grant(project_id: str, repository_id: str, request_id: str) -> None:
+ """Read the original consent receipt without resending or replacing its request."""
+ with client() as connection:
+ display(connection.scm_polling_resume_grant(project_id, repository_id, request_id))
+
+
+@scm_polling_resume_grants.command("current")
+@click.argument("project_id")
+@click.argument("repository_id")
+@click.argument("binding_request_id")
+def get_current_scm_polling_resume_grant(project_id: str, repository_id: str, binding_request_id: str) -> None:
+ """Read current consent for an exact polling binding; this does not resume a run."""
+ with client() as connection:
+ display(connection.current_scm_polling_resume_grant(project_id, repository_id, binding_request_id))
+
+
+@scm_polling_resume_grants.command("review")
+@click.argument("project_id")
+@click.argument("repository_id")
+@click.argument("binding_request_id")
+@click.argument("run_id")
+def review_scm_polling_resume(project_id: str, repository_id: str, binding_request_id: str, run_id: str) -> None:
+ """Read the exact current answered clarification cut without resuming it."""
+ with client() as connection:
+ display(connection.review_scm_polling_resume(project_id, repository_id, binding_request_id, run_id))
+
+
@project.group("scm-product-change-grants")
def scm_product_change_grants() -> None:
"""Review and save explicit consent for signed whole-product edits."""
@@ -1866,3 +1912,18 @@ def export_run_evidence(run_id: str, expected_cut: str, output: Path) -> None:
with os.fdopen(descriptor, "wb") as target:
target.write(content)
click.echo(str(output))
+
+
+@evidence.command("activity")
+@click.argument("run_id")
+@click.argument("event_id")
+@click.option("--expected-descriptor", required=True)
+@click.option("--chunk", type=click.IntRange(0, 255), default=0)
+def read_activity_content(run_id: str, event_id: str, expected_descriptor: str, chunk: int) -> None:
+ """Read one verified fragment of the complete sanitized provider message."""
+ with client() as connection:
+ display(
+ connection.run_activity_content(
+ run_id, event_id, {"expected_descriptor": expected_descriptor, "chunk": chunk}
+ )
+ )
diff --git a/src/firefly_forge/client.py b/src/firefly_forge/client.py
index d779b368..bf21ec1c 100644
--- a/src/firefly_forge/client.py
+++ b/src/firefly_forge/client.py
@@ -32,6 +32,7 @@
from firefly_forge.runner_continuations import RunnerContinuationInput
from firefly_forge.sandbox_models import SandboxInput
from firefly_forge.scm_authority_models import SCMActorBindingInputV1
+ from firefly_forge.scm_poll_resume_models import SCMPollResumeGrantInputV3
from firefly_forge.scm_product_change_models import SCMProductChangeGrantInputV1
from firefly_forge.scm_product_intake_models import SCMProductIntakeConsentInputV1
from firefly_forge.scm_resume_models import SCMResumeGrantInputV2
@@ -510,6 +511,55 @@ def current_scm_resume_grant(self, project_id: str, repository_id: str, binding_
path = self._scm_resume_grant_path(project_id, repository_id)
return dict(self.request("GET", path + "/current/" + self._scm_actor_identifier(binding_request_id)))
+ def _scm_poll_resume_grant_path(self, project_id: str, repository_id: str) -> str:
+ project = self._scm_actor_identifier(project_id)
+ repository = self._scm_actor_identifier(repository_id)
+ return f"projects/{project}/repositories/{repository}/scm-polling/resume-grants"
+
+ def configure_scm_polling_resume_grant(
+ self, project_id: str, repository_id: str, request: SCMPollResumeGrantInputV3 | dict[str, Any]
+ ) -> dict[str, Any]:
+ """Submit one explicit V3 polling consent; preserve its request identity after an uncertain result."""
+ from firefly_forge.scm_poll_resume_models import SCMPollResumeGrantInputV3
+
+ path = self._scm_poll_resume_grant_path(project_id, repository_id)
+ try:
+ value = request.model_dump(warnings=False) if isinstance(request, SCMPollResumeGrantInputV3) else request
+ body = SCMPollResumeGrantInputV3.model_validate(value)
+ except ValueError:
+ raise ValueError(
+ "Use a bounded V3 polling resume consent with exact reviewed scope and request identities"
+ ) from None
+ return dict(self.request("POST", path, json=body.model_dump(mode="json")))
+
+ def scm_polling_resume_grant(self, project_id: str, repository_id: str, request_id: str) -> dict[str, Any]:
+ """Read the exact retained consent; missing evidence never authorizes another write."""
+ path = self._scm_poll_resume_grant_path(project_id, repository_id)
+ return dict(self.request("GET", path + "/requests/" + self._scm_actor_identifier(request_id)))
+
+ def current_scm_polling_resume_grant(
+ self, project_id: str, repository_id: str, binding_request_id: str
+ ) -> dict[str, Any]:
+ """Read the current consent for an exact API-observed polling binding without dispatching a run action."""
+ path = self._scm_poll_resume_grant_path(project_id, repository_id)
+ return dict(self.request("GET", path + "/current/" + self._scm_actor_identifier(binding_request_id)))
+
+ def review_scm_polling_resume(
+ self, project_id: str, repository_id: str, binding_request_id: str, run_id: str
+ ) -> dict[str, Any]:
+ """Read the current same-run clarification cut; do not dispatch a command."""
+ path = self._scm_poll_resume_grant_path(project_id, repository_id)
+ return dict(
+ self.request(
+ "GET",
+ path + "/review",
+ params={
+ "binding_request_id": self._scm_actor_identifier(binding_request_id),
+ "run_id": self._scm_actor_identifier(run_id),
+ },
+ )
+ )
+
def _scm_product_change_grant_path(self, project_id: str, repository_id: str) -> str:
project = self._scm_actor_identifier(project_id)
repository = self._scm_actor_identifier(repository_id)
@@ -960,6 +1010,18 @@ def export_run_evidence(self, identifier: str, expected_cut: str) -> bytes:
response.raise_for_status()
return response.content
+ def run_activity_content(self, run_id: str, event_id: str, query: dict[str, Any]) -> dict[str, Any]:
+ from firefly_forge.activity_models import ActivityContentQuery
+
+ selected = ActivityContentQuery.model_validate(query)
+ return dict(
+ self.request(
+ "GET",
+ f"runs/{self._id(run_id)}/activity/{self._id(event_id)}/content",
+ params=selected.model_dump(),
+ )
+ )
+
def export(self, identifier: str) -> bytes:
response = self.http.get(f"runs/{identifier}/artifacts/changes.zip")
response.raise_for_status()
diff --git a/src/firefly_forge/collaboration.py b/src/firefly_forge/collaboration.py
index f312f147..a7d29199 100644
--- a/src/firefly_forge/collaboration.py
+++ b/src/firefly_forge/collaboration.py
@@ -314,6 +314,7 @@ def __init__(
self.store = store
self.polling: Any = None
self.resume_authority = resume_authority
+ self.poll_resume_authority: Any = None
self.product_change_authority: SCMProductChangeAuthority | None = product_change_authority
self.product_intake_authority: SCMProductIntake | None = product_intake_authority
self.scm = scm or SCMConnections(store)
@@ -355,6 +356,7 @@ async def settings(self, repository_id: str) -> Data:
data.pop("_github_poll_v1", None)
data.pop("_scm_authority_v1", None)
data.pop("_scm_resume_authority_v2", None)
+ data.pop("_scm_poll_resume_v3", None)
data.pop("_scm_product_change_authority_v1", None)
data.pop("_scm_product_intake_v1", None)
return {
@@ -404,6 +406,7 @@ async def configure(self, repository_id: str, request: CollaborationSettings) ->
for key in (
"_scm_authority_v1",
"_scm_resume_authority_v2",
+ "_scm_poll_resume_v3",
"_scm_product_change_authority_v1",
"_scm_product_intake_v1",
"_github_poll_v1",
@@ -876,10 +879,15 @@ async def process(self, receipt_id: str) -> Data:
return {"id": receipt_id, "status": "denied", "reason": "scm_product_edit_setup_or_conflict"}
return {"id": receipt_id, "status": "withheld", "reason": "scm_product_edit_result_unconfirmed"}
if (initial.get("command") or {}).get("version") == 2:
- if self.resume_authority is None:
+ resume_authority = (
+ self.poll_resume_authority
+ if (initial.get("verification") or {}).get("transport") == "github_rest_poll_v1"
+ else self.resume_authority
+ )
+ if resume_authority is None:
raise ValueError("The native V2 resume service is unavailable")
try:
- await self.resume_authority.process(receipt_id)
+ await resume_authority.process(receipt_id)
except Exception:
async with self.store._unit():
row = await self.inbox.find_by_id(identifier(receipt_id), lock=LockMode.PESSIMISTIC_WRITE)
@@ -993,6 +1001,7 @@ async def process(self, receipt_id: str) -> Data:
"_github_poll_v1",
"_scm_authority_v1",
"_scm_resume_authority_v2",
+ "_scm_poll_resume_v3",
"_scm_product_change_authority_v1",
"_scm_product_intake_v1",
):
@@ -1447,6 +1456,11 @@ async def _feedback_authority(self, data: Data, *, local: bool = False) -> None:
await self.product_intake_authority.authorize_feedback(data["id"], remote=not local)
return
if (data.get("verification") or {}).get("transport") == "github_rest_poll_v1":
+ if command.get("version") == 2:
+ if self.poll_resume_authority is None:
+ raise AccessDenied("The polling continuation feedback authority is unavailable")
+ await self.poll_resume_authority.authorize_feedback(data["id"])
+ return
if self.polling is None:
raise AccessDenied("The polling feedback authority is unavailable")
if local:
@@ -1539,7 +1553,7 @@ async def feedback(self, receipt_id: str) -> None:
try:
repo = await self.store.repository(data["repository_id"])
run = await self.store.get_run(data["run_id"]) if data.get("run_id") else {}
- from firefly_forge.collaboration_feedback import feedback_card, milestone_key, review_key
+ from firefly_forge.collaboration_feedback import feedback_card, milestone_key, provider_recovery, review_key
from firefly_forge.configuration import redact_secrets
from firefly_forge.secrets import redact_environment
@@ -1565,6 +1579,8 @@ def public_text(value: str) -> str:
run.get("publish") and not run.get("pr_url") and not run.get("publication_error")
),
}
+ if recovery := provider_recovery(run):
+ intent["provider_recovery"] = recovery
async with self.store._unit():
row = await self.inbox.find_by_id(identifier(receipt_id), lock=LockMode.PESSIMISTIC_WRITE)
assert row is not None
@@ -1572,7 +1588,9 @@ def public_text(value: str) -> str:
payload = {"content": {"raw": message}} if data["provider"] == "bitbucket" else {"body": message}
async def before_write() -> None:
- await self._feedback_authority(data, local=True)
+ await self._feedback_authority(
+ data, local=(data.get("verification") or {}).get("transport") != "github_rest_poll_v1"
+ )
self.store.reject_credentials(public_values)
remote = await self._remote_write(repo, payload, data["issue"], before_write=before_write)
diff --git a/src/firefly_forge/collaboration_feedback.py b/src/firefly_forge/collaboration_feedback.py
index abb93641..1469dd5b 100644
--- a/src/firefly_forge/collaboration_feedback.py
+++ b/src/firefly_forge/collaboration_feedback.py
@@ -141,11 +141,26 @@ def review_key(run: Data) -> str | None:
return hashlib.sha256(str(value).encode()).hexdigest() if value else None
+def provider_recovery(run: Data) -> Data:
+ value = run.get("provider_recovery")
+ if (
+ not isinstance(value, dict)
+ or not isinstance(value.get("request_id"), str)
+ or not re.fullmatch(r"[a-f0-9]{32}", value["request_id"])
+ or type(value.get("attempt")) is not int
+ or not 1 <= value["attempt"] <= 100
+ or not isinstance(value.get("status"), str)
+ or value.get("status") not in {"reserved", "consumed", "completed"}
+ ):
+ return {}
+ return {"request_id": value["request_id"], "attempt": value["attempt"]}
+
+
def milestone(run: Data) -> Data:
"""Use durable run fields, never provider output messages or wall-clock ticks."""
request = run.get("worker_request") or {}
checks = run.get("final_verification") or []
- return {
+ result = {
"status": run.get("status"),
"task": run.get("active_task") if run.get("active_task") in STAGES else None,
"request": request.get("id") if request.get("status") == "pending" else None,
@@ -156,6 +171,9 @@ def milestone(run: Data) -> Data:
"handoff": verification_handoff(run),
"verification_refusal": verification_refusal(run),
}
+ if recovery := provider_recovery(run):
+ result["provider_recovery"] = recovery
+ return result
def milestone_key(run: Data) -> str:
@@ -185,9 +203,13 @@ def feedback_due(data: Data, run: Data) -> bool:
if data.get("feedback_key"):
return bool(data["feedback_key"] != milestone_key(run))
# Existing receipts adopt fingerprints only on a real transition, without reposting on upgrade.
+ recovery, retained = provider_recovery(run), data.get("feedback_intent")
+ reported = retained.get("provider_recovery") if isinstance(retained, dict) else None
return bool(
run.get("status") != data.get("feedback_stage")
or ((run.get("worker_request") or {}).get("id") != data.get("feedback_request_id"))
+ or recovery
+ and recovery != reported
)
@@ -211,6 +233,15 @@ def feedback_card(data: Data, run: Data, repository: Data, host: str, safe: Call
if stage in active_stages.get(status, set()):
title, description = copy["stage_titles"][stage], copy["stages"][stage]
sections, details = [], []
+ recovery = provider_recovery(run)
+ if recovery:
+ sections.append(
+ prompt_text(
+ "collaboration/provider-recovery",
+ attempt=str(recovery["attempt"]),
+ request_id=_identifier(recovery["request_id"], safe),
+ )
+ )
if result:
details.append(
prompt_text(
@@ -286,6 +317,22 @@ def feedback_card(data: Data, run: Data, repository: Data, host: str, safe: Call
if checks:
passed = sum(type(check.get("exit_code")) is int and check["exit_code"] == 0 for check in checks)
sections.append(prompt_text("collaboration/verification", passed=str(passed), total=str(len(checks))))
+ if recovery:
+ attempts = {
+ check["attempt"]
+ for check in checks
+ if type(check.get("attempt")) is int and 1 <= check["attempt"] <= 100
+ }
+ complete = all(type(check.get("attempt")) is int and 1 <= check["attempt"] <= 100 for check in checks)
+ provenance = "unknown" if not complete else "current" if attempts == {recovery["attempt"]} else "retained"
+ sections.append(
+ prompt_text(
+ "collaboration/verification-provenance",
+ attempts=", ".join(str(attempt) for attempt in sorted(attempts))
+ or copy["check_provenance_unknown"],
+ explanation=copy["check_provenance"][provenance],
+ )
+ )
url = _link(run.get("pr_url"), host, repository)
if url:
safe(url)
diff --git a/src/firefly_forge/collaboration_persistence.py b/src/firefly_forge/collaboration_persistence.py
index 6e43ad0a..d72f18ff 100644
--- a/src/firefly_forge/collaboration_persistence.py
+++ b/src/firefly_forge/collaboration_persistence.py
@@ -8,10 +8,12 @@
from pyfly.container import repository
from pyfly.data.relational.sqlalchemy import BaseEntity, Repository
-from sqlalchemy import BigInteger, ForeignKey, Identity, String, UniqueConstraint, and_, case, or_, select
+from sqlalchemy import BigInteger, ForeignKey, Identity, String, UniqueConstraint, and_, case, func, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
+from firefly_forge.persistence import ForgeRunEntity
+
class ForgeCollaborationSettingsEntity(BaseEntity):
__tablename__ = "forge_collaboration_settings"
@@ -130,6 +132,25 @@ async def feedback_context(self, data: dict[str, Any]) -> list[ForgeWebhookInbox
)
async def find_pending(self) -> list[ForgeWebhookInboxEntity]:
+ recovery = ForgeRunEntity.data["provider_recovery"]
+ reported = ForgeWebhookInboxEntity.data["feedback_intent"]["provider_recovery"]
+ unreported_recovery = (
+ select(ForgeRunEntity.id)
+ .where(
+ ForgeRunEntity.repository_id == ForgeWebhookInboxEntity.repository_id,
+ ForgeRunEntity.data["id"].as_string() == ForgeWebhookInboxEntity.data["run_id"].as_string(),
+ func.jsonb_typeof(recovery["request_id"]) == "string",
+ recovery["request_id"].as_string().op("~")(r"^[a-f0-9]{32}$"),
+ func.jsonb_typeof(recovery["attempt"]) == "number",
+ recovery["attempt"].as_string().op("~")(r"^([1-9][0-9]?|100)$"),
+ recovery["status"].as_string().in_(["reserved", "consumed", "completed"]),
+ or_(
+ recovery["request_id"].as_string().is_distinct_from(reported["request_id"].as_string()),
+ recovery["attempt"].as_string().is_distinct_from(reported["attempt"].as_string()),
+ ),
+ )
+ .exists()
+ )
return list(
await self._require_session().scalars(
select(ForgeWebhookInboxEntity)
@@ -155,6 +176,7 @@ async def find_pending(self) -> list[ForgeWebhookInboxEntity]:
ForgeWebhookInboxEntity.data["feedback_publication_pending"]
.as_boolean()
.is_(True),
+ unreported_recovery,
),
),
),
diff --git a/src/firefly_forge/engine.py b/src/firefly_forge/engine.py
index 7bc926f5..295a79c6 100644
--- a/src/firefly_forge/engine.py
+++ b/src/firefly_forge/engine.py
@@ -242,8 +242,9 @@ async def stop(self) -> None:
await asyncio.gather(self.poller, return_exceptions=True)
tasks = list(self.active.values())
for task in tasks:
- task.cancel()
- await asyncio.gather(*tasks, return_exceptions=True)
+ if not task.cancelling():
+ task.cancel()
+ await asyncio.shield(asyncio.gather(*tasks, return_exceptions=True))
self.shutdown_tasks.update(self.consumer_tasks())
if any(not task.done() for task in self.shutdown_tasks):
self.ownership_failure = "Execution consumers have not drained; storage ownership remains fenced"
@@ -283,7 +284,9 @@ async def _poll(self) -> None:
self.store.execution_stopping = True
for task in self.active.values():
task.cancel()
- await asyncio.gather(*self.active.values(), return_exceptions=True)
+ # Another consumer can trigger native shutdown during this drain.
+ # Cancelling the poller must not cancel effect cleanup a second time.
+ await asyncio.shield(asyncio.gather(*self.active.values(), return_exceptions=True))
# Native shutdown drains every higher-phase effect consumer before Engine.stop releases storage.
return
self.active = {identifier: task for identifier, task in self.active.items() if not task.done()}
diff --git a/src/firefly_forge/mcp_server.py b/src/firefly_forge/mcp_server.py
index 3d197f89..a441b3b8 100644
--- a/src/firefly_forge/mcp_server.py
+++ b/src/firefly_forge/mcp_server.py
@@ -11,6 +11,7 @@
from pydantic import SkipValidation, WithJsonSchema
from firefly_forge.access_controllers import MembershipInput
+from firefly_forge.activity_models import ActivityContentQuery
from firefly_forge.agent_checkpoint_models import RestoreInput
from firefly_forge.assistant_attachments import AttachmentInput
from firefly_forge.campaign_delivery_plan_models import CampaignDeliveryPlanInput
@@ -29,6 +30,7 @@
from firefly_forge.runner_continuations import RunnerContinuationInput
from firefly_forge.sandbox_models import SandboxInput
from firefly_forge.scm_authority_models import SCMActorBindingInputV1
+from firefly_forge.scm_poll_resume_models import SCMPollResumeGrantInputV3
from firefly_forge.scm_product_change_models import SCMProductChangeGrantInputV1
from firefly_forge.scm_resume_models import SCMResumeGrantInputV2
from firefly_forge.task_routing import TaskRoutingInput
@@ -58,7 +60,9 @@
CampaignDeliveryPlanRequest = SkipValidation[CampaignDeliveryPlanInput]
SCMActorBindingRequest = SkipValidation[SCMActorBindingInputV1]
SCMResumeGrantRequest = SkipValidation[SCMResumeGrantInputV2]
+SCMPollResumeGrantRequest = SkipValidation[SCMPollResumeGrantInputV3]
RunEvidenceRequest = Annotated[Any, WithJsonSchema(EvidenceQuery.model_json_schema())]
+ActivityContentRequest = Annotated[Any, WithJsonSchema(ActivityContentQuery.model_json_schema())]
SCMProductChangeGrantRequest = SkipValidation[SCMProductChangeGrantInputV1]
RecoveryIdentifier = Annotated[Any, WithJsonSchema({"type": "string", "pattern": r"^[a-f0-9]{32}$"})]
CompletionIdentifier = Annotated[Any, WithJsonSchema({"type": "string", "pattern": r"^fbr1_[a-f0-9]{32}$"})]
@@ -309,6 +313,18 @@ def query_run_evidence(identifier: str, query: RunEvidenceRequest) -> dict[str,
with client() as connection:
return connection.run_evidence(identifier, query)
+ @server.tool(
+ description=prompt_text("mcp/get_run_activity_content"),
+ annotations=ToolAnnotations(
+ read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False
+ ),
+ )
+ def get_run_activity_content(identifier: str, event_id: str, query: ActivityContentRequest) -> dict[str, Any]:
+ from firefly_forge.cli import client
+
+ with client() as connection:
+ return connection.run_activity_content(identifier, event_id, query)
+
@server.tool(description=prompt_text("mcp/get_execution_journal"))
def get_execution_journal(identifier: str) -> dict[str, Any]:
from firefly_forge.cli import client
@@ -846,6 +862,60 @@ def get_current_scm_resume_grant(project_id: str, repository_id: str, binding_re
with client() as connection:
return connection.current_scm_resume_grant(project_id, repository_id, binding_request_id)
+ @server.tool(
+ description=prompt_text("mcp/configure_scm_polling_resume_grant"),
+ annotations=ToolAnnotations(
+ read_only_hint=False, destructive_hint=True, idempotent_hint=True, open_world_hint=True
+ ),
+ )
+ def configure_scm_polling_resume_grant(
+ project_id: str, repository_id: str, request: SCMPollResumeGrantRequest
+ ) -> dict[str, Any]:
+ from firefly_forge.cli import client
+
+ with client() as connection:
+ return connection.configure_scm_polling_resume_grant(project_id, repository_id, request)
+
+ @server.tool(
+ description=prompt_text("mcp/get_scm_polling_resume_grant"),
+ annotations=ToolAnnotations(
+ read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False
+ ),
+ )
+ def get_scm_polling_resume_grant(project_id: str, repository_id: str, request_id: str) -> dict[str, Any]:
+ from firefly_forge.cli import client
+
+ with client() as connection:
+ return connection.scm_polling_resume_grant(project_id, repository_id, request_id)
+
+ @server.tool(
+ description=prompt_text("mcp/get_current_scm_polling_resume_grant"),
+ annotations=ToolAnnotations(
+ read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False
+ ),
+ )
+ def get_current_scm_polling_resume_grant(
+ project_id: str, repository_id: str, binding_request_id: str
+ ) -> dict[str, Any]:
+ from firefly_forge.cli import client
+
+ with client() as connection:
+ return connection.current_scm_polling_resume_grant(project_id, repository_id, binding_request_id)
+
+ @server.tool(
+ description=prompt_text("mcp/review_scm_polling_resume"),
+ annotations=ToolAnnotations(
+ read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False
+ ),
+ )
+ def review_scm_polling_resume(
+ project_id: str, repository_id: str, binding_request_id: str, run_id: str
+ ) -> dict[str, Any]:
+ from firefly_forge.cli import client
+
+ with client() as connection:
+ return connection.review_scm_polling_resume(project_id, repository_id, binding_request_id, run_id)
+
@server.tool(
description=prompt_text("mcp/configure_scm_product_change_grant"),
annotations=ToolAnnotations(
diff --git a/src/firefly_forge/migrations.py b/src/firefly_forge/migrations.py
index 2a908a75..f8feb0b8 100644
--- a/src/firefly_forge/migrations.py
+++ b/src/firefly_forge/migrations.py
@@ -9,6 +9,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from firefly_forge.access_persistence import ForgeAccessEventEntity, ForgeMembershipEntity
+from firefly_forge.activity_persistence import ForgeEventContentEntity
from firefly_forge.analytics_persistence import ForgeAnalyticsBindingEntity, ForgeAnalyticsConfigurationReceiptEntity
from firefly_forge.application_persistence import ForgeIncidentEntity, ForgeObservationEntity
from firefly_forge.assistant_attachment_persistence import ForgeAssistantAttachmentEntity
@@ -83,7 +84,7 @@
ForgeIdentityAdminOperationEntity,
)
-SCHEMA_REVISION = 29
+SCHEMA_REVISION = 30
async def migrate(session: AsyncSession, schema: str) -> None:
@@ -115,6 +116,7 @@ async def migrate(session: AsyncSession, schema: str) -> None:
ForgeRepositoryEntity,
ForgeRunEntity,
ForgeEventEntity,
+ ForgeEventContentEntity,
ForgeDeploymentEntity,
ForgePlanEntity,
ForgeTaskEntity,
@@ -233,6 +235,7 @@ async def migrate(session: AsyncSession, schema: str) -> None:
"forge_analytics_configuration_receipts",
"forge_assistant_events",
"forge_assistant_attachments",
+ "forge_event_content",
"forge_campaign_children",
"forge_campaign_events",
"forge_campaign_report_archives",
diff --git a/src/firefly_forge/prompts.py b/src/firefly_forge/prompts.py
index 210625fc..e9832324 100644
--- a/src/firefly_forge/prompts.py
+++ b/src/firefly_forge/prompts.py
@@ -49,7 +49,7 @@ def confined_path(self) -> _Entry:
class _Manifest(_Strict):
schema_version: StrictInt
- templates: list[_Entry] = Field(min_length=1, max_length=256)
+ templates: list[_Entry] = Field(min_length=1, max_length=512)
@model_validator(mode="after")
def unique_templates(self) -> _Manifest:
diff --git a/src/firefly_forge/resources/prompts/v1/collaboration/copy.yaml b/src/firefly_forge/resources/prompts/v1/collaboration/copy.yaml
index 536926fd..f973eb48 100644
--- a/src/firefly_forge/resources/prompts/v1/collaboration/copy.yaml
+++ b/src/firefly_forge/resources/prompts/v1/collaboration/copy.yaml
@@ -85,6 +85,12 @@ user_template: |-
},
"choices_heading": "Choices:",
"verification_refused": "The factory could not start the requested checks. An operator needs to review the original question.",
+ "check_provenance_unknown": "unknown",
+ "check_provenance": {
+ "current": "These checks were recorded for this recovery attempt.",
+ "retained": "These totals include checks from another attempt and do not verify this recovery attempt.",
+ "unknown": "The attempt provenance is incomplete; these retained checks do not verify this recovery attempt."
+ },
"titles": {
"recorded": "Request recorded",
"queued": "Queued for execution",
diff --git a/src/firefly_forge/resources/prompts/v1/collaboration/provider-recovery.yaml b/src/firefly_forge/resources/prompts/v1/collaboration/provider-recovery.yaml
new file mode 100644
index 00000000..07e1d906
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/collaboration/provider-recovery.yaml
@@ -0,0 +1,14 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: collaboration/provider-recovery
+version: 1.0.0
+description: Native provider recovery identity in repository feedback.
+system_template: ''
+user_template: |-
+ Recovery attempt: **{{ attempt }}**
+
+ Recovery request: `{{ request_id }}`
+required_variables:
+- attempt
+- request_id
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/resources/prompts/v1/collaboration/verification-provenance.yaml b/src/firefly_forge/resources/prompts/v1/collaboration/verification-provenance.yaml
new file mode 100644
index 00000000..8ddb988c
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/collaboration/verification-provenance.yaml
@@ -0,0 +1,14 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: collaboration/verification-provenance
+version: 1.0.0
+description: Attempt provenance of checks shown beside a provider recovery.
+system_template: ''
+user_template: |-
+ Recorded check attempts: {{ attempts }}.
+
+ {{ explanation }}
+required_variables:
+- attempts
+- explanation
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/resources/prompts/v1/manifest.json b/src/firefly_forge/resources/prompts/v1/manifest.json
index 4411a4e3..4aa44801 100644
--- a/src/firefly_forge/resources/prompts/v1/manifest.json
+++ b/src/firefly_forge/resources/prompts/v1/manifest.json
@@ -45,6 +45,186 @@
"checks": "string"
}
},
+ {
+ "id": "collaboration/action",
+ "version": "1.0.0",
+ "path": "collaboration/action.yaml",
+ "sha256": "1c321d7bd330a4e1ebc21c240a20425c3ce6880ea6eeb39abdec6bb7fb49e377",
+ "field": "user",
+ "variables": {
+ "summary": "string",
+ "status": "string"
+ }
+ },
+ {
+ "id": "collaboration/card",
+ "version": "1.0.0",
+ "path": "collaboration/card.yaml",
+ "sha256": "b5f4017405bf020ed48f3fa52db49de223111d2eeb6faff643ba1a0ec7eb30ae",
+ "field": "user",
+ "variables": {
+ "title": "string",
+ "description": "string",
+ "sections": "string",
+ "action": "string",
+ "status": "string",
+ "details": "string",
+ "run_reference": "string",
+ "receipt_id": "string"
+ }
+ },
+ {
+ "id": "collaboration/copy",
+ "version": "1.0.0",
+ "path": "collaboration/copy.yaml",
+ "sha256": "4d7b567dd55a9078f5ce51c960375ef33551978231febfb2894314185578d4a1",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "collaboration/facts",
+ "version": "1.0.0",
+ "path": "collaboration/facts.yaml",
+ "sha256": "140a2e17a7c33e3973f737b1c1bf6413d1736296bd7065a833f6ea283dac3c7d",
+ "field": "user",
+ "variables": {
+ "facts": "string"
+ }
+ },
+ {
+ "id": "collaboration/handoff",
+ "version": "1.0.0",
+ "path": "collaboration/handoff.yaml",
+ "sha256": "ace623f79d300f03daed600360f9bd2f983bb1bd2f51de70494a71dcf7738035",
+ "field": "user",
+ "variables": {
+ "explanation": "string",
+ "completed": "string",
+ "total": "string",
+ "failed": "string"
+ }
+ },
+ {
+ "id": "collaboration/proposal",
+ "version": "1.0.0",
+ "path": "collaboration/proposal.yaml",
+ "sha256": "fe8e4b9c405620022d36a74eb1904881f12d5e3f3afea60b2bd6ab10ce600f79",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "collaboration/provider-recovery",
+ "version": "1.0.0",
+ "path": "collaboration/provider-recovery.yaml",
+ "sha256": "a25663f6af6479f1ac1358140bd45d79cd9fbba4ce341cf6dd30522bec6c25e1",
+ "field": "user",
+ "variables": {
+ "attempt": "string",
+ "request_id": "string"
+ }
+ },
+ {
+ "id": "collaboration/publication-held",
+ "version": "1.0.0",
+ "path": "collaboration/publication-held.yaml",
+ "sha256": "dbbce1e843da6de07f80c5189fc24045f55f2155e09291641f437c33551a520d",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "collaboration/question",
+ "version": "1.0.0",
+ "path": "collaboration/question.yaml",
+ "sha256": "a9c59b934dd4495c7dc3c06e7a6f19691eb87b90fa05789b55d9224082c49bd7",
+ "field": "user",
+ "variables": {
+ "question": "string",
+ "choices": "string"
+ }
+ },
+ {
+ "id": "collaboration/recovery",
+ "version": "1.0.0",
+ "path": "collaboration/recovery.yaml",
+ "sha256": "ff287ec19b61c8e80120ee74cb406c2cbe5886280f2b0088a6264b38ffa74c6d",
+ "field": "user",
+ "variables": {
+ "explanation": "string"
+ }
+ },
+ {
+ "id": "collaboration/reference",
+ "version": "1.0.0",
+ "path": "collaboration/reference.yaml",
+ "sha256": "b7dc8137af0c8d039e64aad789e40a46a5e17eacbe1b74c613f73310b527b0f4",
+ "field": "user",
+ "variables": {
+ "url": "string"
+ }
+ },
+ {
+ "id": "collaboration/reply",
+ "version": "1.0.0",
+ "path": "collaboration/reply.yaml",
+ "sha256": "d5c225447b66fbceff45dffb4cca36b244f760cd7e28a577d8aec8ea7549960e",
+ "field": "user",
+ "variables": {
+ "reply": "string"
+ }
+ },
+ {
+ "id": "collaboration/review",
+ "version": "1.0.0",
+ "path": "collaboration/review.yaml",
+ "sha256": "75f55a0e395bc6ca12fa900d51020fa8931b11421fdebdf9571f7ccbd5e0763f",
+ "field": "user",
+ "variables": {
+ "url": "string"
+ }
+ },
+ {
+ "id": "collaboration/run",
+ "version": "1.0.0",
+ "path": "collaboration/run.yaml",
+ "sha256": "67ca4b594ea6de0e83eae7254a03ec3993c1346b6204a3b077bf5b4f724ddea4",
+ "field": "user",
+ "variables": {
+ "run_id": "string"
+ }
+ },
+ {
+ "id": "collaboration/verification",
+ "version": "1.0.0",
+ "path": "collaboration/verification.yaml",
+ "sha256": "c0c9134c723ab7113ea9f39452908e12a1d2b5b86428cf43380837bd2d1418f1",
+ "field": "user",
+ "variables": {
+ "passed": "string",
+ "total": "string"
+ }
+ },
+ {
+ "id": "collaboration/verification-provenance",
+ "version": "1.0.0",
+ "path": "collaboration/verification-provenance.yaml",
+ "sha256": "4f10f64ed4af87511e418183de783f0aeacc33933c13679477690667dcd5078e",
+ "field": "user",
+ "variables": {
+ "attempts": "string",
+ "explanation": "string"
+ }
+ },
+ {
+ "id": "implementation/automatic-verification",
+ "version": "1.0.0",
+ "path": "implementation/automatic-verification.yaml",
+ "sha256": "b258605c3a29f73c2fe2106defef517a8a0aac2841315fe07d80b0f1352a8337",
+ "field": "user",
+ "variables": {
+ "policy": "string",
+ "response": "string"
+ }
+ },
{
"id": "implementation/backend-dotnet",
"version": "1.0.0",
@@ -330,6 +510,18 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "implementation/verification-result",
+ "version": "1.0.0",
+ "path": "implementation/verification-result.yaml",
+ "sha256": "827e21bf5c6b2d08a8a199a14220cd796540a0f1e003084cf7930c8389672936",
+ "field": "user",
+ "variables": {
+ "count": "string",
+ "failed": "string",
+ "results": "string"
+ }
+ },
{
"id": "implementation/worker-feedback",
"version": "1.0.0",
@@ -388,6 +580,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/cancel_run_creation_request",
+ "version": "1.0.0",
+ "path": "mcp/cancel_run_creation_request.yaml",
+ "sha256": "d8d37d5787d96833c741a6e40098cdfd9284367d7dd1707ab3624b90bdc8e1cc",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/check_application",
"version": "1.0.0",
@@ -468,6 +668,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/configure_scm_polling_resume_grant",
+ "version": "1.0.0",
+ "path": "mcp/configure_scm_polling_resume_grant.yaml",
+ "sha256": "1110c8c8ad8457ce92f1c96452e5bd155510861981078b62fd4c737d50c91b99",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/configure_scm_product_change_grant",
"version": "1.0.0",
@@ -548,6 +756,22 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/get_agent_checkpoint_candidates",
+ "version": "1.0.0",
+ "path": "mcp/get_agent_checkpoint_candidates.yaml",
+ "sha256": "9913ffa4101977bc4b86a2db122850f59f316f624d343842fd7e17aec98cf57d",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "mcp/get_agent_checkpoint_restore",
+ "version": "1.0.0",
+ "path": "mcp/get_agent_checkpoint_restore.yaml",
+ "sha256": "c113d237bf628a91fed374e9cdf9afa24616d9077147ac4ba81d87b934b9eb8a",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/get_agent_role_lifecycle",
"version": "1.0.0",
@@ -604,6 +828,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/get_current_scm_polling_resume_grant",
+ "version": "1.0.0",
+ "path": "mcp/get_current_scm_polling_resume_grant.yaml",
+ "sha256": "afb332cfc6cf847ef52cfdcc76cea361b944c21482c3e3691ba74972351f8ace",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/get_current_scm_product_change_grant",
"version": "1.0.0",
@@ -868,6 +1100,30 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/get_run_activity_content",
+ "version": "1.0.0",
+ "path": "mcp/get_run_activity_content.yaml",
+ "sha256": "adcae5deda232c67e96939a58a24339aa1b3a903289009c8baabf41698e45535",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "mcp/get_run_creation_decision",
+ "version": "1.0.0",
+ "path": "mcp/get_run_creation_decision.yaml",
+ "sha256": "e93f09efecbf4fb6907d7f5dc5bad4a185230c490cce51614977ac9a75600b18",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "mcp/get_run_creation_request",
+ "version": "1.0.0",
+ "path": "mcp/get_run_creation_request.yaml",
+ "sha256": "41a1fa384435f6db9852b7a1abbcb3f6d3bff90d9185045f1c555ebc3bf0db2d",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/get_run_tool_invocations",
"version": "1.0.0",
@@ -900,6 +1156,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/get_scm_polling_resume_grant",
+ "version": "1.0.0",
+ "path": "mcp/get_scm_polling_resume_grant.yaml",
+ "sha256": "551bfb7a6fb05f9ad5b912b710585b5b810c22264409c9b98c872015993fddde",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/get_scm_product_change",
"version": "1.0.0",
@@ -1252,6 +1516,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/query_run_evidence",
+ "version": "1.0.0",
+ "path": "mcp/query_run_evidence.yaml",
+ "sha256": "51a8d98b6c82eeca836823be9b82d9ec786ab871b07492bffd2f86d092280669",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/reconcile_execution_request",
"version": "1.0.0",
@@ -1309,10 +1581,18 @@
"variables": {}
},
{
- "id": "mcp/request_incident_maintenance",
+ "id": "mcp/request_agent_checkpoint_restore",
"version": "1.0.0",
- "path": "mcp/request_incident_maintenance.yaml",
- "sha256": "7e470efb8ff71156bbfdb42c70cabba0a29db5d7a9f4c5f9ea90cf5bea9affa5",
+ "path": "mcp/request_agent_checkpoint_restore.yaml",
+ "sha256": "c075c879bd0d19d74318cb37537b4fa9d965457914915331d9117372ebf0d975",
+ "field": "user",
+ "variables": {}
+ },
+ {
+ "id": "mcp/request_incident_maintenance",
+ "version": "1.0.0",
+ "path": "mcp/request_incident_maintenance.yaml",
+ "sha256": "7e470efb8ff71156bbfdb42c70cabba0a29db5d7a9f4c5f9ea90cf5bea9affa5",
"field": "user",
"variables": {}
},
@@ -1324,6 +1604,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/request_run_creation",
+ "version": "1.0.0",
+ "path": "mcp/request_run_creation.yaml",
+ "sha256": "931f9510644d4f1c881c4a971c5b3ab913286fd1892ffaa4ade5d1deca8de943",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/request_runner_continuation",
"version": "1.0.0",
@@ -1380,6 +1668,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "mcp/review_scm_polling_resume",
+ "version": "1.0.0",
+ "path": "mcp/review_scm_polling_resume.yaml",
+ "sha256": "e4dc762f06917fb34690e563f3ae1fcf0cd925c090282ab179906816bede5cdd",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "mcp/review_source_analysis",
"version": "1.0.0",
@@ -1837,6 +2133,14 @@
"field": "user",
"variables": {}
},
+ {
+ "id": "tools/read_run_evidence",
+ "version": "1.0.0",
+ "path": "tools/read_run_evidence.yaml",
+ "sha256": "d36c263ee23205073b7bae519a601500f7d84c825cb5728a7d83c75aae809f1a",
+ "field": "user",
+ "variables": {}
+ },
{
"id": "tools/search-evidence",
"version": "1.0.0",
@@ -1868,248 +2172,6 @@
"sha256": "90d8cc3a086274b7ff631f0428124356b713dee00f32a4f5a8eb7506f0c96b1a",
"field": "user",
"variables": {}
- },
- {
- "id": "mcp/query_run_evidence",
- "version": "1.0.0",
- "path": "mcp/query_run_evidence.yaml",
- "sha256": "51a8d98b6c82eeca836823be9b82d9ec786ab871b07492bffd2f86d092280669",
- "field": "user",
- "variables": {}
- },
- {
- "id": "tools/read_run_evidence",
- "version": "1.0.0",
- "path": "tools/read_run_evidence.yaml",
- "sha256": "d36c263ee23205073b7bae519a601500f7d84c825cb5728a7d83c75aae809f1a",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/request_run_creation",
- "version": "1.0.0",
- "path": "mcp/request_run_creation.yaml",
- "sha256": "931f9510644d4f1c881c4a971c5b3ab913286fd1892ffaa4ade5d1deca8de943",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/get_run_creation_request",
- "version": "1.0.0",
- "path": "mcp/get_run_creation_request.yaml",
- "sha256": "41a1fa384435f6db9852b7a1abbcb3f6d3bff90d9185045f1c555ebc3bf0db2d",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/cancel_run_creation_request",
- "version": "1.0.0",
- "path": "mcp/cancel_run_creation_request.yaml",
- "sha256": "d8d37d5787d96833c741a6e40098cdfd9284367d7dd1707ab3624b90bdc8e1cc",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/get_run_creation_decision",
- "version": "1.0.0",
- "path": "mcp/get_run_creation_decision.yaml",
- "sha256": "e93f09efecbf4fb6907d7f5dc5bad4a185230c490cce51614977ac9a75600b18",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/get_agent_checkpoint_candidates",
- "version": "1.0.0",
- "path": "mcp/get_agent_checkpoint_candidates.yaml",
- "sha256": "9913ffa4101977bc4b86a2db122850f59f316f624d343842fd7e17aec98cf57d",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/request_agent_checkpoint_restore",
- "version": "1.0.0",
- "path": "mcp/request_agent_checkpoint_restore.yaml",
- "sha256": "c075c879bd0d19d74318cb37537b4fa9d965457914915331d9117372ebf0d975",
- "field": "user",
- "variables": {}
- },
- {
- "id": "mcp/get_agent_checkpoint_restore",
- "version": "1.0.0",
- "path": "mcp/get_agent_checkpoint_restore.yaml",
- "sha256": "c113d237bf628a91fed374e9cdf9afa24616d9077147ac4ba81d87b934b9eb8a",
- "field": "user",
- "variables": {}
- },
- {
- "id": "collaboration/copy",
- "version": "1.0.0",
- "path": "collaboration/copy.yaml",
- "sha256": "0687533909b71ba23252b8e58ab1983080d6ed93d090482a1db32eede4512d7a",
- "field": "user",
- "variables": {}
- },
- {
- "id": "collaboration/card",
- "version": "1.0.0",
- "path": "collaboration/card.yaml",
- "sha256": "b5f4017405bf020ed48f3fa52db49de223111d2eeb6faff643ba1a0ec7eb30ae",
- "field": "user",
- "variables": {
- "title": "string",
- "description": "string",
- "sections": "string",
- "action": "string",
- "status": "string",
- "details": "string",
- "run_reference": "string",
- "receipt_id": "string"
- }
- },
- {
- "id": "collaboration/action",
- "version": "1.0.0",
- "path": "collaboration/action.yaml",
- "sha256": "1c321d7bd330a4e1ebc21c240a20425c3ce6880ea6eeb39abdec6bb7fb49e377",
- "field": "user",
- "variables": {
- "summary": "string",
- "status": "string"
- }
- },
- {
- "id": "collaboration/question",
- "version": "1.0.0",
- "path": "collaboration/question.yaml",
- "sha256": "a9c59b934dd4495c7dc3c06e7a6f19691eb87b90fa05789b55d9224082c49bd7",
- "field": "user",
- "variables": {
- "question": "string",
- "choices": "string"
- }
- },
- {
- "id": "collaboration/recovery",
- "version": "1.0.0",
- "path": "collaboration/recovery.yaml",
- "sha256": "ff287ec19b61c8e80120ee74cb406c2cbe5886280f2b0088a6264b38ffa74c6d",
- "field": "user",
- "variables": {
- "explanation": "string"
- }
- },
- {
- "id": "collaboration/verification",
- "version": "1.0.0",
- "path": "collaboration/verification.yaml",
- "sha256": "c0c9134c723ab7113ea9f39452908e12a1d2b5b86428cf43380837bd2d1418f1",
- "field": "user",
- "variables": {
- "passed": "string",
- "total": "string"
- }
- },
- {
- "id": "collaboration/review",
- "version": "1.0.0",
- "path": "collaboration/review.yaml",
- "sha256": "75f55a0e395bc6ca12fa900d51020fa8931b11421fdebdf9571f7ccbd5e0763f",
- "field": "user",
- "variables": {
- "url": "string"
- }
- },
- {
- "id": "collaboration/proposal",
- "version": "1.0.0",
- "path": "collaboration/proposal.yaml",
- "sha256": "fe8e4b9c405620022d36a74eb1904881f12d5e3f3afea60b2bd6ab10ce600f79",
- "field": "user",
- "variables": {}
- },
- {
- "id": "collaboration/handoff",
- "version": "1.0.0",
- "path": "collaboration/handoff.yaml",
- "sha256": "ace623f79d300f03daed600360f9bd2f983bb1bd2f51de70494a71dcf7738035",
- "field": "user",
- "variables": {
- "explanation": "string",
- "completed": "string",
- "total": "string",
- "failed": "string"
- }
- },
- {
- "id": "collaboration/publication-held",
- "version": "1.0.0",
- "path": "collaboration/publication-held.yaml",
- "sha256": "dbbce1e843da6de07f80c5189fc24045f55f2155e09291641f437c33551a520d",
- "field": "user",
- "variables": {}
- },
- {
- "id": "collaboration/reply",
- "version": "1.0.0",
- "path": "collaboration/reply.yaml",
- "sha256": "d5c225447b66fbceff45dffb4cca36b244f760cd7e28a577d8aec8ea7549960e",
- "field": "user",
- "variables": {
- "reply": "string"
- }
- },
- {
- "id": "collaboration/facts",
- "version": "1.0.0",
- "path": "collaboration/facts.yaml",
- "sha256": "140a2e17a7c33e3973f737b1c1bf6413d1736296bd7065a833f6ea283dac3c7d",
- "field": "user",
- "variables": {
- "facts": "string"
- }
- },
- {
- "id": "collaboration/reference",
- "version": "1.0.0",
- "path": "collaboration/reference.yaml",
- "sha256": "b7dc8137af0c8d039e64aad789e40a46a5e17eacbe1b74c613f73310b527b0f4",
- "field": "user",
- "variables": {
- "url": "string"
- }
- },
- {
- "id": "collaboration/run",
- "version": "1.0.0",
- "path": "collaboration/run.yaml",
- "sha256": "67ca4b594ea6de0e83eae7254a03ec3993c1346b6204a3b077bf5b4f724ddea4",
- "field": "user",
- "variables": {
- "run_id": "string"
- }
- },
- {
- "id": "implementation/automatic-verification",
- "version": "1.0.0",
- "path": "implementation/automatic-verification.yaml",
- "sha256": "b258605c3a29f73c2fe2106defef517a8a0aac2841315fe07d80b0f1352a8337",
- "field": "user",
- "variables": {
- "policy": "string",
- "response": "string"
- }
- },
- {
- "id": "implementation/verification-result",
- "version": "1.0.0",
- "path": "implementation/verification-result.yaml",
- "sha256": "827e21bf5c6b2d08a8a199a14220cd796540a0f1e003084cf7930c8389672936",
- "field": "user",
- "variables": {
- "count": "string",
- "failed": "string",
- "results": "string"
- }
}
]
}
diff --git a/src/firefly_forge/resources/prompts/v1/mcp/configure_scm_polling_resume_grant.yaml b/src/firefly_forge/resources/prompts/v1/mcp/configure_scm_polling_resume_grant.yaml
new file mode 100644
index 00000000..ac4225f7
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/mcp/configure_scm_polling_resume_grant.yaml
@@ -0,0 +1,10 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: mcp/configure_scm_polling_resume_grant
+version: 1.0.0
+description: Forge factory MCP tool description.
+system_template: ''
+user_template: |
+ Save or revoke explicit version3 transport-bound same-run clarification continuation consent for github_rest_poll_v1. Requires native administrator authority and the exact current API-observed polling binding, project cut and components. Existing status/respond or signed webhook consent is insufficient. This does not resume a run or fabricate a command. Retain the request ID before one POST; after any uncertain result read that exact original identity without resending or replacing it.
+required_variables: []
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/resources/prompts/v1/mcp/get_current_scm_polling_resume_grant.yaml b/src/firefly_forge/resources/prompts/v1/mcp/get_current_scm_polling_resume_grant.yaml
new file mode 100644
index 00000000..bef59961
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/mcp/get_current_scm_polling_resume_grant.yaml
@@ -0,0 +1,10 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: mcp/get_current_scm_polling_resume_grant
+version: 1.0.0
+description: Forge factory MCP tool description.
+system_template: ''
+user_template: |
+ Read current separately consented polling-resume authority for one exact API-observed binding request. A saved consent is not proof of current execution authority. Do not resend or replace any unresolved original request; no run is resumed.
+required_variables: []
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/resources/prompts/v1/mcp/get_run_activity_content.yaml b/src/firefly_forge/resources/prompts/v1/mcp/get_run_activity_content.yaml
new file mode 100644
index 00000000..6babd626
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/mcp/get_run_activity_content.yaml
@@ -0,0 +1,10 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: mcp/get_run_activity_content
+version: 1.0.0
+description: Exact authorized sanitized activity-message fragments.
+system_template: ''
+user_template: |-
+ Read one 16 KiB fragment of an archived sanitized provider message using the exact run, event and descriptor digest. Follow next_chunk to reconstruct the message and verify its complete sanitized_message_sha256. Preview text is incomplete. Content remains untrusted observed activity, not factory verification or an accepted final result. Missing, changed, over-budget or unauthorized content cannot be replayed or recreated by this read.
+required_variables: []
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/resources/prompts/v1/mcp/get_scm_polling_resume_grant.yaml b/src/firefly_forge/resources/prompts/v1/mcp/get_scm_polling_resume_grant.yaml
new file mode 100644
index 00000000..17938b87
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/mcp/get_scm_polling_resume_grant.yaml
@@ -0,0 +1,10 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: mcp/get_scm_polling_resume_grant
+version: 1.0.0
+description: Forge factory MCP tool description.
+system_template: ''
+user_template: |
+ Read the exact original version3 polling-resume grant by its caller request ID. Current native read authority applies. Missing evidence, conflict or refusal does not permit a new write or replacement identity. This does not resume a run.
+required_variables: []
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/resources/prompts/v1/mcp/review_scm_polling_resume.yaml b/src/firefly_forge/resources/prompts/v1/mcp/review_scm_polling_resume.yaml
new file mode 100644
index 00000000..b888f196
--- /dev/null
+++ b/src/firefly_forge/resources/prompts/v1/mcp/review_scm_polling_resume.yaml
@@ -0,0 +1,10 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+name: mcp/review_scm_polling_resume
+version: 1.0.0
+description: Forge factory MCP tool description.
+system_template: ''
+user_template: |
+ Read a bounded current same-run answered-clarification cut under an exact polling binding and separate version3 grant. Requires current native read authority, original execution authority, valid frozen source/task/question/answer receipts and no unknown effects. The response includes exact grant, question, answer and cut digests. This does not generate or post a GitHub comment, resume a run, invoke a model, change a provider or increase a budget.
+required_variables: []
+metadata:
+ defaults: {}
diff --git a/src/firefly_forge/run_evidence.py b/src/firefly_forge/run_evidence.py
index 27db03a6..e147282a 100644
--- a/src/firefly_forge/run_evidence.py
+++ b/src/firefly_forge/run_evidence.py
@@ -24,6 +24,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
+from firefly_forge.activity_models import ActivityUnavailable, validate_descriptor
from firefly_forge.journal import Journal, digest
from firefly_forge.persistence import (
ForgeEventEntity,
@@ -327,6 +328,21 @@ def build_evidence(
"run_provenance": "observed_run_row",
"artifact_bytes": "not_included",
}
+ archived = False
+ for row in activity:
+ data = row.get("data", {})
+ if "activity_content" in data:
+ try:
+ descriptor = validate_descriptor(data["activity_content"])
+ require(
+ (descriptor["run_id"], descriptor["event_id"], descriptor["stage"])
+ == (run["id"], row["id"], data.get("stage"))
+ )
+ except ActivityUnavailable as exc:
+ raise EvidenceUnavailable("evidence_integrity") from exc
+ archived = True
+ if archived:
+ manifest["activity_content_bytes"] = "not_included_with_bound_references"
manifest["digest"] = digest(manifest)
if len(encoded(manifest)) + sum(len(encoded(v)) for v in members.values()) > MAX_BYTES:
raise EvidenceUnavailable("evidence_limit")
diff --git a/src/firefly_forge/scm_poll_resume.py b/src/firefly_forge/scm_poll_resume.py
new file mode 100644
index 00000000..fcb16207
--- /dev/null
+++ b/src/firefly_forge/scm_poll_resume.py
@@ -0,0 +1,186 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""API-observed continuation authority with an independent immutable consent history."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pyfly.data.transaction import current_unit_of_work
+
+from firefly_forge.journal import digest
+from firefly_forge.project_access import CURRENT_PRINCIPAL, AccessDenied, ProjectAccess
+from firefly_forge.scm_poll_models import GitHubPollReferenceV1
+from firefly_forge.scm_poll_resume_models import SCMPollResumeGrantInputV3, SCMPollResumeGrantReceiptV3
+from firefly_forge.scm_polling import BINDING_KIND, SCMPolling, poll_key
+from firefly_forge.scm_resume_cut import polling_resume_cut
+from firefly_forge.scm_resume_models import SCMResumeGrantInputV2
+from firefly_forge.scm_resume_service import SCMResumeAuthority
+from firefly_forge.store import identifier
+
+Data = dict[str, Any]
+
+
+class SCMPollResumeAuthority(SCMResumeAuthority):
+ state_key = "_scm_poll_resume_v3"
+ event_kind = "scm_poll_resume_grant_v3"
+ grant_version = 3
+ request_type = SCMPollResumeGrantInputV3
+ receipt_type = SCMPollResumeGrantReceiptV3
+ cut_builder = staticmethod(polling_resume_cut)
+ polling: SCMPolling
+
+ async def _current_binding(self, project: str, repository: str, request_id: str) -> Data | None:
+ async with self.store._unit():
+ _, state = await self.polling._state(repository)
+ pointer = state["requests"].get(project + ":" + request_id)
+ if pointer is None:
+ return None
+ return await self.polling._event(pointer, BINDING_KIND, project, repository)
+
+ async def _binding(
+ self, project: str, repository: str, request: SCMResumeGrantInputV2 | SCMPollResumeGrantInputV3
+ ) -> Data:
+ if not isinstance(request, SCMPollResumeGrantInputV3):
+ raise AccessDenied("Polling resume requires its separate transport-bound consent")
+ binding = await self._current_binding(project, repository, request.expected_binding_request_id)
+ if binding is None:
+ raise AccessDenied("Review an exact current API-observed polling binding first")
+ current = await self.polling._head(project, repository, binding["binding_key"])
+ policy, state = await self.polling._policy(repository)
+ await self.polling._scope(repository, policy)
+ project_cut = await self.authority._project(project, repository, request.component_ids)
+ observed = await self.polling._observation(project, repository, binding["request"]["observed_receipt_id"])
+ if (
+ current != binding
+ or policy["project_id"] != project
+ or not state["cursor"]["ready"]
+ or observed.get("source_conflict")
+ or binding["digest"] != request.expected_binding_digest
+ or not binding["request"]["active"]
+ or "respond" not in binding["request"]["allowed_actions"]
+ or not set(request.component_ids) <= set(binding["request"]["component_ids"])
+ or digest(project_cut) != request.expected_project_binding_digest
+ or binding["project_binding_digest"] != digest(project_cut)
+ or any(binding["verified_reference"].get(k) != v for k, v in policy["scope"].items())
+ ):
+ raise AccessDenied("The reviewed polling binding, connection or project scope changed")
+ async with self.store._unit():
+ user = await self.polling.users.find_by_id(identifier(binding["request"]["native_user_id"]))
+ memberships = await self.polling.memberships.for_user(identifier(binding["request"]["native_user_id"]))
+ row, _ = await self.polling._state(repository)
+ if (
+ user is None
+ or not user.enabled
+ or binding["verified_reference"]["external_actor_id"] not in row.data.get("trusted_actors", [])
+ or "respond" not in row.data.get("channel_commands", [])
+ or not any(
+ item.project_id.hex == project and item.active and ProjectAccess.permits(item.role, "execute")
+ for item in memberships
+ )
+ ):
+ raise AccessDenied("The polling binding no longer has current native execution authority")
+ return binding
+
+ async def _preflight(self, context: Data) -> None:
+ await super()._preflight(context)
+ binding = context["binding"]
+ delivery = context.get("delivery") or await self.polling._observation(
+ binding["project_id"], binding["repository_id"], binding["request"]["observed_receipt_id"]
+ )
+ await self.polling._fresh(delivery, await self.store.repository(binding["repository_id"]))
+
+ async def _context(self, delivery: Data) -> Data:
+ command = delivery["command"]
+ if command.get("version") != 2 or command.get("action") != "resume":
+ raise AccessDenied("Polling continuation supports only the typed same-run resume command")
+ run = await self._run_data(command["run_id"])
+ project, repository, component = run.get("project_id"), run["repository_id"], run.get("component_id")
+ if not project or not component or repository != delivery["repository_id"]:
+ raise AccessDenied("Polling resume requires the original run project, repository and component")
+ reference = GitHubPollReferenceV1.model_validate(delivery.get("verification")).model_dump(mode="json")
+ retained = await self.polling._observation(project, repository, delivery["id"])
+ if (
+ retained != delivery
+ or retained.get("source_conflict")
+ or reference["payload_digest"] != delivery.get("digest")
+ or reference["external_actor_id"] != delivery.get("actor")
+ or reference["remote_repository_id"] != delivery.get("remote_id")
+ ):
+ raise AccessDenied("The API-observed continuation source or actor changed")
+ binding = await self.polling._head(project, repository, poll_key(project, reference))
+ grant = await self._grant(project, repository, key=binding["binding_key"])
+ if grant is None or grant["digest"] != command["expected_grant_digest"] or not grant["request"]["active"]:
+ raise AccessDenied("A current separately consented polling resume grant is required")
+ request = SCMPollResumeGrantInputV3.model_validate(grant["request"])
+ current = await self._binding(project, repository, request)
+ identity_fields = (
+ "provider",
+ "api_origin",
+ "installation_id",
+ "remote_repository_id",
+ "external_actor_id",
+ "connection_revision",
+ "connection_scope_digest",
+ "transport",
+ )
+ if (
+ current != binding
+ or component not in request.component_ids
+ or grant["native_user_id"] != binding["request"]["native_user_id"]
+ or any(reference[k] != binding["verified_reference"][k] for k in identity_fields)
+ ):
+ raise AccessDenied("The polling resume grant no longer binds this account, user or component")
+ async with self.store._unit():
+ user = await self.polling.users.find_by_id(identifier(grant["native_user_id"]))
+ memberships = await self.polling.memberships.for_user(identifier(grant["native_user_id"]))
+ row, _ = await self.polling._state(repository)
+ if (
+ user is None
+ or not user.enabled
+ or delivery["actor"] not in row.data.get("trusted_actors", [])
+ or "respond" not in row.data.get("channel_commands", [])
+ or not any(
+ item.project_id.hex == project and item.active and ProjectAccess.permits(item.role, "execute")
+ for item in memberships
+ )
+ ):
+ raise AccessDenied("Current polling actor and native execution permission are required")
+ return {"run": run, "binding": binding, "grant": grant, "user_id": user.id.hex, "delivery": delivery}
+
+ async def _after_admission(self, context: Data) -> None:
+ await self._preflight(context)
+
+ async def review(self, project: str, repository: str, binding_request: str, run_id: str) -> Data:
+ if current_unit_of_work() is not None:
+ raise ValueError("Read the polling resume review outside a caller transaction")
+ actor = CURRENT_PRINCIPAL.get()
+ await self.authority._reader(actor, project, repository, admin=False)
+ binding = await self._current_binding(project, repository, binding_request)
+ if binding is None:
+ raise AccessDenied("The exact polling binding is unavailable")
+ grant = await self._grant(project, repository, key=binding["binding_key"])
+ if grant is None or not grant["request"]["active"]:
+ raise AccessDenied("A separately consented polling resume grant is required")
+ request = SCMPollResumeGrantInputV3.model_validate(grant["request"])
+ binding = await self._binding(project, repository, request)
+ run = await self._run_data(run_id)
+ if (run.get("project_id"), run["repository_id"]) != (project, repository) or run.get(
+ "component_id"
+ ) not in request.component_ids:
+ raise AccessDenied("The reviewed run is outside this polling grant")
+ context = {"run": run, "binding": binding, "grant": grant, "user_id": grant["native_user_id"]}
+ await self._original(run)
+ await self._preflight(context)
+ cut = await self._cut(context)
+ await self.store.admit_credentials(cut)
+ await self._after_admission(context)
+ await self.authority._reader(actor, project, repository, admin=False)
+ current_grant = await self._grant(project, repository, key=binding["binding_key"])
+ if current_grant != grant or await self._binding(project, repository, request) != binding:
+ raise AccessDenied("The polling continuation review cut changed")
+ current_run = await self._run_data(run_id)
+ await self._original(current_run)
+ if await self._cut({**context, "run": current_run}) != cut:
+ raise AccessDenied("The polling continuation review cut changed")
+ self.store.reject_credentials(cut)
+ return cut
diff --git a/src/firefly_forge/scm_poll_resume_controllers.py b/src/firefly_forge/scm_poll_resume_controllers.py
new file mode 100644
index 00000000..d89c6db2
--- /dev/null
+++ b/src/firefly_forge/scm_poll_resume_controllers.py
@@ -0,0 +1,45 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Explicit polling-only continuation consent and original-result reads."""
+
+from typing import Any
+
+from pyfly.container import rest_controller
+from pyfly.web import Body, PathVar, QueryParam, get_mapping, post_mapping, request_mapping
+
+from firefly_forge.scm_poll_resume import SCMPollResumeAuthority
+from firefly_forge.scm_poll_resume_models import SCMPollResumeGrantInputV3
+
+
+@rest_controller
+@request_mapping("/api/v1/projects/{project_id}/repositories/{repository_id}/scm-polling/resume-grants")
+class SCMPollResumeController:
+ def __init__(self, authority: SCMPollResumeAuthority) -> None:
+ self.authority = authority
+
+ @post_mapping("", status_code=201)
+ async def configure(
+ self, project_id: PathVar[str], repository_id: PathVar[str], body: Body[SCMPollResumeGrantInputV3]
+ ) -> dict[str, Any]:
+ return await self.authority.configure(project_id, repository_id, body)
+
+ @get_mapping("/requests/{request_id}")
+ async def exact(
+ self, project_id: PathVar[str], repository_id: PathVar[str], request_id: PathVar[str]
+ ) -> dict[str, Any]:
+ return await self.authority.get(project_id, repository_id, request_id)
+
+ @get_mapping("/current/{binding_request_id}")
+ async def current(
+ self, project_id: PathVar[str], repository_id: PathVar[str], binding_request_id: PathVar[str]
+ ) -> dict[str, Any]:
+ return await self.authority.current(project_id, repository_id, binding_request_id)
+
+ @get_mapping("/review")
+ async def review(
+ self,
+ project_id: PathVar[str],
+ repository_id: PathVar[str],
+ binding_request_id: QueryParam[str],
+ run_id: QueryParam[str],
+ ) -> dict[str, Any]:
+ return await self.authority.review(project_id, repository_id, binding_request_id, run_id)
diff --git a/src/firefly_forge/scm_poll_resume_models.py b/src/firefly_forge/scm_poll_resume_models.py
new file mode 100644
index 00000000..fe957f01
--- /dev/null
+++ b/src/firefly_forge/scm_poll_resume_models.py
@@ -0,0 +1,53 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Separate opt-in continuation authority for API-observed GitHub accounts."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from pydantic import Field, field_validator, model_validator
+
+from firefly_forge.scm_authority_models import Digest, NativeID, StrictContract
+
+
+class SCMPollResumeGrantInputV3(StrictContract):
+ version: Literal[3]
+ transport: Literal["github_rest_poll_v1"]
+ request_id: NativeID
+ expected_previous_digest: Digest | None
+ expected_project_binding_digest: Digest
+ expected_binding_request_id: NativeID
+ expected_binding_digest: Digest
+ component_ids: list[Annotated[str, Field(pattern=r"^[a-z][a-z0-9-]{0,63}$")]] = Field(min_length=1, max_length=50)
+ allowed_actions: list[Literal["resume"]] = Field(min_length=1, max_length=1)
+ active: bool
+ acknowledge_same_run_resume: Literal[True]
+
+ @field_validator("acknowledge_same_run_resume", mode="before")
+ @classmethod
+ def explicit_consent(cls, value: object) -> object:
+ if value is not True:
+ raise ValueError("Explicitly consent to same-run continuation through GitHub polling")
+ return value
+
+ @model_validator(mode="after")
+ def unique_components(self) -> SCMPollResumeGrantInputV3:
+ if len(set(self.component_ids)) != len(self.component_ids):
+ raise ValueError("Polling resume component scope must be unique")
+ return self
+
+
+class SCMPollResumeGrantReceiptV3(StrictContract):
+ version: Literal[3]
+ event_id: NativeID
+ project_id: NativeID
+ repository_id: NativeID
+ binding_key: Digest
+ native_user_id: NativeID
+ revision: int = Field(ge=1, le=50)
+ previous_digest: Digest | None
+ prepared_at: str = Field(min_length=1, max_length=50)
+ administering_user_id: NativeID
+ request: SCMPollResumeGrantInputV3
+ request_digest: Digest
+ digest: Digest
diff --git a/src/firefly_forge/scm_polling.py b/src/firefly_forge/scm_polling.py
index b8794fa1..f0535ce5 100644
--- a/src/firefly_forge/scm_polling.py
+++ b/src/firefly_forge/scm_polling.py
@@ -663,7 +663,13 @@ async def step(self, repository: str) -> None:
try:
parsed = RepositoryCommand.parse(source["body"])
- if parsed.action not in {"respond", "status"} or getattr(parsed, "version", None) is not None:
+ version = getattr(parsed, "version", None)
+ if not (
+ parsed.action in {"respond", "status"}
+ and version is None
+ or parsed.action == "resume"
+ and version == 2
+ ):
raise ValueError("Unsupported polling action")
command = parsed.model_dump(exclude_none=True)
except (ValueError, TypeError, AttributeError):
diff --git a/src/firefly_forge/scm_resume_cut.py b/src/firefly_forge/scm_resume_cut.py
index 7c7d1a2a..d8eb08f3 100644
--- a/src/firefly_forge/scm_resume_cut.py
+++ b/src/firefly_forge/scm_resume_cut.py
@@ -12,6 +12,7 @@
from firefly_forge.journal import Journal, digest
from firefly_forge.models import RunInput
from firefly_forge.scm_authority_models import SCMRespondingActorV2
+from firefly_forge.scm_poll_models import SCMRespondingActorV3
Data = dict[str, Any]
FROZEN_FIELDS = frozenset(RunInput.model_fields) | {
@@ -54,6 +55,51 @@ def resume_cut(
grant_digest: str,
binding_digest: str,
native_user_id: str,
+) -> Data:
+ return _resume_cut(
+ run=run,
+ plan=plan,
+ tasks=tasks,
+ entries=entries,
+ grant_digest=grant_digest,
+ binding_digest=binding_digest,
+ native_user_id=native_user_id,
+ polling=False,
+ )
+
+
+def polling_resume_cut(
+ *,
+ run: Data,
+ plan: Data,
+ tasks: list[Data],
+ entries: list[Data],
+ grant_digest: str,
+ binding_digest: str,
+ native_user_id: str,
+) -> Data:
+ return _resume_cut(
+ run=run,
+ plan=plan,
+ tasks=tasks,
+ entries=entries,
+ grant_digest=grant_digest,
+ binding_digest=binding_digest,
+ native_user_id=native_user_id,
+ polling=True,
+ )
+
+
+def _resume_cut(
+ *,
+ run: Data,
+ plan: Data,
+ tasks: list[Data],
+ entries: list[Data],
+ grant_digest: str,
+ binding_digest: str,
+ native_user_id: str,
+ polling: bool,
) -> Data:
"""Seal this declared frozen projection; never claim that a mutable run is a receipt."""
if (
@@ -131,10 +177,14 @@ def resume_cut(
or response["idempotency_key"] != "worker-response:" + str(response["payload"].get("id"))
or attribution.get("user_id") != native_user_id
or type(attribution.get("version")) is not int
- or attribution.get("version") not in {1, 2}
+ or attribution.get("version") not in ({1, 3} if polling else {1, 2})
):
raise ValueError("The mutable question or answer does not match the causal, attributed journal cut")
- if attribution["version"] == 2:
+ if polling and attribution["version"] == 3:
+ polling_actor = SCMRespondingActorV3.model_validate(attribution)
+ if polling_actor.provenance.binding_digest != binding_digest:
+ raise ValueError("The answer belongs to a different polling delegation revision")
+ elif attribution["version"] == 2:
actor = SCMRespondingActorV2.model_validate(attribution)
if actor.provenance.binding_digest != binding_digest:
raise ValueError("The answer belongs to a different delegation revision")
diff --git a/src/firefly_forge/scm_resume_service.py b/src/firefly_forge/scm_resume_service.py
index 20928766..4ea7f0f7 100644
--- a/src/firefly_forge/scm_resume_service.py
+++ b/src/firefly_forge/scm_resume_service.py
@@ -34,6 +34,7 @@
from firefly_forge.sandbox_persistence import ForgeSandboxEntity
from firefly_forge.scm_actor_authority import SCMActorAuthority, binding_key
from firefly_forge.scm_authority_models import VerifiedSCMReferenceV1
+from firefly_forge.scm_poll_resume_models import SCMPollResumeGrantInputV3, SCMPollResumeGrantReceiptV3
from firefly_forge.scm_resume_cut import FROZEN_FIELDS, resume_cut
from firefly_forge.scm_resume_models import (
SCMResumeCommandV2,
@@ -48,13 +49,13 @@
EVENT_KIND = "scm_resume_grant_revision"
-def grant_state(data: Data) -> Data:
- state = copy.deepcopy(data.get(STATE_KEY, {"version": 2, "heads": {}, "requests": {}}))
+def grant_state(data: Data, state_key: str = STATE_KEY, version: int = 2) -> Data:
+ state = copy.deepcopy(data.get(state_key, {"version": version, "heads": {}, "requests": {}}))
if (
not isinstance(state, dict)
or set(state) != {"version", "heads", "requests"}
or type(state["version"]) is not int
- or state["version"] != 2
+ or state["version"] != version
or not isinstance(state["heads"], dict)
or not isinstance(state["requests"], dict)
or len(state["heads"]) > 100
@@ -68,6 +69,13 @@ def grant_state(data: Data) -> Data:
class SCMResumeAuthority:
"""A Factory bean; all mutable admission checks are repeated under native locks."""
+ state_key = STATE_KEY
+ event_kind = EVENT_KIND
+ grant_version = 2
+ request_type: type[SCMResumeGrantInputV2] | type[SCMPollResumeGrantInputV3] = SCMResumeGrantInputV2
+ receipt_type: type[SCMResumeGrantReceiptV2] | type[SCMPollResumeGrantReceiptV3] = SCMResumeGrantReceiptV2
+ cut_builder = staticmethod(resume_cut)
+
def __init__(
self,
store: Store,
@@ -96,10 +104,10 @@ async def _record(self, event_id: str, project: str, repository: str) -> Data:
row is None
or row.project_id.hex != project
or set(row.data) != {"kind", "receipt"}
- or row.data["kind"] != EVENT_KIND
+ or row.data["kind"] != self.event_kind
):
raise ValueError("Resume grant event scope is invalid")
- receipt = SCMResumeGrantReceiptV2.model_validate(row.data["receipt"]).model_dump(mode="json")
+ receipt = self.receipt_type.model_validate(row.data["receipt"]).model_dump(mode="json")
if set(receipt) != {
"version",
"event_id",
@@ -116,9 +124,9 @@ async def _record(self, event_id: str, project: str, repository: str) -> Data:
"digest",
}:
raise ValueError("Resume grant envelope is invalid")
- request = SCMResumeGrantInputV2.model_validate(receipt["request"]).model_dump(mode="json")
+ request = self.request_type.model_validate(receipt["request"]).model_dump(mode="json")
if (
- receipt["version"] != 2
+ receipt["version"] != self.grant_version
or type(receipt["version"]) is not int
or receipt["event_id"] != event_id
or receipt["project_id"] != project
@@ -138,7 +146,7 @@ async def _grant(
) -> Data | None:
async with self.store._unit() as session:
row = await self.settings.find_for_repository(identifier(repository))
- state = grant_state(row.data if row else {})
+ state = grant_state(row.data if row else {}, self.state_key, self.grant_version)
pointer = state["heads"].get(key) if key else state["requests"].get(project + ":" + str(request_id))
latest = None
if key:
@@ -146,7 +154,7 @@ async def _grant(
select(ForgeAccessEventEntity.id)
.where(
ForgeAccessEventEntity.project_id == identifier(project),
- ForgeAccessEventEntity.data["kind"].as_string() == EVENT_KIND,
+ ForgeAccessEventEntity.data["kind"].as_string() == self.event_kind,
ForgeAccessEventEntity.data["receipt"]["repository_id"].as_string() == repository,
ForgeAccessEventEntity.data["receipt"]["binding_key"].as_string() == key,
)
@@ -191,7 +199,7 @@ async def current(self, project: str, repository: str, binding_request: str) ->
raise ValueError("Read resume grants outside a caller transaction")
actor = CURRENT_PRINCIPAL.get()
await self.authority._reader(actor, project, repository, admin=False)
- binding = await self.authority._existing(project, repository, binding_request)
+ binding = await self._current_binding(project, repository, binding_request)
if binding is None:
raise KeyError("V1 binding not found")
receipt = await self._grant(project, repository, key=binding["binding_key"])
@@ -199,7 +207,12 @@ async def current(self, project: str, repository: str, binding_request: str) ->
raise KeyError("No separately consented V2 grant exists")
return await self._return(actor, receipt, admin=False)
- async def _binding(self, project: str, repository: str, request: SCMResumeGrantInputV2) -> Data:
+ async def _current_binding(self, project: str, repository: str, request_id: str) -> Data | None:
+ return await self.authority._existing(project, repository, request_id)
+
+ async def _binding(
+ self, project: str, repository: str, request: SCMResumeGrantInputV2 | SCMPollResumeGrantInputV3
+ ) -> Data:
binding = await self.authority._existing(project, repository, request.expected_binding_request_id)
if binding is None:
raise AccessDenied("Review an exact current V1 mapping first")
@@ -233,10 +246,15 @@ async def _preflight(self, context: Data) -> None:
):
raise AccessDenied("The application repository grant is no longer available")
- async def configure(self, project: str, repository: str, request: SCMResumeGrantInputV2) -> Data:
+ async def _after_admission(self, context: Data) -> None:
+ """Allow transports to revalidate remote authority after admission waits."""
+
+ async def configure(
+ self, project: str, repository: str, request: SCMResumeGrantInputV2 | SCMPollResumeGrantInputV3
+ ) -> Data:
if current_unit_of_work() is not None:
raise ValueError("Admit resume consent outside a caller transaction")
- request = SCMResumeGrantInputV2.model_validate(request.model_dump(mode="json"))
+ request = self.request_type.model_validate(request.model_dump(mode="json"))
actor = CURRENT_PRINCIPAL.get()
await self.authority._reader(actor, project, repository, admin=True)
assert actor is not None
@@ -275,7 +293,7 @@ async def configure(self, project: str, repository: str, request: SCMResumeGrant
row = await self.settings.find_for_repository(identifier(repository))
if row is None:
raise AccessDenied("Configure collaboration before consenting to resume")
- state = grant_state(row.data)
+ state = grant_state(row.data, self.state_key, self.grant_version)
previous = await self._grant(project, repository, key=binding["binding_key"])
if request.expected_previous_digest != (previous["digest"] if previous else None):
raise AccessDenied("Resume grant revision changed; preserve the reviewed request")
@@ -288,7 +306,7 @@ async def configure(self, project: str, repository: str, request: SCMResumeGrant
):
raise ValueError("Resume grant retention limit reached")
retained = dict(
- version=2,
+ version=self.grant_version,
event_id=uuid4().hex,
project_id=project,
repository_id=repository,
@@ -302,13 +320,13 @@ async def configure(self, project: str, repository: str, request: SCMResumeGrant
request_digest=digest(incoming),
)
retained["digest"] = digest(retained)
- SCMResumeGrantReceiptV2.model_validate(retained)
- envelope = {"kind": EVENT_KIND, "receipt": retained}
+ self.receipt_type.model_validate(retained)
+ envelope = {"kind": self.event_kind, "receipt": retained}
total = await session.scalar(
select(
func.coalesce(func.sum(func.octet_length(cast(ForgeAccessEventEntity.data, Text))), 0)
).where(
- ForgeAccessEventEntity.data["kind"].as_string() == EVENT_KIND,
+ ForgeAccessEventEntity.data["kind"].as_string() == self.event_kind,
ForgeAccessEventEntity.data["receipt"]["repository_id"].as_string() == repository,
)
)
@@ -326,7 +344,7 @@ async def configure(self, project: str, repository: str, request: SCMResumeGrant
pointer = {"event_id": retained["event_id"], "digest": retained["digest"]}
state["heads"][binding["binding_key"]] = pointer
state["requests"][project + ":" + request.request_id] = pointer
- row.data = {**row.data, STATE_KEY: state}
+ row.data = {**row.data, self.state_key: state}
await self.settings.save(row)
return await self._return(actor, retained, admin=True)
@@ -459,7 +477,7 @@ async def _cut(self, context: Data) -> Data:
raise ValueError("Journal scalar columns do not match their immutable envelopes")
if any(item.plan_id != plan.id and item.status != "done" for item in tasks):
raise ValueError("An active task belongs to another plan revision")
- return resume_cut(
+ return self.cut_builder(
run=run,
plan={**copy.deepcopy(plan.data), "digest": plan.digest},
tasks=[
@@ -587,6 +605,8 @@ async def authorize_feedback(self, receipt_id: str) -> None:
context = await self._context(delivery)
await self._preflight(context)
await self.store.admit_credentials(delivery)
+ context = await self._context(await self._delivery(receipt_id))
+ await self._after_admission(context)
await self._context(await self._delivery(receipt_id))
if delivery.get("command", {}).get("action") == "resume" and delivery.get("action_status") == "completed":
await self._verify_completion(delivery["action_result"])
@@ -607,6 +627,8 @@ async def process(self, receipt_id: str) -> Data:
await self._preflight(context)
await self.store.admit_credentials({"command": command.model_dump(mode="json"), "grant": context["grant"]})
context = await self._context(await self._delivery(receipt_id))
+ await self._after_admission(context)
+ context = await self._context(await self._delivery(receipt_id))
async with self._locked(initial, context) as row:
context = await self._context(row.data)
if row.data.get("action_status") == "completed" and row.data["status"] == "processed":
diff --git a/src/firefly_forge/store.py b/src/firefly_forge/store.py
index 50629eef..bcf8f483 100644
--- a/src/firefly_forge/store.py
+++ b/src/firefly_forge/store.py
@@ -26,6 +26,8 @@
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
+from firefly_forge.activity_models import MAX_EVENT_BYTES, MAX_EVENTS, MAX_RUN_BYTES, project_activity
+from firefly_forge.activity_persistence import ForgeEventContentEntity, ForgeEventContentRepository
from firefly_forge.agent_roles import ProjectRoles, RoleRegistry
from firefly_forge.collaboration_persistence import ForgeProductDefinitionRepository
from firefly_forge.configuration import ConfigurationStore
@@ -160,6 +162,7 @@ def __init__(
repository_records: ForgeRepositoryRepository | None = None,
run_records: ForgeRunRepository | None = None,
event_records: ForgeEventRepository | None = None,
+ event_content_records: ForgeEventContentRepository | None = None,
deployment_records: ForgeDeploymentRepository | None = None,
product_records: ForgeProductDefinitionRepository | None = None,
campaign_records: ForgeCampaignRepository | None = None,
@@ -193,6 +196,7 @@ def __init__(
self.repository_records = repository_records or ForgeRepositoryRepository()
self.run_records = run_records or ForgeRunRepository()
self.event_records = event_records or ForgeEventRepository()
+ self.event_content_records = event_content_records or ForgeEventContentRepository()
self.deployment_records = deployment_records or ForgeDeploymentRepository()
self.product_records = product_records or ForgeProductDefinitionRepository()
self.campaign_records = campaign_records or ForgeCampaignRepository()
@@ -840,11 +844,45 @@ async def assert_campaign_current(self, run: Data, *, action_id: str | None = No
)
async def event(self, value: str, stage: str, message: str) -> None:
- data = {"id": uuid.uuid4().hex, "stage": stage, "message": message[:8000], "created_at": now()}
+ data: Data = {"id": uuid.uuid4().hex, "stage": stage, "message": message[:8000], "created_at": now()}
+ provider_output = stage in {"stdout", "stderr"}
+ if provider_output:
+ await self.admit_credentials(message)
async with self._unit():
+ payload = None
+ if provider_output:
+ run = await self.run_records.find_by_id(identifier(value), lock=LockMode.PESSIMISTIC_WRITE)
+ if run is None:
+ raise KeyError("Run not found")
+ count, size = await self.event_content_records.budget(run.id)
+ message_size = len(message.encode("utf-8", errors="replace"))
+ projected, payload = project_activity(
+ run.id.hex,
+ data["id"],
+ stage,
+ message,
+ retain=count < MAX_EVENTS
+ and size + message_size <= MAX_RUN_BYTES
+ and message_size <= MAX_EVENT_BYTES,
+ )
+ data.update(projected)
+ self.reject_credentials(message)
await self.event_records.save(
ForgeEventEntity(id=identifier(data["id"]), run_id=identifier(value), data=data)
)
+ if payload is not None:
+ descriptor = data["activity_content"]
+ await self.event_content_records.save(
+ ForgeEventContentEntity(
+ id=identifier(data["id"]),
+ event_id=identifier(data["id"]),
+ run_id=identifier(value),
+ version=1,
+ byte_count=len(payload),
+ sha256=descriptor["sanitized_message_sha256"],
+ raw_bytes=payload,
+ )
+ )
async def finish(self, value: str, status: str, *, error: str | None = None) -> None:
if status not in TERMINAL:
diff --git a/studio/package-lock.json b/studio/package-lock.json
index 454028e4..64d20d38 100644
--- a/studio/package-lock.json
+++ b/studio/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "firefly-forge-studio",
- "version": "0.2.24",
+ "version": "0.2.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firefly-forge-studio",
- "version": "0.2.24",
+ "version": "0.2.26",
"license": "Apache-2.0",
"dependencies": {
"lucide-react": "^0.577.0",
diff --git a/studio/package.json b/studio/package.json
index 208a76a6..0faa45cf 100644
--- a/studio/package.json
+++ b/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "firefly-forge-studio",
- "version": "0.2.24",
+ "version": "0.2.26",
"private": true,
"type": "module",
"license": "Apache-2.0",
diff --git a/studio/src/App.tsx b/studio/src/App.tsx
index c02db0b5..ef879636 100644
--- a/studio/src/App.tsx
+++ b/studio/src/App.tsx
@@ -72,6 +72,7 @@ import {
TimelineEventView,
} from "./ExecutionPresentation";
import { RunVerification } from "./RunVerification";
+import { ProviderActivityView } from "./ProviderActivityView";
import { DeliverySetup, RunDelivery } from "./Delivery";
import { Operations } from "./Operations";
import { Projects } from "./Projects";
@@ -2844,7 +2845,17 @@ function RunDetail({