Current Weftra Capabilities
GENERATED from docs/features.yml — do not hand-edit; regenerate with npm run docs:capabilities.
This page lists every Weftra capability that is shipped, in beta, or partially available, for a user, operator, or admin audience — rendered directly from the capability traceability manifest (docs/features.yml) so it cannot drift from the traced source of truth. See docs/OPERATIONS.md §12 ("Capability manifest") for how the manifest works and how to keep it, and this page, in sync.
Capabilities not yet generally available (planned, superseded) and internal-only or contributor-facing capabilities are intentionally omitted from this page.
Status legend: 🟢 SHIPPED — production-ready · 🧪 BETA — available, still maturing · 🟡 PARTIAL — partially implemented, scope may be limited
Intake And Clarification
Status: 🟢 SHIPPED Audience: User
Create and manage feature/work-item requests, pre-execution analysis, and the clarification Q&A workflow (including spec-kit's clarification gate) before a feature is queued for implementation. Every submission also records which DOOR it came through (authored_via) on the feature row and on a feature_submitted run event. Spec 484 inc-1 (RM-368): an already-answered 'agent' clarification can be AMENDED before the spec is written, instead of waiting for spec approval to fix a sentence left out of an answer. Two routes share one implementation (src/services/clarification-amend.ts): a project key may amend only an answer it itself gave (principal_class: 'tenant' on the latest clarification_answered event); an admin/approver may amend any 'agent' answer, but amending one already reviewer_side (part of the conformance rubric) runs the same separation-of-duties evaluation the admin answer route runs and is BLOCKED outright on refusal (clarification_amend_refused, nothing changed) — otherwise the feature's own submitter could take an answer out of the rubric by amending it. Refused once a spec record or an author_clarifications_given event exists for the feature, so the rubric the Author was already handed can never be rewritten underneath the reviewer. Every amendment appends a row to the additive clarification_answer_revisions table (text lives there; the ledger carries only hashes) and writes a clarification_amended event plus a fresh clarification_answered event in the existing shape, so resolveRubricClarifications grades the amended text with no change to src/services/review/. GET /api/features/admin/:id/clarifications/:clarificationId/ history reads the revisions back; the dashboard gained a matching Amend-answer control and History toggle, built with DOM APIs only. No status change, no re-analysis — a feature whose analysis was already in flight when the answer changed still finishes against the previous text (increment 2, not yet built, redoes that analysis).
Public surfaces:
POST /api/featuresPOST /api/features/:id/clarifications/:clarificationId/answerGET /api/features/:idPOST /api/features/:id/clarifications/:clarificationId/amendPOST /api/features/admin/:id/clarifications/:clarificationId/amendGET /api/features/admin/:id/clarifications/:clarificationId/history
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Web Reading
Status: 🟢 SHIPPED Audience: Admin
An operator-kept allowlist of documentation hostnames, and a bounded, hashed text snapshot of one allowed page fetched inside an egress-bound sandbox (no agent consumer yet). An admin keeps the allowlist and previews a page; the fetch reuses the sandboxed one-host fetch path (HTTPS-only GET, byte cap, timeout, address pinned through the enforced egress forwarder) and never follows redirects. The admin dashboard's Settings tab carries a panel to edit the allowlist and preview one page through the same routes. A project may request a documentation domain and read its own requests, and an admin may list them across projects; decisions arrive in a later increment and no fetch uses a grant.
Public surfaces:
GET /api/admin/web-reading/allowed-domainsPUT /api/admin/web-reading/allowed-domainsPOST /api/admin/web-reading/previewPOST /api/project/web-domain-requestsGET /api/project/web-domain-requestsGET /api/admin/web-reading/domain-requestspublic/index.html#web-reading-add-btnpublic/index.html#web-reading-preview-btn
Docs:
docs/OPERATIONS.md
Builder Spec Drafting
Status: 🟢 SHIPPED Audience: User
Conversational, AI-assisted drafting and refinement of a feature spec from plain English, including clarifying questions, cost/scope estimate, and submission.
Public surfaces:
POST /api/builder/draftPOST /api/builder/refinePOST /api/builder/submit
Docs:
docs/USER_GUIDE.md
Feature Lifecycle
Status: 🟢 SHIPPED Audience: User
The governed feature state machine (pending through queued/in_progress/implemented/ merged, with revising/cancelled/failed side paths) and the agent-engine polling loop that drives transitions. Spec 454 inc-2 (RM-320 part 1): a Claude CLI run during which the model endpoint rejected Weftra's credential mid-run is never treated as a successful run, even when the CLI itself goes on to end the stream with result subtype: "success". A pure classifier (src/services/agent/engine-auth-failure.ts) reads the envelope fields the pinned CLI writes on the assistant event it synthesises for an API error (is_api_error_message, error: "authentication_failed", message.model: "<synthetic>", confirmed against real captures committed as fixtures). It never reads tool output, model text, the result event, or stderr. The runner rejects with a typed marker regardless of the CLI's own success signal, and the implement flows rethrow it rather than continuing on produced files, so there is no commit, push or PR. Because a same-uid process in the sandbox can forge a stream line, the classification alone only fails the run. The awaiting_auth recovery path (host token refresh + re-queue, an uncharged attempt) additionally requires Weftra-side corroboration: an operator-default credential, oauth + 401, and a stale host credential (src/services/agent/engine-auth-corroboration.ts). Every other case becomes the non-escalatable, charged run_failure cause engine_auth, which is never attributed to the operator and is excluded from Weftra's automatic failed-feature retry. A feature paused in awaiting_auth now raises a fleet-attention item (reauthenticate_engine) instead of being invisible there. Governed revert, increment 1 (RM-468): an admin or linked approver can revert a merged feature with POST /api/features/:id/revert. Weftra builds the revert in the sandbox on a fresh revert/<slug>-<id8> branch, opens a draft revert PR with the project's own credential, and records revert_requested, then revert_pr_opened or revert_refused, on the original feature's ledger. Nothing is pushed to the base branch, the feature stays merged, and a person merges the revert PR. Increment 2a (RM-468): the revert PR becomes a tracked feature in the same project, linked to the original, and Weftra's existing merge polling records reverted on the original when it merges (the original stays merged). Increment 2b (RM-468): the revert PR is reviewed when the project arms the reviewers, against a fixed revert checklist Weftra writes at creation, and a change request on a revert escalates to a person: Weftra starts no agent round on a revert by itself (no auto-revise, no CI-remediation round, no Security Fixer round), and the loop never auto-merges one. A person's own /revise still runs. Not built yet: ship-dark gating of the whole revert flow.
Public surfaces:
GET /api/features/:idPOST /api/features/:id/cancelPOST /api/features/:id/retryGET /api/features/admin/attention#reauthenticate_enginePOST /api/features/:id/revertpublic/index.html#revert-btn
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Approval And Autonomy Policy
Status: 🟡 PARTIAL Audience: Operator
Product-owner approval gate (po_approval autonomy mode) and the autonomy-mode policy floors (full_auto/auto_safe/po_approval) that decide whether a feature needs a human approval step before it is queued. Also covers the LATER, separate require_spec_approval checkpoint (spec 299): Weftra pauses in awaiting_spec_approval after it authors a spec and before any branch/commit work, so an approver reviews the rubric Weftra itself wrote (or edits it) via GET /:id/pending-spec / POST /:id/approve-spec / POST /:id/reject-spec. Since spec 443 inc-8 (RM-262 g), that approval binds to BYTES, not to a workspace: a later run — including a cold /retry that wiped the workspace — reinstates the approved bytes at spec_path (or recognises them if the workspace still holds them) instead of re-authoring and re-pausing, carries the approved record's own provenance forward, and force-stages the reinstated/recognised spec onto the commit the same way a freshly authored one is. Changing spec_path after approval invalidates the binding and the checkpoint pauses again. Since spec 443 inc-23 (RM-277), the authoring pass also ends its spec with a fixed ## Prior art and evidence declaration (what it found already implemented, and any claim it could not verify by reading); Weftra checks only whether each cited path exists in the restored workspace tree — after the spec-368 restore, never the tree the pass left behind — and never opens or judges it, records the result on the same spec record row (authoring_evidence, checked: "paths_exist_only"), and shows it to the approver via GET /:id/pending-spec before the hash is bound. A missing or unparseable declaration is recorded honestly and never fails the run or blocks the pause.
Public surfaces:
POST /api/features/:id/approvePOST /admin/:id/approveGET /api/features/:id/pending-specPOST /api/features/:id/approve-specPOST /api/features/:id/reject-spec
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: A spec-kit run records a spec_approval_skipped event (flag_off or spec_kit_flow) and continues the pipeline; under an operator require_spec_approval: required floor it is rerouted through the standard flow, so the checkpoint applies (RM-236). Spec 472 inc-2 (RM-348 b): at the project's declared separation_policy.submitter_approver level require/require_proven (require is the default), POST /:id/approve and POST /:id/approve-spec additionally refuse any approval whose credential does not resolve to a verified NAMED person — 403 approver_unverified, reason: named_approver_required, no remediation — closing the gap where the shared ADMIN_API_KEY could approve a project-key submission under the role-credential carve-out in checkSeparationOfDuties. off/record are unchanged, and every other caller of the underlying evaluateApprovalSeparation (@weftra merge's submitter_merger pair, /:id/amend-spec, the clarification-answer route) is unaffected — only the two approve routes layer the new evaluateApprovalActSeparation. Spec 494 (RM-390): an approval is bound to the text that was approved. approve stores approved_content_hash; a project-key edit of an approved title/description is refused (409 approved_text_locked) except on failed/cancelled, where it re-opens the approval (approval_reopened) so /retry returns to awaiting_approval; an admin edit rebinds the hash and records approved_text_amended. There is no dispatch-time re-check in the agent flows. Spec 472 inc-3 (RM-348 c): the Evidence Pack states the separation facts as they are — per-approval submitter kind and five verdicts (satisfied, not_asserted, indeterminate, self_approved, not_evaluated); the configured level and the self-approved/not-evaluated distinctions are shown to admin callers only. Approval refusal logic is unchanged.
Engine Abstraction And Routing
Status: 🟡 PARTIAL Audience: Operator
The pluggable agent-engine abstraction (Claude Code, ACP, headless transports), multi-engine profiles/model routing, and the independent reviewer/analyze/answerer/ revise engine+model selection. Engine.enforcesAllowedTools (spec 443 inc-3, RM-262 e — mirroring enforcesBudgetCap) is a REQUIRED per-engine capability signal: true for claude-code (the only transport that reads task.allowedTools), false for ACP and headless. requireToolGrantEnforcement refuses a task whose allowedTools is non-empty on a non-enforcing engine, applied both by the two non-enforcing adapters themselves and unskippably at the registerEngine choke point. The standard flow's fa_authored spec-authoring pass (implement.ts step 2g) checks this BEFORE its turn and fails the run (spec_author_posture_refused) rather than authoring a spec under a grant nothing enforces — see the authored-spec-approval-checkpoint note in provenance-and-audit below for the ledger side of this. Since spec 443 inc-51 (RM-295 part 1), the ACP mid-run permission gate's whole lifecycle is durable on the run-event ledger: permission_requested (before the approver is notified), permission_decided (with the deciding principal's decided_by_class — user/admin_role/project_key/unknown, derived from the already-proven credential, never the request body), and permission_expired (fail-safe timeout). A ledger write that fails is fail-closed on the GRANT (no gate registered, no notification) and fail-open on the DENY (a decided-write failure leaves the gate pending and returns a distinguishable not_recorded result → 500, never the 404 an unknown id gets; an expired-write failure still denies). A project-key ledger read withholds actor/ actor_verified on permission_decided (redactPermissionDecisionPayload) while keeping decision/tool_name/decided_by_class/waited_ms; admin reads and the SIEM export keep every field. Each gate also records the role of the run that raised it (author/revise/security_fixer/helper, spec 505): all three events carry raised_by_role, and a project key resolves only an author gate — a revise, security-fixer or helper gate answers 403 and needs an approver or admin on the admin route.
Public surfaces:
GET /api/admin/enginesper-feature engine override fieldrevise_model/revise_engine (project + feature, admin-only)revise_escalation_model (project + feature, admin-only)GET /api/projects/:id/workflowGET /api/project/workflowpublic/index.html#on:closeModal
Docs:
docs/ENGINES.mddocs/SETUP.mddocs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: The Tier-1 classifier also predicts a complexity tier (light/standard/deep, fail-closed to standard), recorded on routing_rationale.complexity and the routing_decision event; a role's classifier config may map the tier to an engine via a complexity map consulted after the category map (spec 546 inc-1, RM-467). Only the claude-code transport enforces tool grants. Closed by RM-334: every stored per-role engine slot (security, fixer, designer, and the static fields of the other roles) is checked against the registry at resolution, so an engine pinned by an admin and later removed is skipped with the skip recorded in the routing record (independence is judged against the engine that ran); the admin project and feature write paths refuse an unknown id with a 400 before any write. Remaining: getEngine() still falls back silently to the default engine for any unregistered id (many direct callers, a separate item), and the security stage still runs on the default when a distinct pin is skipped rather than failing closed (a named follow-up). A project's feature-build workflow is readable in one place by an admin or a linked approver: GET /api/projects/:id/workflow (spec 557 inc-1, RM-411) returns every step in run order with its engine, model, profile, tool grant and the source of each value, resolved by calling the flows' own resolvers over a project-only view and pinned equal to them by a test; configured-but-unused values appear as applied: false. The same response lists each live worker deployment's agent-run profile under agent_runs (spec 557 inc-2, RM-411): the engine, model and profile an episode resolves from the worker project, a declared engine profile or model an episode does not use as applied: false, a conditional runtime, and each compiled step's tools at the deployment's autonomy, pinned equal to the episode resolvers by a test. The dashboard has a read-only Workflow button and modal beside Outcomes for admins and approvers (spec 557 inc-3, RM-411), and a project key reads its own workflow at GET /api/project/workflow (API-only: the dashboard has no project-key sign-in, so the modal does not show the project-key view yet): an allowlist projection where steps a project cannot configure read as operator-set, operator-profile values read operator_set, and worker deployments are not shown; a sentinel test asserts no withheld value appears.
Helper Roles Mid Run Delegation
Status: 🟢 SHIPPED Audience: Operator
Declared helper roles (name -> engine+model+tool-allowance+budget) a running agent may invoke BY NAME mid-run via a provisioned MCP tool, at a tier independent of the parent run's own model — governance (shared budget draw-down, per-run invocation cap, depth 1, sandboxed dispatch) over the native CLI-subagent capability, plus the cost tiering that path can't provide. Roles used only during authoring are project-declared; roles reachable from a control (the spec-conformance reviewer, the Security Fixer, the intent gate) are admin-only to declare and to read.
Public surfaces:
helper_roles (project + admin, self-configurable)admin_helper_roles (admin-only, write and read)POST /api/internal/helper-roles/invoke (run-scoped ephemeral bearer token only)run-event types: helper_invocation, helper_refusal, helper_invocation_control, helper_refusal_control
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Agent Profiles
Status: 🟢 SHIPPED Audience: Operator
A named {name, engine_id, model, prompt_extension} bundle selectable BY NAME per role (author/reviewer/fixer/security/analyze/answerer/revise/maintainer), resolving through the existing engine/model routing chain and contributing TEXT only — never a process or a credential. Subsumes the standalone security_context and code_discipline prompt-extension special cases onto one shared mechanism.
Public surfaces:
agent_profiles / agent_profile_roles (project, self-configurable)admin_agent_profiles / admin_agent_profile_roles (admin-only, write and read)
Docs:
docs/USER_GUIDE.mddocs/design/agent-inputs.md
Sandboxed Runtime
Status: 🟡 PARTIAL Audience: Operator
Configurable runtime image, Docker-sandboxed agent execution, declared sandbox services (e.g. ephemeral Postgres), and sandboxed repository cloning — the trust boundary every agent run executes inside (see CLAUDE.md's Trust Boundary core law). The network a run attaches to is checked against the operator's allowlist: host, container and other runs' networks are never accepted, at submission or at run time. The agent job container's kernel-side hardening (cap-drop ALL, no-new-privileges, a Weftra-shipped seccomp profile, a read-only rootfs) is brought to the bootstrap git-clone container's existing parity via one shared sandboxHardeningArgs() ceiling, strict by default with a recorded compat opt-out (spec 405 inc-1). Declared services/env are provisioned on implement (standard and spec-kit), revise, and security-fixer rounds alike via a shared declared-environment module; all apply the same class-based withhold over the merged tenant env layer (dynamic loader, interpreter startup, command lookup / exec hooks, model routing, proxy, TLS trust — silent, logged by key name only) before it reaches the container, and each records a services_started run event naming declared/injected/withheld key and service names, never a value (spec 450 inc-14, inc-15, inc-17). The same withhold also covers the designer render's credential-holding container and the environment provision-check runner (spec 450 inc-17). An opt-in, operator-only per-project npm cache (FA_SANDBOX_NPM_CACHE=project) mounts <root>/<project_id> read-write at /fa-cache/npm with patient npm fetch-retry env, for the implement (standard and spec-kit), revise, security-fixer and gate-rerun flows only — isolated between projects by a containment check, shared on purpose between runs of one project (spec 475 inc-1). An operator-set size cap on the cache root (FA_SANDBOX_NPM_CACHE_MAX_MB) is enforced only when a sweep runs — it is NOT a quota, so a live run can still take the root over the cap (and fill the volume) until it ends, and the root belongs on its own volume. The sweep is requested in the background on every mount and evicts whole project directories least-recently-used first (by the run-event ledger, never a filesystem timestamp), never one that is in use; the cap is one shared number, not a per-project quota, so a large cache can still cost another project its idle cache (a re-download). An admin can inspect per-project usage and clear one project's cache through two admin-only routes, each clear recorded (spec 475 inc-2), and from a Settings-tab card; the Evidence Pack states the head run's cache posture from that run's own npm_cache_posture event (spec 475 inc-3). The default and browser images carry a native-build toolchain (python3/make/g++) and npm_config_nodedir, so a package's node-gyp fallback build compiles with no network at all when its prebuilt binary cannot be fetched (spec 475 inc-4). An admin can declare a runtime_base_image (project or feature; admin-only, allowlist-bound, mutually exclusive with runtime_image) and Weftra builds the sandbox image from it by copying a constant agent layer onto it with no RUN inside the base, cached by base digest and layer version (spec 514). An admin can also declare runtime_writable_paths (project or feature; admin-only, at most 8 validated absolute paths) and Weftra builds a further image whose only content is a COPY --chown of those directories to the sandbox user, with no RUN, no network and no secrets, cached by source image ID, paths and owner; it applies to the agent image and the declared test image, and a missing path fails the run before any engine turn (spec 517).
Public surfaces:
RUNTIME=docker (default) confignpm run verify:cli-contractFA_SANDBOX_HARDENING (strict default | compat opt-out)public/index.html#on:loadSandboxNpmCache
Docs:
docs/RUNTIME_IMAGES.mddocs/SETUP.mddocs/OPERATIONS.mddocs/operations/security-model.mddocs/USER_GUIDE.md
Limitations: Spec-kit enrollment runs specify init and the constitution agent in one flow-owned sandbox under RUNTIME=docker, not on the Weftra host; the curated fa-runtime image provides the pinned specify CLI (RM-059). Weftra's own dependency install is covered by a live test through the enforced egress forwarder (tests/spec-540-npm-ci-under-allowlist-forwarder.test.ts) but that suite has not yet been observed passing on a Docker host, so the self-project stays on the open egress setting until it is (RM-225).
Execution Dirt Isolation
Status: 🟢 SHIPPED Audience: User
Attribute every workspace write during an implement or /revise run to the phase that made it — the agent's own turn, or Weftra itself running a declared setup_command/ test_command/verify_command/declared gate — so a project's own tooling side effects (a build step rewriting a doc, a test run creating scratch files) never enter the candidate commit as if the agent had authored them. A path the agent ever touched is always kept; a path touched only by tooling is restored to its prior committed bytes or removed, both before each blocking gate runs (so gates read the bytes that ship) and at staging time. A project that wants a generated path committed anyway declares it via generated_deliverables. On /revise, an excluded set is additionally recorded as a bounded test_side_effects run event and named (by count only, never by path) in the round's PR comment.
Public surfaces:
none declared
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Empty Commit Guard Excludes Fa Own Artifacts
Status: 🟢 SHIPPED Audience: User
The empty-commit guard behind every implement/spec-kit run: a candidate commit whose only in-workspace changes are files Weftra ITSELF wrote — the provenance record (.fa/provenance/<id>.md), the spec of record at docPath, the verification artifact captured from a declared verify_command, or any declared generated_paths this run's own regeneration rewrote — is not evidence the agent implemented anything, so the run FAILS ("Implementation produced no changes") instead of committing, pushing, and opening a PR. .fa/** and docPath are a static shape (isFaAuthoredPath); everything else Weftra writes into the workspace at a run-determined, non-static path (the verification artifact, the empty-repository bootstrap .gitignore) is tracked in a run-scoped faWrittenPaths set, populated only once the write that produced it actually succeeds (a rejected or failed write registers nothing, so a change at that path still counts). Spec 454 inc-1 (RM-320 part 2) closed the fourth instance of this defect class: the verification artifact landed before the guard sampled the tree and was neither .fa/** nor docPath, so a sandboxed author pass that died mid-turn with zero tool calls still shipped a PR (a pilot run, feature 49cd04ed, PR #9, 2026-09-25). A source-derived tripwire test requires every host write into the workspace in implement.ts/ implement-spec-kit.ts to be either registered into that run's set or named in an in-source classification list with a stated reason, so a future Weftra-authored write cannot reopen the same gap unnoticed. A single genuine agent-authored change — even one in the same directory as the verification artifact — still ships exactly as before.
Public surfaces:
none declared
Docs:
docs/USER_GUIDE.md
Seccomp Profile Name Support Check
Status: 🟢 SHIPPED Audience: Operator
Startup + on-demand validation that the Docker host's own runc/libseccomp can actually resolve every syscall name in the Weftra-shipped seccomp profile (containers/seccomp/fa-default.json). runc silently DROPS a name its libseccomp does not recognise — nothing surfaces in the Docker API or docker run's own output — so Weftra now names the drop instead. A companion support table (containers/seccomp/fa-default.support.json) classifies every profile name against the libseccomp version that first resolves it; one startup line reports validated | degraded | not-checked (never claiming a validation that did not run), and npm run diagnose:seccomp prints the full report on demand. Reporting only — no run behaviour, argv, or tenant-visible surface changes.
Public surfaces:
startup log line ([seccomp] support check: …)npm run diagnose:seccomp
Docs:
docs/OPERATIONS.md
Agent Runtime Egress Allowlist
Status: 🟢 SHIPPED Audience: Operator
Egress allowlist for the agent job container (DockerRuntime.start) — an operator/project may bound the sandboxed agent to a declared set of hosts on 443, reusing the bootstrap-clone egress forwarder (git-egress.ts, spec 291) generalized to N hosts. Fail-closed when active. Distinct from (and independent of) the bootstrap-clone's own enforced egress. An allowlist entry may optionally narrow a host allowance to specific calling binaries (binaries:, spec 349 / RM-123) — absent it, an entry stays host-only; present, the connection must match BOTH the host and one of the listed calling binaries, enforced at the same spec-342 seam and fail-closed on any malformed/unknown binary identity. As of spec 405 inc-2 (RM-194), allowlist is the DEFAULT posture: when nothing more specific resolves the policy, createRuntime computes an allowlist itself (the run's model-endpoint host + its own VCS host + the project's own declared hosts) instead of the open bridge; open (FA_RUNTIME_NETWORK/FA_RUNTIME_EGRESS=open) is the explicit, recorded opt-out, surfaced on the per-feature provenance artifact's "Runtime egress" row. As of spec 443 inc-1 (RM-262 a), the resolved model-endpoint host is unioned into every run resolving to allowlist mode (not just the computed default) so a project cannot narrow its own model endpoint away by declaring its own hostlist; under an operator-wide bound the union is restored only when the operator's own list already permits that host, so the effective allowlist's host set stays a provable subset of FA_RUNTIME_EGRESS_ALLOWLIST.
Public surfaces:
FA_RUNTIME_EGRESS / FA_RUNTIME_EGRESS_ALLOWLIST (global env)FA_RUNTIME_NETWORK (non-empty) — spec 405 inc-2 explicit operator opt-out from the default allowlistruntime_egress / runtime_egress_allowlist (project, self-configurable)runtime_egress_allowlist entry {host, binaries?} (spec 349, optional narrowing)
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.mddocs/SETUP.mddocs/operations/security-model.mddocs/design/openshell-comparison.md
Environment Provisioning
Status: 🟡 PARTIAL Audience: Operator
.fa/environment.yml manifest, environment/scaffold detection, scaffold generation, and provision/readiness checks that prepare a project's declared runtime environment before an agent runs. The provision-check clones the project's repository before running a declared setup_command/test_command/verify_command (RM-262 c) — a repository-dependent command is verified against the real tree, or reported as NOT verified rather than a false pass. The check now runs the project's declared test_command too (RM-267), bounded by TEST_GATE_TIMEOUT_MS — the same command Weftra blocks every real feature run on, so a green check predicts a gate that can run. A TEST_GATE_TIMEOUT_MS breach is only reported when the deadline verifiably fired; any other failure to finish is still failed but names no bound. Captured step output is redacted on BOTH streams (stdout as well as stderr) against this run's own injected env/service values, not just Weftra's configured secrets. When runtime_egress is in allowlist mode, a failed repo/setup/test/verify step whose captured tail reports a name-resolution failure for a host that IS on this run's own allowlist gets a dedicated MISSING -> HOW TO FIX entry naming the host, the fact it is allowed, and the proxy env vars Weftra set (RM-291) — a toolchain that ignores HTTPS_PROXY/https_proxy/ NO_PROXY/no_proxy sees an allowed host as a DNS outage, and Weftra now says so instead of leaving it undiagnosed. The check also runs a capability preflight (RM-287 addendum 3, spec 443 inc-43) whenever its own repo step exists: git ls-remote --heads and a non-destructive git push --dry-run against the repository's existing default branch, both through the same sandboxed runSandboxGit seam and the same resolved credential the repo step's clone already uses. This observes whether the credential can enumerate refs and whether the remote authorizes a push-capable session for it — the class of deficiency (a read-only token) that previously passed every check Weftra had and only surfaced when a real implementation run's final push failed. Reported as an additive capability_probe block (never changes ok) from a closed, pure vocabulary (src/services/vcs-capability-probe.ts) — fixed severity/summary/remedy per operation, never text the remote printed. Not on the run-event ledger (an env check has no feature to key a row to); recorded via one redacted job-log line per operation plus the persisted report, same as every other env-check line. A failed check also names its CLASS (RM-287 addendum 3 part 2, spec 443 inc-47; extended by spec 443 inc-54): a closed, pure vocabulary (src/services/provision-failure-class.ts) of eight classes (interrupted, startup_error, credential_permission, egress_proxy_dns, registry_access, runtime_toolchain, repo_test_verify, missing_declaration), assigned STRUCTURALLY — at the producer site that created each missing[] entry, and from the failing outcome's own step kind — never by matching Weftra's own what/remediation prose. Every missing[] entry gains failure_class; the report gains primary_failure_class (present only when ok: false, the first class present in the vocabulary's own precedence order). human gains a FAILURE CLASS: section rendered only when a class applies, so a passing report's human stays byte-identical. registry_access is produced by the declared-host probe (spec 443 inc-75, RM-287 remainder); egress_proxy_dns is the one class still derived from captured output content, via the pre-existing detector above. Every check now also executes under a fresh, PER-RUN id (spec 443 inc-54, RM-267 addendum 2 — the id used to be a fixed envcheck-<project_id> reused by every run on a project, so a run killed before teardown left its workspace and docker resource names for the NEXT run to collide with), carried on the report as run_id. A durable marker (written before the run's first step, removed on every completion path) lets Weftra detect at startup that a run never finished and overwrite a stale persisted report with an honest interrupted report — ok: false, every step not-run — rather than silently continuing to serve an older result as current. A startup-only sweep also removes any leftover run workspace and its docker resources through the existing label-checked tearDownJobResources primitive (spec 434). GET .../env/check/logs keeps its URL/guard and now resolves the CURRENT run's log internally (the running run, else the run that produced the persisted report, else the legacy per-project log), since a per-run id moved the log file off one well-known name. A running check can be cancelled (spec 450 inc-7, RM-316 e part 2): POST .../env/check/cancel (admin or linked approver only — a project key is refused, the same guard as starting a check) records an env_check.cancel actor event fail-closed, then kills the run's registered containers; the persisted report is ok: false, cancelled: true, primary_failure_class: cancelled, completed steps keep their real outcomes and the rest are not-run. The Environment Readiness modal shows a Cancel check button while a check runs. When a sandbox starts, the check also probes each host the PROJECT declared on runtime_egress_allowlist, from inside that sandbox, through the same CONNECT forwarder its own commands use (spec 443 inc-75, RM-287 remainder). An unreachable host adds a registry_access entry naming only what was observed — not that the host is a package registry, not that a credential for it is valid — and is reported as an additive declared_host_probe block that never changes ok. Under open egress nothing is probed.
Public surfaces:
POST /api/projects/:id/env/checkPOST /api/projects/:id/env/check/cancelPOST /api/projects/:id/env/scaffoldpublic/index.html#env-check-cancel-btn
Docs:
docs/SETUP.mddocs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: The declared-host probe produces the registry-access class (spec 443 inc-75). Still open for the capability preflight: the provider-API scope read and the PR/MR list reads of RM-287 addendum 3 part 1, each its own increment.
Implementation Review
Status: 🟡 PARTIAL Audience: Operator
Independent implementation review (a separate reviewer engine/model from the implementor), reviewer run + PR/result posting, the reviewer verdict dashboard surface, and the bounded auto-revise loop that addresses review feedback. At a PR-sourced cap or no-progress escalation the reviewer arms one conformance Fixer round per feature on a distinct admin-set fixer_model, and the review after it is terminal. Spec 443 inc-10 (RM-262 o): a reviewer_verdict/APPROVE reviewer_final payload now names the commit it graded — head_sha/head_bound, non-null ONLY when the round's before-fetch and after-fetch PR-head reads agreed, i.e. no push landed during the diff fetch (the same before/after reads spec 306/307 already resolved — no new provider call). Escalation terminals carry neither field. The posted conformance PR comment names the short graded SHA, or states plainly the round was not commit-bound, so a merger sees staleness without fetching the evidence pack. Spec 443 inc-12 (RM-266): a head-bound APPROVE now TRACKS the head instead of being the last word on the feature — getImplementedForReview re-selects it once the live head has moved past the graded commit (one cheap live-head read before anything else runs, sha_unchanged skip otherwise), bounded by a new REVIEWER_MAX_REGRADES_PER_FEATURE lifetime cap (default 5, 0 restores the exact pre-inc-12 single-shot behaviour). A re-grade is a fresh round-1 of its own auto-revise lineage. Terminals the automatic path cannot reach (an escalation, an unbound approval, a patch-mode feature, or a feature at its cap) get an admin {"reopen": true} escape hatch on POST /:id/run-review, gated by a verifiable admin credential — never the dev-mode open-admin pass — which records ONE reviewer_reopened event naming the terminal it supersedes BY NAME before the review runs. Spec 443 inc-24 (RM-280): the reviewer runs with NO execution tools, yet could still assert a build/test OUTCOME ("the new assertions will fail") that Weftra's own recorded, head-bound build_verdict for that same commit already contradicts — measured on the yq pilot, a false finding of this shape blocked a round and burned an auto-revise round chasing a failure that never happened. A new closed-phrase classifier (execution-claim.ts) flags such a finding; when the classified claim is checked against a PASSING, head-bound build_verdict for the exact graded commit, it is recorded contradicted_by_evidence (naming the matched phrase and the build_verdict event) on reviewer_verdict/conformance_verdict and shown on the PR comment — additively; unmet_count/findings/no-progress are untouched. The verdict itself is never flipped: the contradictions are a separate local that is never merged back into verdict, and the parsed verdict object is handed to submit() unchanged (src/services/review/run-review.ts:731 computes them, :763 posts the review), so a phrase match cannot turn REQUEST_CHANGES into APPROVE. The only behavioural change is that a round whose findings are ALL contradicted escalates to a human instead of auto-revising. READ THE EVIDENCE FOR WHAT IT IS: a build_verdict PASS is the exit code of the project's OWN declared test_command (src/services/agent/gates.ts:951, const pass = exitCode === 0), and test_command is settable with a project API key (SELF_CONFIGURABLE_PROJECT_FIELDS, src/models/projects.ts:54) — a project that declares true gets a PASS on a tree that does not compile, so a contradiction block states "the command this project declared exited 0 on this commit", not "this commit builds" — which is why a contradiction may only ever buy a HUMAN ESCALATION. Spec 443 inc-44 (RM-280 addendum 3 part A) replaced the sentence that used to stand here — "a verify/lint claim is never contradicted, enforced on both sides" is now FALSE: execution-claim.ts grows from two claim classes to five (build/test/verify/base_behaviour/ structural), each answered ONLY by the evidence class that can answer it (build/test → build_verdict, verify → verify_gate — never the other; base_behaviour/structural have no answering class in this increment and are recorded on a new execution_claims payload array instead, never contradicted). A verify-class claim now IS contradicted, but ONLY by a verify_gate record that itself carries the exact graded head — the gate-rerun stage's own write (§24); an implement//revise/security-fixer-written verify_gate record carries no head at all and can never contradict anything. EvidenceClassRecord (evidence-classes.ts) gains method (declared_command|sandbox_execution| static_check|policy_eval|reviewer_judgment), producer ({role, engine, model}) and record_event_id — populated only from what the underlying row states, null on every not_run record — so contradicted_findings cites the real row (record_event_id) instead of a second, independent event search, and the PR comment names the evidence class, its method/producer role, and the event id alongside the matched phrase. The conformance evidence block (reviewer_verdict/ conformance_verdict) is now resolved HEAD-BOUND when the round itself is (previously always unbound) — one resolution per round, reused by the contradiction path — which can render build — not run (no_record_for_head) on a head-bound round where it previously rendered build ✅ pass for a record about a different commit; an unbound round is byte-identical to before. A contradiction still only ever buys a HUMAN ESCALATION — no threshold, gate, or auto-merge condition changes. Spec 443 inc-27 (RM-289): a REQUEST_CHANGES comment now ends with a Resolution block — kind/sentence/next_step, rendered by the new pure blocking-resolution.ts from the SAME decideAutoRevise result the round's own action already used (hoisted to one call site) — naming whether an auto-revise round is queued (round_pending) or, on an escalation, which of capped / no_progress / loop_disabled applies and the exact admin re-open (or manual /revise, when the loop is disabled config-wide) that clears it. An unresolved prior-decisions conflict is checked ahead of all of those and renders blocked_by_clarification instead: getRevisingFeatures, the revise flow's entry guard and POST /:id/revise all refuse a conflicted feature, so a queued-round promise (or a /revise step) would name work Weftra will not start until a human answers the standing clarification. Additive on conformance_verdict; a PASS/APPROVE round is byte-identical to before. Spec 443 inc-33 (RM-279): a revise round can now report a review finding as already_satisfied by code already in the BASE tree instead of inventing a change to "fix" something already true — the agent ends its output with one nonce-bound finding_dispositions block (already_satisfied|changed|disputed, each with path/line/quote evidence for already_satisfied); Weftra adopts it ONLY through the SAME nonce-bound extraction every other verdict parser here uses (parseFindingDispositions, review/revise-dispositions.ts), so a block planted in the untrusted PR feedback or echoed from repo content cannot be adopted. Weftra then mechanically re-checks EVERY already_satisfied pointer — EXISTENCE ONLY, never sufficiency — against the same base tree the round already resolved (flows/revise-disposition-verify.ts, reusing the flow's own sandboxed-git helper), records one revise_dispositions run event and posts one PR comment (buildRevisionDispositionsComment, agent/gates.ts) when at least one disposition parsed, and carries only the LEDGER-sourced, verified entries (getLatestVerifiedDispositions, models/run-events.ts) into the next spec-conformance review round's prompt as evidence — never as a rubric, never from a request body. Of each carried entry only the part Weftra VERIFIED crosses — the pointers and their quoted base-tree bytes; the reviser's free-text description of the finding is dropped there (nothing relates it to its evidence, and it is shaped by the PR feedback the round was answering) and stays on the ledger row and PR comment, where a human reads it. A round with zero parsed dispositions is byte-identical to before. That dispositions comment is Weftra's OWN writing and Weftra never reads it back as review feedback: it leads with an HMAC-signed fa:revision-dispositions marker under its own domain-separated subkey, and the same composed excludeFaAuthoredComments filter every site that reads a PR thread back already goes through now drops it too — the sharpest case of that rule, since the flow that POSTS this comment is the flow that READS the thread, so without it a round would be handed its own last report as "feedback to address" on a feature whose whole purpose is to END a review-revise loop. Selection follows the spec-339 narration contract (marker at the HEAD of the body, signature verified under a server-side key, so quoting the marker or reproducing the public literal cannot suppress anyone's comment); a filtered comment is recorded (revision_dispositions_filtered). Spec 443 inc-50 (RM-294 part 1): Weftra enforces byte-identity of the spec of record three layers deep and semantic reconciliation nowhere — nothing could say "the spec's §3.2 no longer describes what §3.2 shipped", and the reviewer structurally cannot raise it either, because the spec IS its rubric. A new pure module (spec-of-record-staleness.ts) resolves, once per round, whether this round is IN SCOPE to even ask: the feature has a persisted spec record AND a revise_started or security_fixer_started ledger event ran strictly after it was captured (parseDbTimestampMs, never new Date(...); an unparseable timestamp on either side is out of scope). BE HONEST ABOUT WHAT THE TRIGGER PROVES: a round RAN after the spec was recorded — never that it changed src/. When in scope, buildReviewerPrompt adds a fenceFor-fenced DATA section plus a "spec_stale": [] verdict field (empty array is normal; not a quality-grading channel; the reviewer must never propose spec edits — amending the spec of record is a human act); absent/empty, the prompt is byte-identical to before. ReviewVerdict gains specStale ({section, claim, shipped}[]), normalized exactly like inlineComments, capped at 20 entries/500 chars each; an APPROVE with non-empty spec_stale still parses as APPROVE — the parser never changes the decision. decideAutoRevise gains a new rung 0, evaluated before EVEN APPROVE: specOfRecordStale → escalate, never revise — the resolution is an attributed human amendment an agent cannot perform. "An approve cannot land" is enforced at BOTH terminals, not just Weftra's ledger: on a stale round whose reviewer verdict was APPROVE, run-review.ts step 5g WITHHOLDS the provider-side approving review (submit is not called; the reviewer_submitted marker records submitted: false, withheld_reason: 'spec_of_record_stale'). Withheld, not downgraded — a REQUEST_CHANGES round posts exactly as before — and withholding is PER-ROUND, not a retraction: no VCS provider here exposes a review dismissal, so step 5g RECORDS the decision it hands submit() on the reviewer_submitted marker (with attempt_id, pr_url, head_sha, lineage_id) and writes reviewer_posted once the post completes, and resolveStandingApprovingReview READS those rows — never an inference from round numbers or verdicts — for an approving review Weftra posted earlier on this pull request (confirmed, with the head and whether it is the head this round graded) or a submission it cannot confirm was non-approving (unconfirmed, never collapsed into none); the comment, the Resolution sentence and the standing_approving_review field on the event NAME it rather than claiming a retraction Weftra cannot perform. It is not a branch-protection rule: a human can still approve or merge by hand. resolveConformanceResolution gains a spec_of_record_stale branch, labelled ahead of every other escalate branch, so the comment no longer announces an unreached round cap on a stale round. run-review.ts honours spec_stale ONLY when the round was in scope (an out-of-scope round drops the entries and records spec_stale_unsolicited: true on reviewer_verdict, visible without being load-bearing); every entry is redacted through the SAME pool the round's other finding strings use before reaching the ledger or the PR comment. A new spec_of_record_stale run event is recorded once per round, only when the array is non-empty, naming round/spec_record_id/spec_content_hash/head_sha/ head_bound/triggers/reason/entries/blocked_approve. It is deliberately NOT added to PROJECT_KEY_WITHHELD_EVENT_TYPES — it is a fact about the tenant's own spec and its own diff, the same class as no-changes/spec-artifact-drift, and carries no operator control value. buildConformanceCommentBody renders one block (spec content hash, one row per entry, a consequence sentence naming what Weftra actually did — no recorded approval, no agent round, this round's approving review withheld, plus any earlier-round approval it cannot retract — a Not recorded as approved: flag under the header, and a resolution sentence naming that only an admin/approver can clear it — and stating plainly that Weftra does not yet VERIFY an amendment happened: today the block lifts when a graded round stops reporting the divergence) when entries are present; byte-identical without. Increment 50 was detection only. Increment 67 (RM-294 part 2) adds the resolution: POST /api/features/:id/amend-spec lets a named admin/approver (never the implementer, reviser or security fixer) amend a VERBATIM (spec_content) spec of record — guarded exactly like /:id/approve-spec in both halves (requireAdminOrUserAuth + requireApproverScopeForFeature, and the same separation-of-duties check so the feature's own submitter cannot amend its own rubric), body spec_content/reason only (every other field, including every reviewer-arming field, refused by name with a 400 before any write; there is no amended_by — the actor is always the authenticated credential). One transaction updates features.spec_content, appends a spec record under the EXISTING approver_edited provenance, and writes a spec_amended run event ({by, principal_kind, hash_before, hash_after, reason, spec_record_id, spec_path}, never the spec bytes) — a ledger-write failure rolls all three back. No flow file is touched: the next POST /:id/revise re-asserts feature.spec_content unmodified (writing the amended bytes to the branch), and resolveSecurityReviewSpecSource reads the same field, so the amendment is automatically a new security rubric with no carried verdict. specOfRecordAmendmentStep and the conformance PR comment now name the route. Spec 443 inc-77 (RM-294 part 3a): a non-verbatim (fa_authored/repo_committed) spec of record can be amended through the same route. The amendment appends an approver_edited record and repoints spec_record_id at it, and never writes spec_content. revise and the Security Fixer write the amended bytes into the clone before their first turn and restore them before every staging, so the next revise push carries them. A revise round with no PR feedback, or whose agent changes nothing, now writes the amended spec and pushes it as its own docs: commit with no agent turn, recording a spec_amendment_pushed run event. Spec 443 inc-78 (RM-294 part 3a, remainder): the dashboard amend form also covers a non-verbatim spec of record. A read route, GET /api/features/:id/spec-of-record, returns the text in force to an admin or a linked approver, and the form opens on it; the amendability decision is the same one the amend route refuses with. Spec 443 inc-76 (RM-294 part 3): the Evidence Pack carries the recorded amendment chain as an optional spec_amendments key (oldest first, the amender in withheld form, no reason), with a pending line when the bound spec record predates the last amendment and a discontinuity line when the chain does not link; the feature detail view shows an amendments panel and, for an implemented verbatim feature, an amend control that calls this route only. The pack reports the recorded state and makes no claim about what the branch holds. Spec 450 inc-12 (RM-316 l): an answered clarification can ALSO refine or override a spec claim, and until this increment the reviewer was never told — it graded the spec alone and reported the Author's correct, answer-following diff as a divergence (worked example: a pilot project's PR #8). Two new run events make grading spec+answers together possible without letting a project key touch the rubric: clarification_answered (both answer routes, routes/features.ts, same transaction as the answer itself; principal_class — 'tenant' on the project-key route, 'reviewer_side' on the admin/approver route — derived from the credential, never the body; answer text never in the payload) and author_clarifications_given (implement.ts step 3, exactly the rows buildClarificationContext gave the Author). A new pure resolver, resolveRubricClarifications (review/clarification-rubric.ts), includes a row only if it was given to the Author AND answered through the admin/approver route AND its hash is unchanged since either event — a project-key or machine answer, or one whose bytes changed, is excluded with a closed reason, never silently used. buildReviewerPrompt renders the included Q/A under its own fenced heading right after the spec (byte-identical when nothing is included — no round had an eligible answer before this increment); reviewer_verdict/reviewer_final gain rubric_clarifications: {ids, hash, excluded}, and the conformance PR comment names which answers were graded and which were excluded for a reason a human can act on (tenant_answer/answer_changed), pointing at the same amend-spec route inc-67 above documents. Does not touch the security reviewer's rubric or the spec-kit pipeline (spec-kit already folds an answer into the spec it regenerates).
Public surfaces:
GET /api/features/:id (review verdict fields)POST /api/features/:id/run-review {"reopen": true}dashboard "!" review-comment indicatorPOST /api/features/:id/run-reviewPOST /:id/run-reviewreview_on_patch (project + feature, admin-only)POST /api/features/:id/amend-specPOST /:id/amend-spec
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/SETUP.md
Limitations: The dashboard amend control covers a non-verbatim spec of record as well as a verbatim one, through a read of the spec of record in force (spec 443 inc-78, RM-294 part 3a remainder). A finding raised only after an earlier approval, on files whose diff is identical at the approved commit and at this one, is recorded as late-raised and goes to a person instead of buying a revise round (spec 443 inc-79, RM-280 addendum 2 part 1); it never approves, drops, re-rates or hides a finding. The operator-declared claim check (spec 443 inc-80, RM-280 addenda 1–2 part 2a): an admin-only per-project command runs once on the PR head and once on the base branch tip, with one file a finding names passed only as an environment variable; both exit codes are recorded on the ledger and shown on the PR comment; it decides nothing by itself. Spec 443 inc-81 (RM-280 addenda 1–2 part 2b): a claim about earlier behaviour (claim_class: base_behaviour) whose check answered the same on both trees — present on both, equal recorded exit codes, and that code is not 126/127 (the shell's own "did not run") — now buys that round a human escalation instead of an automatic revise round, through one new decideAutoRevise rung evaluated beside the late-raised rung; it still never approves, merges, drops or re-rates a finding, and a project's own repository being both trees means the only effect of arranging a match is an escalation of the project's own round. A retried round recovers the earlier attempt's recorded result (already_checked with recorded) instead of re-running the command. A recovered result is acted on only inside the review lineage that measured it (a round re-opened or re-graded at the same head does not inherit it), and the PR comment's routed sentence is rendered only when this rung is what decided the round, never merely because the checked finding is unchanged at base. test/structural claims are never routed by this rung. Spec 443 inc-82 (RM-280 addenda 1–2 part 2c-1): the Evidence Pack now lists each recorded claim check (statement.claim_checks, one entry per claim_check_result row, oldest first, omitted entirely when a feature has none) and the dashboard project form gained a control for claim_check_command, writing through the same admin-only route and validator the API has used since inc-80. Each pack entry carries the row's commits, exit codes and the same unchanged_at_base/isUnchangedAtBase projection the routing rung reads — never the command text or its output, and never a claim that the entry's unchanged_at_base by itself routed the round. Spec 443 inc-83 (RM-280 addenda 1–2 part 2c-2): the escalated reviewer_final for a round the unchanged-at-base rung decided now carries one additive routed_by: 'unchanged_at_base' key, absent on every other escalation and on every approval; a new review-disposition fleet-attention source (status: 'review_parked', action: 'review_disposition') and a board Decide action name that escalation for a human, at the same SLA class as the merge item it sits beside, clearing when the feature leaves implemented, a revise round starts, or a later round records a newer verdict — carrying only the feature's own title, ids and PR URL, and no word implying the finding is wrong, disproved, dismissed or resolved. What is left of RM-280 addenda 1–2: the same attention treatment for the contradicted and late-raised rungs (an operator decision, not part of RM-280 part 2c); a test/structural claim whose check answers the same (including an equal non-zero exit, "unmet on both"); more than one target per finding; a criterion phrased as a command; a merge-base rather than the base branch tip. Guard efficacy is built (RM-452 inc-1): the reviewer counts a test as proof of a criterion only if the test could fail when the criterion is violated, and reports the criterion unmet otherwise. Export survival of a shipped test is built (RM-452 inc-2), so both parts of RM-452 are built: an admin-only per-project review_context_paths names up to five repository files the reviewer receives as context data, read from the base branch and bounded, and a fixed rule tells it to check each added test that ships in a declared subset for an unguarded read of a path the subset leaves out; the context directs attention only and never grounds an approval. A run_mode: patch feature an administrator has armed with patch_fix_rounds (and review_on_patch on) no longer ends at one review: an artifact REQUEST_CHANGES starts a bounded sandboxed fix round and the loop ends in one recorded terminal naming its stop_reason (spec 544 inc-8f, RM-446); unarmed behaviour is unchanged.
Security Review And Fix
Status: 🟢 SHIPPED Audience: Operator
The dedicated adversarial security-reviewer engine role, the on-demand security-fixer trigger/recovery loop (the Security Fixer runs on its own security_fixer role with admin-only security_fixer_engine/security_fixer_model/security_fixer_escalation_model; fixer_* is read by the conformance Fixer rung of the implementation review, not by the Security Fixer), and the reviewer's read-only repository-context verification pass (see CLAUDE.md's Trust Boundary incident note, 2026-07-17). Since spec 401 §1.3, the Fixer runs its build/test command (and, on failure, the self-triggered build-repair round) BEFORE it pushes by default — a head still broken after the repair cap is never pushed at all, rather than reaching the reviewer. Spec 400 (RM-183): a verdict is bound to the CONTENT it graded, not the commit that carried it — a re-based head whose reviewable-diff fingerprint (file identity plus declared-generated_paths exclusion, dropped index lines, normalised hunk offsets, and — spec 425/RM-218 — every -prefixed hunk-body CONTEXT line excluded, keeping only the added/removed lines) matches one already conclusively graded carries that verdict forward as security_verdict_carried, spending no engine call and no round (inc-1); a verdict recorded before spec 425 shipped never carries, even to a fingerprint-matching head, because it lacks the fingerprint_context_ignored: true marker the carry now requires on the candidate. Inc-2 changes what the ESCALATE round-cap terminal STOPS: exhausting SECURITY_REVIEWER_MAX_ROUNDS now records security_fixer_final(escalated_cap) (a Fixer terminal) instead of security_final — the Security Fixer is parked, but the Reviewer stays selectable, so a later head with a genuinely different reviewable diff still gets an ordinary bounded round without an admin security_reopened. The lifetime cap (security_final(cap_exhausted)) is the unchanged cost backstop, and while the round-cap terminal is live the loop's unattended merge gate parks the candidate (security_escalate_cap) — so a later autonomous PASS cannot land a PR whose capped findings nobody read; an admin re-open, an accountable deferral, or a human merge is what clears it. With the lifetime cap disabled (SECURITY_MAX_PASSES_PER_FEATURE=0) the round-cap terminal keeps ending re-selection, as it did before inc-2. pushed at all, rather than reaching the reviewer. pass (see CLAUDE.md's Trust Boundary incident note, 2026-07-17). Spec 401 §2 (inc-2) adds three cost-control knobs: security_fixer_scope (project, admin-only — the Fixer runs only on the EFFECTIVE/severity-gated verdict by default, blocking, instead of every raw reviewer objection), a runtime reviewer-fallback model retried once in-round on a rate_limited provider rejection, and a runtime verification-round model plus a low-severity second-opinion re-sample — both the latter OFF by default. Spec 427 (RM-248, Pilot 0's first accepted retrospective countermeasure): the stage's head-SHA read is now typed and self-explaining instead of collapsing every failure into a bare null. GithubProvider.resolveHeadSha classifies the failure into a CLOSED HeadShaFailureReason enum (no_credential, unsupported_url, not_found, unauthorized, rate_limited, provider_error, no_head, unclassified); on a TRANSIENT reason the stage retries ONCE after a fixed 1s delay (a module constant, not a config knob) before giving up — a terminal reason is never retried. A successful retry records security_head_sha_retried; an unresolved head still records the existing security_sha_unavailable event (now carrying reason/ attempts/retried/notice_posted) AND posts one fixed-template PR comment (per streak) saying no security/provenance check was posted for the current head and why — never provider or exception text. The conservative skip itself (no round spent, no lifetime pass spent) is unchanged; this only makes the failure legible. Spec 436 (RM-252): the self-triggered build-repair prompt (build-repair.ts) is now BOUNDED — the compiler output, test_command, and (when a diffBaseRef is supplied) the round's own diff are each size-capped before assembly, with a hard ceiling on the assembled prompt, superseding spec 281 AC6's unbounded "verbatim and untruncated" embedding, which overflowed the engine's own prompt-size limit on Weftra's own ~13,000-test suite and discarded the round's real fix twice on the same feature. The repair turn now also sees the round's own diff so far (fenced as DATA), and — since a build-repair round's commits are local-only until the final attempt passes (spec 401 §1.3) — an unfixed round's commits are preserved (redacted) on build_repair_escalated as unpushed_diff rather than lost with the workspace. build_repair_triggered gains prompt_bytes/output_truncated/diff_included/prompt_ceiling_applied, and security_fixer_completed gains build_repair_prompt_bytes whenever a repair round ran. Spec 443 inc-27 (RM-289): an ESCALATE comment now ends with a Resolution block — kind/sentence/next_step, rendered by the new pure blocking-resolution.ts — naming whether autonomous security fix will start a round (autonomous_pending, naming the exact head SHA and the "while the head is still" condition — never an unconditional promise), already started one (autonomous_started), is off (autonomous_off, naming POST /fix-security and the project setting, and rendered only while the feature is still ELIGIBLE — that call is answered 409 once a round has parked), or is capped/blocked (capped / no_progress / gate_edit_detected / loop_disabled / blocked_by_terminal, each naming its own outcome tag and the {"reopen": true} admin re-open, for BOTH settings of autonomous_security_fix). A tenth kind, blocked_by_clarification, is checked ahead of all of them: an unresolved prior-decisions conflict blocks every relaunch path — the Fixer's selection query excludes the feature, the flow refuses at entry, and POST /fix-security answers 409 — so the block names answering the standing clarification instead of any route the conflict would refuse. Computed via the SAME resolveFixSecurityEligibility the autonomous dispatcher uses, evaluated against the round's own just-computed verdict. Additive on security_verdict; a PASS comment is byte-identical to before. The dashboard's feature-detail view shows the same recorded sentence, and shows the recorded next_step in place of the "Fix security findings"/"Address PR comments" buttons wherever it would otherwise hide them with no explanation — reading the LATEST verdict of each type, so a later non-blocking round clears a superseded sentence rather than leaving a closed finding's instruction on screen. Spec 443 inc-48 (RM-270 part 1): a finding-level severity carry, independent of and finer-grained than spec 400/425's whole-diff carry above. When a finding RESTATES a prior round's finding (the existing spec 207 continuity attribution) at a strictly higher, UNCITED severity band, Weftra decides whether to carry the prior band on bytes IT LOCATES ITSELF. The reviewer quotes the source text its finding is about (quoted_bytes); Weftra reads the file and locates that text verbatim (a per-finding content anchor, finding-anchor.ts — a sha256 over the normalized quote, pure/no I/O, bounded to one read per distinct (ref, file) pair and 20 reads per round). The carry fires only when the PRIOR finding's quote is located, EXACTLY ONCE, in the same file at BOTH heads and the CURRENT finding's own quote overlaps that span. The reviewer's line is never used for this: Weftra's own prompt defines it as an approximate DIFF coordinate and there is no diff-to-file mapping, so it cannot establish which file bytes a finding is about — the positional 7-line window the first implementation used was replaced by this quoted-bytes anchor on the operator's ruling of 2026-09-20. Every unclear case fails closed and refuses the carry: no quote, a quote Weftra cannot find, a quote matching in more than one place, an unreadable file, or two quotes resolving to different regions. The claim a carried: true record makes — and the narrower things it does not — are written out in docs/OPERATIONS.md §4b-7. Nothing is suppressed: the finding is still recorded and displayed in full at its own reported severity, and the round's gated verdict is recomputed normally over the effective severities — a carry changes exactly one finding's blocking contribution, which turns the round's verdict to PASS when that finding was the only one at or above the project's threshold. The floor band always blocks regardless; a cited re-rating (severity_change_reason, parsed fail-closed exactly like severity_justification) always stands, and its text is rendered on the PR comment in the finding's own fenced block as well as on the security_verdict run-event payload, so the one model-authored field that can defeat a blocking control is readable where the decision is read; and a restatement lineage carries at most ONCE (a carried_anchor_sha256 marker written on the carried finding and propagated onto every later restatement, refused on presence alone so no model-authored field can release the bound). Every evaluated restatement — carried or not — writes a severity_drift run event naming both severity bands, whether Weftra established byte-identity (bytes_verified_identical) and, when not carried, why (floor/cited/rubric_changed/quote_missing/quote_unreadable/quote_not_found/quote_ambiguous/quote_not_at_finding/repeated); the repeated case also writes severity_drift_repeated. The PR comment gains one line per drifted finding naming both bands and stating only what Weftra established about the bytes, and each finding's block shows the quoted bytes the carry treats as identity. No new project/feature field, no schema change, no new dashboard surface — see docs/OPERATIONS.md §4b-7. Spec 443 inc-65 (RM-270 part 2): a restatement naming a genuinely DIFFERENT defect no longer hides a verified fix. The continuity schema gains an optional same_defect: true (meaningful only alongside status: "restates", parsed fail-closed — kept only for the literal boolean true), and the reviewer prompt now says explicitly that restates means the SAME defect re-observed, never a merely-neighbouring one, and that a verified-fixed prior id belongs in resolved_finding_ids even alongside a new, neighbouring finding. computeSecurityFindingSets changes in exactly one case: a prior finding BOTH named in resolved_finding_ids AND restated by a current finding. If any restater affirms same_defect: true, the existing spec 207 contradiction guard stands (carried, unchanged). If none does, the prior finding is closed and each restater is REATTRIBUTED — scored introduced and recorded on a new reattributed list ({finding_id, restates_finding_id, reason: "resolved_and_not_affirmed_same_defect"}). This is the exact dulwich re-run shape (2026-09-16): a TOCTOU symlink race filed as "restates" an already-fixed suffix-traversal finding wrongly forced the fixed finding to stay carried and scored the round no_progress, parking a real fix uncredited. Closure still requires the reviewer's own resolved_finding_ids entry — nothing closes from absence or inference. decideSecurityFixRound's reason names every reattribution, and the security_verdict net-progress payload carries a fixer_findings_reattributed array alongside the existing fixer_findings_*_count fields. No new project/feature field, no route, no dashboard surface — see docs/OPERATIONS.md §4d.
Public surfaces:
POST /api/features/:id/security-fixdashboard security-review verdict panelfixer_escalation_model (project + feature, admin-only)POST /api/features/:id/security-review (now also resolves a captured patch.diff artifact when pr_url is unset — spec 276)test_runtime_image (project + feature, admin-only)security_fixer_scope (project, admin-only — spec 401 §2.1)security_reviewer_fallback_model (runtime setting, admin-only — spec 401 §2.2)security_verification_model (runtime setting, admin-only, off by default — spec 401 §2.3)security_second_opinion (runtime setting, admin-only, off by default — spec 401 §2.3)run-event types: security_sha_unavailable (widened payload — reason/attempts/retried/notice_posted), security_head_sha_retried (spec 427 / RM-248)PR comment posted when the security stage cannot resolve the PR head SHA after its one bounded retry (spec 427 / RM-248)
Docs:
docs/OPERATIONS.mddocs/operations/security-model.mddocs/USER_GUIDE.md
Verification Gates
Status: 🟡 PARTIAL Audience: Operator
Pluggable gate providers, the verification-integrity gate, custom per-project verification commands, browser-based verification, and (spec 401 §1) a project-declared pinned_test_command pre-push check run before the test gate — a failure gets one bounded, targeted fix turn (the failing test BLOCK, not just the tail, via extractFailureEvidence) before falling through to the ordinary gate unchanged. (Spec 443 inc-14, RM-275) a /revise round now resolves and runs the declared verify_command too, in the sandbox, leaving the same ledger/anchor/provenance evidence an initial implementation run does — a block-mode failure is recorded and PR-commented but does not discard the round, per spec 257's non-blocking-push invariant for that flow. (Spec 443 inc-22, RM-281) the on-demand Security Fixer gets the same verify gate: run once per round through the same sandboxed seam, after which its OWN push posture applies — the default fixer_build_before_push order withholds the push entirely on a block-mode failure (outcome: 'verify_failed', no commit reaches origin, no PR comment), the legacy order pushes anyway with the failure recorded, PR-commented, and the pushed sha's verification anchor cleared with the true exit code. This closes the Fixer's own anchor exemption: its push is now anchored (or explicitly cleared with the true reason) the same three-condition way implement//revise already decide it, rather than clearing unconditionally regardless of what the round's gates actually found. (Spec 443 inc-25, RM-282) the implement and /revise prompts' "PROJECT-DECLARED CHECKS" block (spec 443 inc-16) now also states Weftra's own contract — Weftra runs the declared command(s) itself, in the workspace, after the agent's run, and only Weftra's run produces evidence, so the agent re-running them during its own iteration buys nothing — and asks the agent to use the narrowest check its own project tooling supports while iterating, running the declared command(s) in full only when it believes it is done. Weftra derives no narrow-test form from the declared command and names no runner/tool/script (constitution §VII — a pinned test asserts this). Every Bash call the agent itself makes that exactly matches a declared command (normalised by trim + whitespace-collapse only, never a substring/wrapped match) is now counted per pass as declared_check_runs on that pass's engine_pass_tool_summary ledger row — see the provenance-and-audit and evidence-pack capabilities below for the ledger/pack side. (Spec 443 inc-29, RM-273) a project may opt in (gate_rerun_on_moved_head, admin-only) to have Weftra re-run the declared test_command/verify_command against a PR head that carries no gate evidence of its own — the case it exists for is a head Weftra did not push (a human, a bot, anything other than Weftra's own implement//revise/Security-Fixer flows), and the condition it actually evaluates is "no build_verdict/prior attempt names this head", which also covers Weftra's own spec-kit pushes (they record no build verdict). Nothing witnesses WHO pushed a commit, so no ledger row or PR comment from this stage claims to. Not an agent run (no engine call, no prompt, no push); dispatched from the existing PR poll, bounded to 5 ATTEMPTS per feature (counted before the clone, so a failing setup or a dead sandbox spends budget too), and it never blocks a merge or changes the feature's status — it only records build_verdict{source:'gate_rerun'} and a blocking_gate_result{gate:'verify_gate'} that, uniquely among this event's writers, carries head_sha/head_sha_known:true. That "changes no merge condition" is enforced, not just asserted: the loop's unattended merge gate (latestBuildVerdict, cycle-scheduler.ts) SKIPS source:'gate_rerun' rows, so a head Weftra did not produce still parks for a human exactly as before, while every evidence reader sees the verdict. A failing re-run posts one Weftra-authored PR comment per (head, gate), idempotent, carrying none of the project's own command output — which is written to the feature's run log, where that comment says it is. (Spec 443 inc-49, RM-298) before either gate's own declared-command exec starts, and again at the end of every agent pass, Weftra sweeps the sandbox container for processes not traceable to what was there before the pass (a setup_command daemon and anything it forks are always spared), a live Weftra docker exec, or the sweep itself, and signals them per-pid — so a suite the agent backgrounded and never finished cannot still be running, competing for the same sandbox, when Weftra's own gate starts. Nothing about a gate's command, timeout, exit code or verdict changes; a non-empty pre-gate reap is recorded (agent_processes_reaped) and surfaced as a caveat on the evidence pack and (only when a failing-gate PR comment is already being posted) as one appended line on it. The "PROJECT-DECLARED CHECKS" prompt block also now tells the agent, in Weftra's own words, to run its declared command(s) in the foreground and never end its pass with one still running. (Spec 443 inc-59, RM-288) a project declares tool_proxy_env: inherit (default) | strip_for_tests so its OWN test_command/verify_command/pinned_test_command run with the six proxy-shaped names unset — a suite that asserts proxy-environment semantics (the measured dulwich case: tests/test_client.py::test_environment_empty_proxy) no longer fails for a reason unrelated to the change under test. Every declared command already runs through one chokepoint (loginShellArgs, spec 443 inc-15); this adds a second, constant prelude that unsets the six proxy-shaped names immediately before the final exec — whoever set them (Weftra's runtime-egress grant sets four; an image, a login profile or the project's own env: can set any of them). Applies to exactly those three commands, at EVERY Weftra path that runs them (both test gates, the pin precheck, a gate re-run on a moved head, a security-fixer round and its build repair, the merge/canary gate set), so one declared command never runs in two environments inside one feature's lifetime; setup_command, a generated-path regenerate, a gate provider, the environment provision runner, the eval replay scorer and the agent's own turns are unaffected. The run's own run_config_snapshot records which environment the gate saw, and a stripped run whose command still needed egress gets a remediation naming the field, never Weftra's ordinary "Weftra set the proxy" text. (Spec 486 inc-1, RM-371) the agent cannot remove the gates that judge it: a blocking gate declared in gates: at the commit a feature branch forks from still runs, blocking, in its baseline form, even when the branch's own manifest deletes it, sets block: false on it, or replaces its command — pinned on the fresh clone before the agent runs (a gate_baseline_pinned ledger event), never read from the post-agent workspace; a later run re-verifies its read against the pin and fails closed on a mismatch. (Spec 533 inc-1, RM-449) the standard implement flow's test gate reuses an identical earlier PASS instead of re-running the suite: the key is the project, the working-tree content hash, the command, the docker image id the gate runs in, and a config fingerprint; only a clean pass is recorded, data_dirs/services/non-docker runtimes are never reused, and the ledger shows gate_memo_hit / gate_memo_miss. Host switch GATE_MEMO (default on); entries are bounded to 30 days and 200 rows per project. Each removed or weakened baseline gate is recorded as a gate_removed run event (name, kind of change, base SHA, and a SHA-256 of each command — never the command text) and shown in the Evidence Pack's "Declared gate baseline" section; when the baseline cannot be determined at all (no base commit) the run proceeds on the branch's own manifest and the ledger records that the comparison did not run, with a reason, rather than claiming a clean comparison it never made. gate_result gains a source of baseline or branch so a baseline-forced re-run is distinguishable from the branch's own gate of the same name. Protects the declaration, not what the command resolves to; covers the standard implement flow and the gates: key only. (Spec 486 inc-2, RM-371) an admin can set the project field gate_removal_policy: require_signoff: FA's own merges (@weftra merge and the loop's unattended merge) then refuse a merged tree that removes or weakens a base blocking gate until a named approver signs off that exact change set (POST /api/features/:id/gate-changes/sign-off, fingerprint-bound, shared admin key and project keys refused, separation of duties applied), and refuse with gate_removal_unchecked when the comparison cannot be made. Default record changes nothing. Recorded as gate_removal_signed_off / gate_removal_merge_check run events, surfaced in the Evidence Pack signoff section and the provenance check; holds only merges FA performs.
Public surfaces:
verify_command / test_command project configpinned_test_command (project, self-configurable — spec 401 §1.2)gate_rerun_on_moved_head (project, admin-only — spec 443 inc-29)tool_proxy_env (project, self-configurable — spec 443 inc-59)GATE_MEMO (host env switch, on/off — spec 533 inc-1)gate_removal_policy (project, admin-only — spec 486 inc-2)GET /:id/gate-changesPOST /:id/gate-changes/sign-offnpm run test:coveragenpm run test:pins (every pinned/sync/tripwire test in ~1 min, including the engine-account failed-call call-site count pin; the self-project pinned_test_command runs the same set before each gate)
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/SETUP.mddocs/RUNTIME_IMAGES.md
Limitations: Iterate-until-green runs on the standard implement flow only — not yet on the spec-kit or revise flows. Both reviewers are told, from the diff FA itself fetched, when a change deleted test files, added skip or focus markers, or removed assertions; the signal is recorded once per head, appears in the Evidence Pack, and informs by default. An administrator can set the admin-only project field test_integrity_gate: block, and then an unattended loop merge refuses a head whose signal is flagged or missing, unreadable or incomplete; a human-approved landing is labelled on the fleet board and in the merge-ready notification, never refused. Not yet built: the PR-comment line, a dashboard control for the field, a test-count-fell signal. For each head the standard implement flow pushes, FA also records whether the tree the test gate read equals the tree that shipped, naming files changed after the gate and files the agent wrote that the gate could read but the commit does not carry; both reviewers and the Evidence Pack show it, it informs only. When the tested tree is not known to be the pushed tree, the gate also runs again on a clean checkout of the pushed commit before push, with data_dirs and setup_command re-applied there, and that result is the build verdict (a non-pass blocks under block or iterate; warn records it); an operator can switch it off with TEST_GATE_CLEAN_CHECKOUT=off.
Contribution Lineage
Status: 🟢 SHIPPED Audience: User
Spec 443 inc-55 (RM-293 part 1) — on every PR poll, Weftra observes which commits arrived on a branch since it last looked and whether the branch is still a descendant of the head Weftra last saw, without ever asserting WHO pushed anything (the same epistemic refusal gate-rerun already applies, spec 443 inc-29). Each observed commit is classified asserted (an execution_checkpoints row for this feature names this SHA as one Weftra produced) or external (no Weftra record names it — a superset of "a person pushed it" that deliberately also catches Weftra's own uncheckpointed pushes, e.g. the spec-kit flow), never phrased as an actor claim. Records artifact_lineage_observed (one marker per observation: head/from/relation/commit_count, unavailable on a provider failure, truncated+total_commits beyond CONTRIBUTION_LINEAGE_MAX_COMMITS_PER_OBSERVATION (50)), artifact_contribution_observed (one row per new commit, oldest first — SHA, attribution, a bounded [A-Za-z0-9._-]-only reported_author_handle when the provider supplies one, handle_verified: false always, never an email, never a raw commit message), and lineage_discontinuity (from_sha/to_sha/relation) when the PROVIDER REPORTS the head is behind or diverged relative to the head Weftra last saw — followed by a second, bounded compare against the PR base so the post-rewrite commits are still enumerated (after_discontinuity: true), a hard ceiling of two provider reads per moved head. An unchanged head costs zero provider calls and writes zero rows, forever (the anti-noise guard runs before any credentialed call). The marker, every contribution row and any discontinuity row for one observation land in a single recordRunEvents call — all-or-nothing. New provider seam VcsProvider.listCommitsBetween (GitHub via repos.compareCommitsWithBasehead, the same call fetchChangedFilesBetween already makes, and the only provider supplying an explicit ahead/behind/diverged status; GitLab/Bitbucket via their own one-directional compare/commit-range endpoints, which cannot distinguish identical from behind — so they report identical only when the request itself proves it and unknown in every other case, which means a rewind on those two providers is recorded as unknown with NO lineage_discontinuity row rather than guessed), added to METHOD_RETRY_POLICY (compile-checked exhaustive). No new route, no schema change, no UI file touched — readable through the existing run-event ledger surfaces (feature events endpoints, dashboard timeline, the overview Audit feed, the audit export).
Public surfaces:
GET /api/features/:id/eventsGET /api/features/admin/:id/eventsGET /api/audit/export?source=run_events
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Default Branch Landings
Status: 🟢 SHIPPED Audience: Admin
Spec 463 inc-1a (RM-326) — Weftra observes each project's default branch and RECORDS every commit that lands there (a direct push, or a PR merged that this instance never gated), attributing it, and NEVER verifies it: no Gate, security review, test, verify command, sandbox, runtime or engine call runs for a landing, and no model budget is spent (operator ruling 2026-09-28: record, do not verify). On every agent-engine tick, observeDefaultBranches() picks at most 2 projects (no cursor first, then oldest checked_at, throttled by PR_CHECK_INTERVAL_MS), resolves each project's own default-branch head through its own per-project VCS credential (spec 215), and classifies every new commit governed_here (a merged, same-repo PR targeting the branch whose URL matches a feature's pr_url of THIS SAME project, linked_feature_id set), governed_elsewhere (a merged, same-repo PR matching no feature of this project), direct (no qualifying PR), unknown (the provider could not answer — never guessed), or history_rewritten (the branch moved behind/diverged from what Weftra last saw — exactly one row for the new head, no PR lookup, then a re-baseline). A fork PR, a PR with no provable same-repo provenance, or another project's feature can never yield governed_here. The first observation of a project baselines its cursor with zero backfill; an ahead/unknown compare is bounded to 20 commits per tick, oldest-first, advancing the cursor only to the last processed commit. Every row carries fa_evidence: 'no_fa_evidence' — even governed_here rows, since the row records the landing, not Weftra's own gate. No commit message, diff or file list is ever stored; author_handle is capped at 255 chars. New provider seam VcsProvider.listPullRequestsForCommit (GitHub, GitLab, Bitbucket), added to METHOD_RETRY_POLICY (compile-checked exhaustive). Kill switch FA_DEFAULT_BRANCH_OBSERVER (default on). Two new tables (default_branch_cursors, default_branch_landings) — no column added to projects or features. Admin-only read API. Inc-1b additionally reads what the project's OWN CI already reports for each landing's commit SHA — GitHub check-runs + legacy statuses, GitLab pipeline jobs + commit statuses, Bitbucket build statuses — via a new SHA-keyed VcsProvider.fetchCommitChecks(repoUrl, sha), sharing fetchPrChecks's provider mechanism, credential, throttle and "null, never throw" contract (never populating logExcerpt). This is an OBSERVED FACT WITH ITS SOURCE, never Weftra evidence: fa_evidence stays no_fa_evidence on every row. Stored append-only in a new default_branch_landing_checks table (outcome unreadable/none_reported/pending/all_passed/some_failed, derived by the pure summarizeLandingChecks, plus a check_runs_readable partial-read flag and at most 50 capped check entries — name/status/conclusion/completed_at only, never a details URL or log excerpt). A bounded read pass runs beside the existing observer pass each tick: at most 5 landings per project, a landing read at most 6 times, none older than 24 hours, and a landing whose latest outcome has already settled (none_reported/all_passed/some_failed) is never read again. The admin read API gains an additive provider_verification field per landing (no new route), and the dashboard's project view gains a read-only "Default-branch landings" panel (admin-only, hidden for an approver session, built with DOM APIs only) showing each landing with its classification and the provider's own verification badge, captioned that the results are the repository's own CI, Weftra ran nothing, and no landing carries Weftra evidence. An opt-in verification pass (where Weftra itself runs something) remains deferred to inc-3. Inc-2 adds a declare-only record of an operator/proxy act performed OUTSIDE Weftra on a governed project — a direct fix-break push, a merge-time conflict-resolution push, a hand edit to the self-build loop ledger, or another out-of-band kind — so the WHY and WHO behind an out-of-band landing is not lost the moment the terminal closes. Still RECORD, DO NOT VERIFY: POST /api/projects/:id/operator-actions runs, fetches or checks nothing, including the verification the actor claims it ran (an optional description + ref, stored verbatim and never dereferenced). Attribution comes only from the presented credential (never the request body, which refuses any field that tries to name an actor); the shared ADMIN_API_KEY is recorded honestly as a role (admin_legacy), never a person. Appends a new operator_action.declared row to the existing tamper-evident actor_events ledger (spec 261) — no schema change. GET /api/projects/:id/operator-actions?limit= reads them back, newest first. The default-branch-landings read additionally joins each landing to the declared actions whose commit_sha matches (via the same commitShaMatches prefix comparison PR adoption uses), in one bounded read per page; fa_evidence and every other landing column are untouched. The dashboard's landings panel gains an Explanation cell (kind, actor, justification, and the verification claim prefixed "claimed by the actor, not checked by Weftra"), reading "unexplained" for a direct/governed_elsewhere landing with no matching declaration; the audit view's actor-events renderer gains a label for the new action. No dashboard form to declare — that surface is API-only, by design, meant to be called right at the terminal where the out-of-band act happened. Inc-3a adds an OPT-IN, operator-set verification pass: once an admin enables it (PUT /api/projects/:id/foreign-landing-verification, admin-only, recorded as a foreign_landing_verification.set actor event), Weftra runs the project's own declared verify_command (else test_command) in the sandbox against the default branch's then-current head and records pass/fail/error against the landings it covers. The default stays RECORD, DO NOT VERIFY: with no policy row, behaviour is byte-identical to inc-1a/1b/2. Only governed_elsewhere/direct landings are ever pending, never recorded before the policy's latest enabled_at (no back-verification across an on→off→on cycle). One run per tick instance-wide (a single job claim), a per-project hourly cap that counts attempts (a 'running' row is written before the clone) not completions, and every pending landing a project has coalesces into that one run against the one head it clones. exact coverage means the landing's own commit ran; at_later_head means only that the run's head_sha was not the landing's commit — ancestry is not checked (a force-push can leave a head that does not contain the landing), and on an error run head_sha may be null with nothing verified, so read outcome and head_sha — never claimed as the landing's own commit. pass is not Weftra governance: fa_evidence stays no_fa_evidence regardless. Requires RUNTIME=docker (409 to enable otherwise) and a declared verify_command/ test_command (400 otherwise); refuses to execute outside docker regardless of the policy. Never comments, blocks, reverts, re-opens, writes run_events, or raises attention — a fail raising fleet attention and a dashboard column/toggle are deferred to inc-3b. New side tables only (foreign_landing_verification_policies, foreign_landing_verifications, default_branch_landing_verifications) — no column added to projects or features. Kill switch FA_FOREIGN_LANDING_VERIFIER (default on). The landings read API gains an additive foreign_verification field (one bounded join); a new GET /api/projects/:id/foreign-landing-verifications?limit= returns this project's runs newest-first with covered_landing_ids and the redacted, 4000-char-capped output_tail. Inc-3b puts a fail where a human acts on it. A project whose policy is enabled AND whose newest CONCLUSIVE (outcome IN ('pass','fail')) run is a fail raises ONE feature-less, admin-only fleet-attention item (status: 'default_branch_failing', action: 'investigate_default_branch') on the existing attention-source seam (src/services/attention-sources.ts) — running/error runs never decide (an error after a fail leaves the item standing; an error alone raises nothing), and the item clears the moment the newest conclusive run is a pass or the policy is disabled, both derived at read time with no new write. An approver-scoped read never sees it, and a project key cannot reach the route at all — the same admin-only rule as the reads it summarises. The title names the first failing run's head (short SHA) and the earliest covered landing in plain English, never an author handle, email, PR URL or command output, and never claims a landing ran or failed at its own commit unless its coverage is exact. No notification is sent — the item is feature-less, so the existing SLA sweep skips it exactly like repair_vcs_credential/approve_deploy/resume_loop. The fleet board renders the new action with a Project link; the project-view landings panel gains a verification cell per landing (outcome, command_source, coverage stated plainly, error_reason on an error) and the policy toggle (checkbox + max-per-hour + Save, calling the SAME admin-guarded, actor-recorded route inc-3a already added — no new authority), and the panel's caption stops unconditionally claiming "Weftra ran nothing for these commits": it says that only when the policy is disabled and no landing on the page carries a verification, and otherwise explains the verification column honestly (the project's own declared command, run by Weftra in its sandbox — not a review, not governance). No new route, table, migration or column.
Public surfaces:
GET /api/projects/:id/default-branch-landingsPOST /api/projects/:id/operator-actionsGET /api/projects/:id/operator-actionsGET /api/projects/:id/foreign-landing-verificationPUT /api/projects/:id/foreign-landing-verificationGET /api/projects/:id/foreign-landing-verifications
Docs:
docs/OPERATIONS.mddocs/SETUP.md
Repro Check
Status: 🟡 PARTIAL Audience: Operator
The bugfix-category-only repro gate (Spec 278): Weftra authors and executes a minimal repro from the raw problem statement BEFORE any spec refinement (implement.ts), and re-executes the same captured repro against the produced diff at review time, reporting pass/fail/cannot-verify. Catches a class the spec-conformance reviewer structurally cannot — a diff that faithfully implements a MISREAD requirement.
Public surfaces:
repro_gate (project + feature, ADMIN-ONLY — arms a review-stage control; max-strictness resolution, default off)repro.json feature artifact (source=fa_capture, same surface as patch.diff)
Docs:
docs/USER_GUIDE.md
Limitations: The repro verdict now downgrades merge-readiness: with the gate armed and a repro captured, a head whose repro is cannot_verify, fail or never graded is unverified — an auto_low_risk loop parks it (repro_unverified) and a human-approved landing is labelled on the fleet board, the merge-ready notification and the PR comments. Remaining gaps: the @weftra merge mention path has no readiness layer, and the feature-detail dashboard shows no readiness panel.
Source Control Delivery
Status: 🟢 SHIPPED Audience: Operator
The VCS provider abstraction (GitHub, Bitbucket, GitLab), draft-PR creation/posting, and merge-preflight safety controls that deliver a governed feature branch as a PR. Since spec 215, every repo-touching path resolves ITS project's own VCS credential through spec 011's CredentialSource ladder (src/services/vcs/credential.ts) before falling back to the operator-default GITHUB_TOKEN — the fallback is recorded, never silent. npm run resolve-vcs-token is the CLI seam the Governor (self-build) uses to resolve the same credential outside the Node process. POST /api/features/:id/create-pr (and its /admin/ twin) refuses to attach a PR — adopted or freshly opened — to a branch that has moved past the head Weftra itself delivered: a delivered-head gate reads the union of this feature's artifact_delivered ledger rows and the execution_checkpoints commit SHAs of the phases that follow one of Weftra's own pushes (never the base tip or a provider-reported head), and only refuses once that union is non-empty and the branch's current head matches none of it (vacuous, unchanged behaviour, for a feature Weftra holds no delivery record for at all).
Public surfaces:
POST /api/features/:id/create-prPOST /api/features/:id/revisenpm run resolve-vcs-token
Docs:
docs/SETUP.mddocs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: The rebase revise strategy (RM-046, spec 124 inc-2) is opt-in: it rebases the feature branch onto its base before the agent runs and pushes with an explicit lease (--force-with-lease against the tip Weftra cloned, never a bare force). A conflict, a base with no common ancestor in the shallow clone, or a branch that moved while the agent ran blocks the round instead of overwriting. branch and merge are unchanged. Every docker-runtime push goes to the project record's own repository URL from a Weftra-owned git dir, so a workspace .git/config (pushurl, insteadOf, include.path) cannot redirect it, the credential helper answers only for that repository's path, and a push_destination run event records host, repository path, ref and SHA (RM-373; RUNTIME=local still resolves the destination from host git config — see docs/OPERATIONS.md "Where Weftra pushes").
Integrations Mcp And Webhooks
Status: 🟡 PARTIAL Audience: Operator
The MCP server interface for governed flows (local stdio + remote Streamable HTTP transports over the same project-scoped tools — feature submit/status/list, a Builder co-authoring loop of draft/clarify/refine/estimate, a clarification loop that closes back to the submitting agent, reading and changing the project's own self-configurable settings (get_project_config, update_project_config), and opening a finished feature's draft pull request (create_pr)), a client-initiated OAuth 2.1 front door letting an MCP client connect by browser consent instead of a pasted project key (convenience only — a minted token's blast radius is identical to a raw project key), and inbound/outbound webhook delivery (trigger providers and HMAC-signed callbacks). Inbound triggers are hardened by a dedicated per-project trigger secret (separate from the tenant api_key, rotatable via the dashboard or API) and persisted, replay-safe, per-project delivery dedup.
Public surfaces:
POST /api/mcpGET /api/mcpDELETE /api/mcpGET /.well-known/oauth-authorization-serverGET /.well-known/oauth-protected-resourcePOST /oauth/mcp/registerGET /oauth/mcp/authorizePOST /oauth/mcp/authorizePOST /oauth/mcp/tokenPOST /oauth/mcp/revokeGET /api/admin/mcp-oauth/tokensPOST /api/users/me/vcs-access/link/githubGET /api/users/me/vcs-accessDELETE /api/users/me/vcs-access/:linkIdGET /api/users/:id/vcs-accessPOST /api/admin/mcp-oauth/tokens/:tokenHash/revokeGET /api/admin/mcp-oauth/clientsDELETE /api/admin/mcp-oauth/clients/:clientIdPOST /api/projects/:id/triggers/:providerGET /api/projects/:id/triggersPOST /api/projects/:id/triggers/rotate-secretPOST /api/projects/:id/regenerate-keypublic/index.html#rotate-trigger-secret-btnpublic/index.html#show-triggers-btncallback_url webhook deliverymcp tool draft_specmcp tool clarify_specmcp tool refine_specmcp tool estimate_specmcp tool get_clarificationsmcp tool answer_clarificationmcp tool get_project_configmcp tool update_project_configmcp tool create_prmcp tool get_feature_progressmcp tool product_propose_readymcp tool create_projectmcp tool issue_project_keymcp tool list_own_projects
Docs:
docs/SETUP.mddocs/OPERATIONS.mddocs/USER_GUIDE.md
Limitations: MCP protocol conformance to the 2026-07-28 revision is not done (RM-234); a named admin user session exposes three MCP tools, create_project, issue_project_key and list_own_projects, over the remote HTTP door (behind FA_MCP_USER_SESSIONS_ENABLED, off by default; spec 199 inc-2a, inc-2b1, inc-2b2b) and over a stdio user mode (FA_USER_API_KEY, not gated by that flag; spec 199 inc-2b1, inc-2b2b). An admin can re-issue a project's API key via POST /api/projects/:id/regenerate-key (spec 199 inc-2b2a), the issue_project_key MCP tool that forwards to it, or a "Re-issue API key" control in the dashboard's project edit modal (spec 199 inc-2b2b) — inc-2b2 is now complete. A named admin can also connect an MCP client as themselves from the OAuth consent screen (behind the same flag), receiving a short-lived token the door admits as exactly that user session (spec 199 inc-2b3). Per-user source-control OAuth link: inc-3a shipped (spec 199 inc-3a) — a named user links a GitHub account for repository access from the Account panel, the token stored in the local encrypted secret store and nothing using it yet; binding it to a project (3b), repository bootstrap (3c) and GitLab / GitHub App user tokens (3d) are not built (RM-052 inc-3). Mention-triggered revise is not built (RM-047). Spec 467 inc-1 (RM-339 a–c) shipped: get_feature_progress (phase, current step, elapsed time, tokens and cost, recent events, a log link), await_feature progress notifications per phase change when the client sends a progress token, and a failure block (stage, cause, redacted evidence) on a failed feature. Spec 467 inc-3 (RM-339 f) shipped: submit_feature, get_feature_status, await_feature and get_feature_progress all return dashboard_url/logs_url/links_base, built server-side from the instance's own configured public base URL (never from the request or the client's own FA_BASE_URL), and the dashboard opens one feature from a /?feature=<id> deep link. Spec 467 inc-4 (RM-339 g) shipped product_propose_ready, a 1:1 MCP door onto the existing project-key route POST .../propose-ready, with its admin/approver-only override branch unreachable from the tool's closed input schema. Still to build (spec 467 inc-2, parked, RM-339 d–e): get_project_config / update_project_config and create_pr.
Notification Channels
Status: 🟢 SHIPPED Audience: Operator
The notification-dispatch service (with transition dedup) and its Telegram, Discord, Slack, WhatsApp, and Email channel adapters.
Public surfaces:
per-project notification channel config
Docs:
docs/SETUP.md
Engine Account Pool
Status: 🟡 PARTIAL Audience: Admin
Admin-registered engine accounts, per engine and auth mode, that operator-default runs draw from least-recently-used. A project can dedicate its own accounts (preferred, not a quota); a run falls through to the shared pool when they are unusable. A probe-confirmed block fails over to another eligible account, the auth-resume check counts the project's own accounts first, and the status read and the admin attention queue show when a project has none usable.
Public surfaces:
GET/POST/PATCH/DELETE /api/admin/engine-credentialsGET /api/admin/engine-credentials/usagePOST /api/admin/engine-credentials/stored-keyPUT|DELETE /api/admin/engine-credentials/:id/stored-keyPOST /api/admin/engine-credentials/connectPOST /api/admin/engine-credentials/connect/:sessionId/codeDELETE /api/admin/engine-credentials/connect/:sessionIdEngine Accounts tab (admin dashboard)
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: A named admin can enter a key directly on an engine account, held in the local encrypted secret backend, instead of a host environment variable (spec 479 inc-3); the account is used by default-engine api-mode runs and by the account check; a revoked key fails the run with no fallback. A named admin can likewise store a Claude OAuth token (the value claude setup-token prints) with an optional admin-supplied expiry, and a default-engine oauth run uses it by name only (spec 509 inc-1). An operator can declare a hold rule per non-default engine: repeated unanswered checks hold an account for a set time, and a run's failed engine call starts one of those checks where that rule is declared.
Principal Identity And Project Roles
Status: 🟢 SHIPPED Audience: Admin
The role scaffold (spec 273): admin-managed principal↔VCS-handle links, per-project hats (submitter/approver/merger), capture of the PR's merger, and the one resolver that answers who submitted/approved/merged with an explicit non-collapsible confidence (named/service/role/unmapped/absent). Grants no authority, removes none, refuses nothing — no route's guard reads a hat. Handle→principal resolution is scoped to the project's own forge AND HOST on the authorization path (plus the reserved legacy mirror tag) and fails closed when one handle is linked to two principals.
Public surfaces:
GET /api/users/:id/vcs-identitiesPOST /api/users/:id/vcs-identitiesDELETE /api/users/:id/vcs-identities/:identityIdGET /api/users/:id/projects/:projectId/hatsPOST /api/users/:id/projects/:projectId/hatsDELETE /api/users/:id/projects/:projectId/hats/:hat
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Http Api
Status: 🟢 SHIPPED Audience: User
The HTTP API surface: feature and artifact routes, and the published OpenAPI/Swagger document describing them. The document is ASSEMBLED at import from a base document plus an ordered list of per-surface modules under src/openapi/, with tripwires that refuse a document in which two modules (or a module and the base) declare the same path or the same components.schemas key, or in which a module contributes anything but schemas under components. npm run openapi:hash prints the SHA-256 of the canonical JSON of that assembled document — key-sorted, so module order cannot change it — which is how a reviewer checks for themselves that a re-layout of the source moved paths without changing what is published.
Public surfaces:
GET /api/featuresPOST /api/features/api-docs (Swagger UI)npm run openapi:hash
Docs:
docs/USER_GUIDE.mddocs/SETUP.md
Artifacts
Status: 🟢 SHIPPED Audience: User
Feature artifact upload, preview, and delete storage and API.
Public surfaces:
POST /api/features/:id/artifactsGET /api/features/:id/artifacts/:artifactId
Docs:
docs/USER_GUIDE.md
Admin Runtime Settings
Status: 🟢 SHIPPED Audience: Admin
Config audit of every src/config.ts option into runtime-tunable / secret / structural buckets, an admin-only surface to view and edit the runtime-tunable subset with no restart (agent concurrency/model/turns/retries, PR-poll interval, budget/iteration/ webhook caps), and a read-only presence-only status list for the operator's configured secrets (never their values).
Public surfaces:
GET /api/admin/settings/runtimePUT /api/admin/settings/runtimepublic/index.html#on:saveRuntimeSettingpublic/index.html#on:resetRuntimeSetting
Docs:
docs/OPERATIONS.md
Operator Policy Floor
Status: 🟢 SHIPPED Audience: Admin
An operator-set policy floor a project key cannot lower, applied at resolve time to test_gate (blocking), verify_gate (block) and require_spec_approval (required). Set by an explicit admin, recorded on the actor ledger, readable (never writable) by the project.
Public surfaces:
GET /api/admin/policy-floorPUT /api/admin/policy-floor
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Dashboards
Status: 🟡 PARTIAL Audience: User
The feature/run status dashboard, and the fleet/portfolio view across projects (campaign grouping, attention SLA visibility). The features table is lazy, paged (20/page), searchable and project-filterable, with cross-cutting widgets (stat tiles, held-for-merge count, auth-paused banner, active-run consistency) reading a dedicated summary endpoint instead of the full unpaginated feature list.
Public surfaces:
public/index.htmlpublic/fleet.html
Docs:
docs/USER_GUIDE.mddocs/design/fleet-portfolio-view.md
Limitations: The Projects, Users and Campaigns tables page at 20 with search (Campaigns also has a project filter), and each loads only while its tab is visible (spec 409 inc-3a, inc-3b). A paged/searchable audit table at 20 with search, and fleet/runs pages reading the summary endpoint, are not built yet. A newly-minted or regenerated API key (project create, user create, user key regenerate) is shown once in an in-page dialog with a Copy button and an ask-before-closing confirmation, never a browser alert (spec 469 inc-1). A feature's id/branch/pull-request-URL/log-URL/doc-path, a project's repository URL and Project ID, and the survey ID each carry a Copy control on the main dashboard, and every existing Copy button there shares one failure behaviour: no Clipboard API says "Not copied" and selects the text rather than failing silently (spec 469 inc-2); the other dashboard pages are deferred to increment 3. A failed run with no owner whose latest failure cause is transient (network, infra, unknown, idle timeout, tool stall, sandbox container missing) and that failed within the last 7 days raises a fleet-attention recovery item with a 1-hour SLA, a board Retry link (navigation only) and a channel alert on breach; automatic re-queue is not built yet.
Observability And Logs
Status: 🟡 PARTIAL Audience: Operator
Run state, live log streaming, and operational status surfaces; cross-project aggregate cost/status rollups for fleet attention; the claim-derived agent-activity signal (is an agent actually working this feature right now, and how full is the pool); and a scoped read of the live-run registry, so the dashboard's Active Runs card and Active wefts panel show an approver the live runs of the projects they approve on and a project key its own project's — an admin still sees every project's. A caller who is not an admin is never shown the inbound-intent gate's own run, the model, or a latest event outside the tenant ledger view, and a project key reads the same masked status as on its feature detail.
Public surfaces:
public/logs.htmlGET /api/logs/:jobIdGET /api/features/admin/agent-activityGET /api/features/active-runs
Docs:
docs/OPERATIONS.mddocs/design/fleet-portfolio-view.md
Limitations: The assistant's visible text is not yet kept after a workspace is cleaned up.
Cost Tracking
Status: 🟡 PARTIAL Audience: User
Per-run/model cost and token tracking, including honest accounting on failed runs, per-role breakdown (implement, security_reviewer, security_fixer, code_reviewer) on the run-events ledger, a per-project/project-self/instance usage rollup with a governed_runs meter, and a token-derived ESTIMATED cost for engines (ACP — copilot and others) that report no dollar figure of their own, marked cost_estimated on the feature and per round on the ledger, shown with an est. marker in the Cost & Budget table and feature detail (the usage rollups and CSV exports still sum estimated and actual into one figure — a named follow-up) — never that engine's own billed charge.
Public surfaces:
GET /api/features/:id (cost/token fields)GET /api/projects/:id/usageGET /api/project/usageGET /api/admin/usageGET /api/features/cost-summary (cost_estimated field)GET /api/features/admin/cost-summary (cost_estimated field)
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: Usage rollups and CSV exports still sum estimated and actual cost into one figure.
Budget And Quota
Status: 🟡 PARTIAL Audience: Operator
Project/tenant budget cap, duration cap, and quota configuration.
Public surfaces:
project budget_cap_usd / max_duration_ms config
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Limitations: No tenant-level budget or quota and no USD-denominated cap yet; cost authority is blocked on the credential broker (spec 011).
Provenance And Audit
Status: 🟡 PARTIAL Audience: Operator
Evidence, tamper-evident run-event ledger, and decision-history provenance chain construction for every feature run. Since spec 443 inc-3 (RM-262 e), every tool_call/subagent_spawn/tool_call_outcome run event on the claude-code transport carries which PASS produced it (spec_author/author/episode/ pin_fix/test_fix/doc/gate_fix/build_repair/repro/revise/ speckit_<step>), and a FAILED tool call (is_error: true) records one tool_call_outcome row (denied on a conservative permission-refusal match, error otherwise — never a false denied); a successful call still records nothing (no ledger growth on the common case). One engine_pass_tool_summary row is recorded per pass on return. Coverage is the claude-code transport only — ACP/headless tool events reach the workspace log, not run_events (EngineTask.onEvent has no consumer in src/). Spec 443 inc-7 (RM-262 l) extends the ledger side of this: the provenance_check_posted event (posted by the provenance-check capability) now records via/failure_reason/envelope_comment_posted, and both GET /api/features/:id/provenance and its admin equivalent serve a derived provenance_check block over closed enums only — see the provenance-check capability entry for the full posting-mechanism/fallback narrative. Spec 443 inc-25 (RM-282) extends the per-pass engine_pass_tool_summary row with declared_check_runs: {test_command, verify_command} — ALWAYS both keys, zeros included; ABSENT on a pre-increment row means unknown, never zero. Counted by a pure detector (matchDeclaredCheckRun, exact normalised-equality match on a Bash call's parsed command against the run's resolved declared checks, never a substring/wrapped match, so the count is a LOWER BOUND) reading the descriptor's pre-redaction text — nothing it reads is itself persisted, only the two integers. Wired at all 12 makeToolEventSink(...) call sites across flows/implement.ts/flows/implement-spec-kit.ts/flows/revise.ts (a source tripwire fails the build on an unwired new site); coverage stays claude-code transport only.
Public surfaces:
GET /api/audit/exportGET /api/features/:id/provenancePOST /admin/:id/rerecord-spec-record
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Limitations: Tool-event coverage is the claude-code transport only — ACP/headless tool events reach the workspace log, not the run-event ledger; an admin can now poll the ledger incrementally with GET /api/audit/stream?cursor=&limit= (insertion order, operator-view redaction, stored record_hash, admin-only, no tenant view); source=actor_events pages administrator actions the same way by their global seq (restore-safe cursor, plus a chain_head to keep outside the instance); push delivery and signed evidence are not built (RM-235). chain_verified for run_events is checked two ways now (spec 487 inc-b): a project key or an approver still gets only a self-consistency check of the records an export returns (chain_verification: "self_consistency", no stored_chain field); an admin additionally gets a check against the hashes and per-feature head register run_events actually stored at write time (chain_verification: "stored_ledger", a stored_chain object naming each break found). Legacy (pre-hashing) rows are counted, never verified — only their deletion is detected. Spec 488 inc-1 (RM-414) moves that admin check off the request path: a {}/project_id scope is now answered from a module-level cache of the last COMPLETED background pass (runRunEventStoredChainPass, sliced and yielding via setImmediate), dated with the pass's own start time (stored_chain.verified_at) and refreshed whenever that verdict is older than 5 minutes; a cold/stale-with-no-completed-pass scope answers stored_chain: {status:"pending", verified_at:null} and chain_verification:"self_consistency" instead of blocking the request. A feature_id scope is unaffected (stays synchronous). Not yet built: the same stored-ledger check for a tenant/approver's own view (would need a no-position-disclosed shape), and stored verification of the /export download (RM-414 inc-2 closes the sibling /health-blocking defect in buildAuditExport's own view build). Still true for every caller regardless: a rewrite that also recomputes the head register together with the stored hashes is not detected — that needs an anchor kept outside the database (RM-235). Administrator actions are recorded: the six admin routes as actor_events (spec 537 inc-1) and administrator feature edits as admin_field_set run events (spec 537 inc-2).
Mentions Collaboration
Status: 🟢 SHIPPED Audience: User
@mention-triggered webhook processing that routes human attention on a PR comment back into the governed feature flow, plus two execution paths: a governed @weftra merge whose gates and re-review run against the MERGE RESULT, not the branch (spec 249), and @weftra revise, a second door onto the existing POST /api/features/:id/revise flow for a VCS-confirmed project approver acting from the pull request (spec 113 inc-2 / RM-047).
Public surfaces:
PR comment @-mention trigger@weftra merge PR-comment command@weftra revise PR-comment command
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
External Pr Review
Status: 🟢 SHIPPED Audience: User
Reviews a PR Weftra did NOT author, on an enrolled project's own repository, via pr_review: off|mention|auto. A specless run emits observations (never a grade, never written to any merge-gate-read field); a PR matching a Weftra feature with a recorded spec is graded against that spec. Security findings are always produced, independent of whether a spec exists (spec 267).
Public surfaces:
pr_review (project field, self-configurable)@weftra review PR-comment commandPR-opened webhook auto-review
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Whole Repo Security Sweep
Status: 🟡 PARTIAL Audience: Admin
An administrator starts a whole-repository security sweep of a project's default branch (POST /api/projects/:id/repo-review, requireExplicitAdmin; the body takes an optional scope of path prefixes and nothing else). The run clones the project's own repository read-only through the sandboxed git path, keeps only regular-file blobs that pass the host path-safety check, and makes one tool-less analysis-mode engine call inside a DockerRuntime (refused with runtime_unavailable unless RUNTIME=docker). One sweep per project at a time; the start is recorded as a repo_review.started actor event before anything runs (fail closed). Results land in repo_review_runs as observations, never a verdict — nothing gates on them, and an unparseable or wrong-nonce output reads as inconclusive, never "no findings" (spec 550 inc-1, inc-2, inc-3a, inc-3b, RM-470). A project whose newest completed sweep has a floor/high observation gets one admin-only review_repo_sweep fleet-attention item (counts and short SHA only, never observation text, not notified); only a newer completed sweep with no such observation clears it. The project view has an admin-only Repository sweeps card to start and read sweeps. An administrator may also set a per-project cadence (repo_sweep_interval_hours, 24 to 2160 hours, null = off, the default; admin-only, withheld from a project key, change recorded on the ledger): a separate scheduler on its own timer (FA_REPO_SWEEP_SCHEDULER_TICK_MS, 0 = off) starts at most one due whole-tree sweep per tick through the same start path as the manual route, as a system actor with trigger: schedule, and skips while draining, off Docker or while the project's sweep is already running. A tenant read of sweeps and turning an observation into a feature are not built.
Public surfaces:
POST /api/projects/:id/repo-reviewGET /api/projects/:id/repo-reviewsGET /api/projects/:id/repo-reviews/:runIdPATCH /api/projects/:id (repo_sweep_interval_hours)
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.mddocs/SETUP.mddocs/design/whole-repo-review.md
Ci Check Remediation
Status: 🟢 SHIPPED Audience: User
Reads failing CI checks off a Weftra-authored PR (GitHub check-runs + statuses, GitLab pipeline jobs, Bitbucket commit statuses; a ci_failure webhook for a standalone CI system) and, for a check the project has declared actionable via ci_actionable_checks, answers it with a gated revising round — the same governed path POST /:id/revise uses, never a status change on its own. A po_approval project gets a ci_remediation_pending fleet-attention item and waits for an approver; auto_safe/full_auto start the round automatically. Bounded by ci_remediation_max_rounds (project key may only lower it) and an identical-excerpt-hash exhaustion rule; the log excerpt handed to the round is always fenced as untrusted data and byte-capped (ci_log_excerpt_max_bytes). A check Weftra itself posts is never remediated (spec 402, RM-190).
Public surfaces:
ci_actionable_checks (project field, self-configurable)ci_remediation_max_rounds (project field, conditionally self-configurable — a project key may only lower it)ci_log_excerpt_max_bytes (runtime setting, default 8000, bounds 1000..32000)ci_remediation_max_rounds_total (runtime setting, default 6, bounds 1..50 — aggregate cap per feature across check names)ci_remediation_min_interval_ms (runtime setting, default 60000, bounds 0..3600000 — 0 disables)POST /api/projects/:projectId/triggers/ci_failure (external CI webhook)dashboard ✓/✗/◔ CI-checks cell on the feature row
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/design/agent-inputs.md
Governed App Deployment
Status: 🟡 PARTIAL Audience: Operator
Declarative, approval-gated deploy of a project's own application (not Weftra itself) via project-declared recipes, executed in the sandbox — the spec 106 rebuild of the capability that shipped insecurely and was closed unmerged as PR #173.
Public surfaces:
POST /api/projects/:id/deploy
Docs:
docs/OPERATIONS.md
Limitations: Full deploy rebuild is blocked on the spec-011 credential broker per specs/106-governed-app-deployment-rebuild/spec.md — see CLAUDE.md's Trust Boundary incident note (2026-07-17).
Self Hosted Packaging
Status: 🟢 SHIPPED Audience: Operator
Self-hosted deployment packaging: container image + Docker Compose (with a proven one-command cold-start install rehearsal), and a Helm chart for Kubernetes.
Public surfaces:
docker compose uphelm install weftra deploy/helm/weftranpm run rehearse:install
Docs:
docs/SETUP.md
Prior Decisions Conflict Gate
Status: 🟢 SHIPPED Audience: Operator
Checks a submitted spec's text against a per-project, admin-owned corpus of prior decisions (prior_decisions_corpus) at the single feature-submission choke point, before any agent run is dispatched. A cited conflict (or an analyzer error) raises a clarification through the existing clarification_needed machinery instead of blocking the submission; an empty corpus is a complete no-op. The corpus is admin-only in both directions (unreadable by a project key on any path) and the check is bounded (concurrency cap, queue cap, timeout) so an unbounded flood of submissions cannot spawn unbounded host processes.
Public surfaces:
npm run measure:prior-decision-fp
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Finding Continuity Study Harness
Status: 🟢 SHIPPED Audience: Operator
An offline, operator-run study harness that measures whether a candidate classifier judges security-finding CONTINUITY ("is finding B a restatement of finding A?") better than the two deterministic rules Weftra already ships — the Jaccard-with-floor same-class predicate and the definition-of-ready finding fingerprint. export reads a SINGLE project's security-verdict run events (never across projects or tenants) and writes a JSONL fixture of labelled (current finding, prior open finding) pairs with a coverage block naming every skip class and a 70/30 hold-out split taken deterministically by FEATURE, so a re-export of an unchanged ledger reproduces byte-identically. score puts any number of named candidate prediction files beside both baselines — which are IMPORTED, never re-implemented — and emits a markdown report of aggregates only (pair counts, label split, coverage tallies, and agreement / precision / recall / F1 / Brier / 10-bin ECE per predictor, on the hold-out split and on all pairs); the report carries no finding text, file paths, feature ids or per-pair rows. Fixture text is redacted through the same audit/SIEM export helpers the ledger export uses, with the empty-secret-pool assertion run before the first byte is written — and, because that assertion reads the same environment the mask set is built from, an installation .env that exists but cannot be read refuses the export outright rather than masking nothing unnoticed. The fixture is written owner-only and the tool refuses to write it anywhere inside the repository working tree except the gitignored research output directory. Read-only measurement apparatus: no route, no dashboard, no OpenAPI entry, no schema change, no model call, no network, and no runtime behaviour change anywhere.
Public surfaces:
npm run research:finding-continuity
Docs:
docs/OPERATIONS.md
Merge Conflict Clarification
Status: 🟢 SHIPPED Audience: Operator
A base-merge conflict raised during /revise parks the feature at clarification_needed with one clarification per conflicted hunk instead of reverting silently to implemented. The answer is a CLOSED choice (base | branch | both | abandon) machine-derived per hunk — never patch text or file content on any path — and both is offered only where a mechanical check establishes both sides are pure, non-overlapping additions. Answering requires the approver bar: a project key can never answer, a code-changing choice requires a NAMED credential-verified admin/approver (the shared role key is offered abandon only), and neither the feature's submitter nor an apparent author of a commit on the PR's branch may — noting that git commit emails are self-asserted, so that last check is a signal rather than a boundary. The raise and the resolution are recorded by the FLOW in the run-events ledger (merge_conflict_raised / merge_conflict_resolved), in the same transaction as the state change — no API write path to the ledger is opened. The push itself is a network call that cannot join that transaction, so a merge_conflict_push_attempted event carrying the resulting SHA is written before it, and a resolution left incomplete by a crash in that window can be re-driven to completion. Resolution redoes the SAME pinned merge and pushes an ordinary two-parent merge commit; never a rebase or force-push, so the reviewed SHA is never rewritten and the new head re-enters both review gates from zero.
Public surfaces:
POST /admin/:id/clarifications/:clarificationId/answerpublic/index.html#on:answerMergeConflictClarification
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Generated Path Merge Resolution
Status: 🟢 SHIPPED Audience: User
A base-merge conflict confined entirely to project-declared generated_paths resolves by regenerating in the sandbox instead of raising a merge-conflict clarification at all — a generated file (e.g. a docs page rendered from a manifest) has no side worth asking a human to pick, since either side is discarded and replaced by the regenerate command's output. A project declares {path, regenerate} entries — ADMIN-ONLY, not self-configurable: naming a path removes it from the set spec 255 raises to a named approver, which is a control over the tenant. Weftra never learns what the command does. On a conflict confined to those paths, Weftra REMOVES the conflicted file (no side survives), runs the declared command through the same createRuntime seam setup_command uses with authMode 'none' (under RUNTIME=docker a container with no agent credential and none of Weftra's environment; under RUNTIME=local a host process that inherits Weftra's environment exactly as setup_command/test_command already do there), requires it to have written a regular file at that path, re-stages (literal pathspecs), and completes the merge — recorded as merge_conflict_auto_resolved. The resolution commit is bounded to what was declared (src/services/agent/generated-path-merge.ts, its docstring cites each line): every declared path is confined and its conflicted file removed before any command runs (no resolved host path is held across a command), the runtime that ran the commands is disposed before any check reads the repository, the delta between the pre-command index and the tree Weftra writes after staging is confined to the declared paths (anything else staged fails with reason index_modified), on a mixed conflict the approver's answers are staged before any command runs, and the finished merge commit's parents AND tree are verified against the pinned branch head and base and that written tree before anything is pushed (reason unexpected_parents / unexpected_tree); the resolve-time push names that verified commit by sha. A MIXED conflict still raises a clarification for the non-generated (source) files only, naming the excluded generated paths and their commands on the raise so the approver sees what else their resolution commit carries; only paths recorded at raise time are regenerated when the hunk choices are applied. A regenerate command that exits non-zero — or exits 0 without writing the file — fails the run outright, never a fallback to a clarification or a guessed side, since either would commit a file its own sync check rejects. The spec-255 closed choice set (base/branch/both/abandon) is unchanged; regenerate is deliberately not a fifth option, because a generated path is never shown to a human at all.
Public surfaces:
PATCH /api/projects/:id#generated_paths
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Generated Paths Normalized After Agent Edits
Status: 🟢 SHIPPED Audience: User
A project's declared generated_paths (the same {path, regenerate} declaration the base-merge-conflict resolution above already uses) get a SECOND consumer: every implement/revise/security-fix/spec-kit run regenerates them again, after EVERY agent turn that precedes an execution of the project's test/build gate (the first pass, each fix-iterate re-run, the post-doc-generation verify gate, each build-repair attempt) — so a generated file (a protobuf, a formatter's output, a lockfile, a generated docs page) an agent's edit to its SOURCE made stale is refreshed before the gate grades the tree, instead of the build going red in a file the agent never touched. No new field, no schema change: one declaration, two consumers. When generated_paths is unset or empty this is a complete no-op — no runtime, no command, no ledger event, no git operation. Otherwise every declared command runs in order in one short-lived runtime created the identical way the conflict-time path creates it (authMode 'none', the project's resolved runtime image and network policy, runtimeEgressProjectId scoped to the owning project), the runtime is disposed, and a git diff-tree between a before/after snapshot proves the invariant: no path OUTSIDE the declared set differs, in either the working tree or the index, capped by the same byte cap and unexpected-path report bound the conflict-time path already established. Deliberately NOT the conflict-time judge (which requires every declared path to appear as an added file): here an already-up-to-date generated file legitimately produces no diff and that is success, not a failure. A declared command exiting non-zero, or one that modifies/adds/stages a path outside the declared set, fails the run outright (named path/exit-code or named offending paths) and nothing is pushed — never a silent fallback. Wired after every agent turn that precedes a gate: implement.ts's main gate, each test-gate re-run in its fix-iterate loop, the verify gate when the doc-generation turn ran, each declared-gate re-run in its gate-iterate loop, and its spec-294 inc-b build-repair round; revise.ts's gate and its build-repair round; security-fixer.ts's gate and its build-repair round (both before its staging/strip pass); and implement-spec-kit.ts's gate. Recorded on the ledger as generated_paths_normalized (flow, declared count, success/failure class, path NAMES only, bounded) once per pass — never file content or raw command output; the command's captured stdout/stderr reaches the per-job workspace log only, and only after redactSecrets (the same scrub test_command output gets), on both runtime shapes. generated_paths stays admin-only, exactly as spec 385 left it; this spec does not touch who may declare it. The conflict-time paths (regenerateGeneratedConflicts, merge-conflict-resolve.ts, base-refresh.ts's stash-restore regenerate, revise.ts's all-generated fast path) are byte-identical after this change.
Public surfaces:
none declared
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Recurrence Classification
Status: 🟢 SHIPPED Audience: Operator
Retrospective, operator-driven classification of Weftra's own recorded security_verdict findings into candidate recurrence classes — a repeated SHAPE (the same rule violated across different features), not a repeated file or string. An admin reviews each candidate class with its full instance evidence (feature id + verdict event per instance) and either promotes it into the prior-decisions-conflict-gate's prior_decisions_corpus, or rejects it so it is not re-proposed. All four routes are admin-only in both directions; the classifier never writes anything itself — a human promotes, the machine only proposes. Spec 443 inc-71 (RM-299 part 3b): recurrence.html moved onto the shared, DOM-free public/project-selection.js picker (chooseProject) — it honours a listed ?project= and remembers the last choice (localStorage['fa_recurrence_project']), instead of always taking GET /api/projects' newest-first list[0]. Deliberately carries no per-project count: recurrence classes are computed ON DEMAND by POST .../recurrence/classify against the ledger, not stored rows, so there is no cheap, truthful count to show without running the classifier — this page must not do that on load. Its project <option>s are now built with createElement/.value/.textContent instead of an innerHTML template.
Public surfaces:
public/recurrence.htmlPOST /:id/recurrence/classifyGET /:id/recurrence/rejectedPOST /:id/recurrence/promotePOST /:id/recurrence/reject
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Release Notes Verification
Status: 🟢 SHIPPED Audience: Operator
Operator-invoked, admin-only generation of release notes that are VERIFIED against what a release ref actually contains. Sentences are derived mechanically from the capability manifest diff between two refs and each cites the capability id it came from, so a sentence with no manifest lineage cannot be emitted. The operator's PR range and exclusion list are an assertion Weftra checks against branch reachability, never an instruction it obeys: an excluded PR whose commits are reachable is included and marked as a prerequisite, and the override is recorded as its own actor event. Reads only — it never writes a manifest, tags, publishes, or runs on merge.
Public surfaces:
POST /release-notes
Docs:
docs/OPERATIONS.md
Per Project Autonomous Loop
Status: 🟡 PARTIAL Audience: Operator
The Weftra-native, per-project autonomous delivery loop: a project declares a backlog source + loop config (enabled, backlog_source: repo_document|definition_store, backlog_path, merge_policy: human_all|auto_low_risk, risk_policy), and Weftra's maintainer role selects the next unblocked backlog item, submits it as a governed feature, and — under auto_low_risk — lands it via a SHA-pinned governed merge gated by a tighten-only risk classifier + reviewer/security verdicts. Under backlog_source: "definition_store" (RM-147 inc-1) Weftra itself — never the model — deterministically selects the next unblocked ready story from the project's OWN Product Definition Plane (product_artifacts) instead of reading a repo document, and the maintainer role only drafts that story's spec; a project no longer has to maintain a second, parallel backlog document once it has defined its product in Weftra. Under definition_store (RM-147 inc-2) selection is now a POLICY over fields the store already declares: a story's roadmap-item ancestor's declared order is honoured (ascending, declared-before-undeclared, otherwise inc-1's creation order), an ancestor's declared depends_on blocks selection until every story serving each dependency reaches delivered (fail-closed on anything unresolvable, with a named disposition), and a story Weftra has drafted unproductively three times at its current content hash is passed over until an edit or a submission resets the count — closing inc-1's stall where one unspeccable story blocked every later one forever. Every decision is recorded (maintainer_selected's selection_policy block, plus new maintainer_draft_outcome, maintainer_story_passed_over, and maintainer_no_selectable_story events), so an idle cycle explains itself from the event feed. A select-stage fault now STOPS the loop instead of retrying forever: a PERMANENT fault (unsupported_engine, underivable_key, no backlog_path configured under repo_document) halts on its first occurrence, and a TRANSIENT one (an unreadable backlog_path, a maintainer-call error, an over-budget estimate) halts once it recurs chronically — cleared only by an admin's resume call, same as the post-merge canary halt; no selectable story under definition_store is a genuine idle, never a fault, exactly like an empty repo_document backlog. Under definition_store (RM-147 inc-3) a roadmap_item may additionally declare an opaque serialization_lane string (compared byte-for-byte, interpreted otherwise not at all): a story is excluded (lane_occupied) while another story lineage in the same declared lane already has a linked feature in flight (awaiting_approval/queued/in_progress/implemented/failed), and the lane frees itself once that feature reaches merged/wont_merge/cancelled — a transient mutual exclusion, distinct from depends_on's permanent ordering, for the "these two must not overlap" case a project's own hot files or migration surfaces create. Evaluated after the existing dependency/pass-over checks so a story that is both reports the one needing human action. GET /api/projects/:id/product/next-pick (admin/approver only) evaluates the exact same selection policy a cycle would, read-only — no event, no write, no pass-over consumption — and returns what would be picked and why, including every excluded story's disposition; the Product canvas renders it as a "Next pick" panel. Distinct from selfbuild/ (Weftra's own external bash harness); this is the productized, multi-tenant capability any enrolled project can arm. Under repo_document (spec 450 inc-2, RM-316 a) an ordering the backlog document states about its own items — that one is to be picked first, is a blocker, or must precede the others — outranks any category, tag, label, severity, or position the document also carries; Weftra still parses nothing of the document to enforce this, the maintainer role is simply told a stated ordering is document content, never an instruction aimed at it, and it can never select a BLOCKED item or change the output format/one-item/ledger rules. The repo_document half now also closes the asymmetry with next-pick: the maintainer's OPTIONAL, bounded selection_rationale (chosen_reason + passed_over[]) is sanitized and recorded on its own maintainer_selection_rationale event (source: "model", never mixed into any Weftra-derived event) immediately after a successful parse — before dedup or submission, so a cycle that skips still explains itself — and projected as loop_status.last_selection, read by both GET /api/projects/:id and GET /api/project. (RM-310 inc-3a) The three fields this policy reads off a roadmap_item — order, depends_on, serialization_lane — are operator-set on the WRITE side too: a project key's create or version request is refused (403, naming the field(s)) if it sets or clears (including {"field": null}) any of the three, and a project key's version that omits all three carries the lineage's current values forward byte-identically rather than silently dropping them. These two routes admit an admin user key, an admin session, or the instance admin key (an approver is 401 here, and writes these fields through the bulk import door instead), and an admin may still set, change, or clear all three freely on create and on version. summary/rationale/labels/references on a roadmap_item, and every field on every other kind, stay exactly as tenant-writable as before. The lock is on the item's VALUES; which item a story is judged by is now ALSO locked once governed (RM-310 inc-3c, below). A refused attempt is recorded on the actor ledger, names only and at most one row per project per hour — a free refusal is never an unbounded append primitive. (RM-310 inc-3b) A roadmap_item may additionally declare an opaque cluster string — a fourth operator-set selection input, subject to the same write-side lock and carry-forward as order/depends_on/serialization_lane. Whether a declared cluster is ACTIVE for a project is a separate, per-project record (product_cluster_activations) that only a NAMED admin can change (requireNamedAdmin — an approver, the shared ADMIN_API_KEY, and a project key are all refused): POST /api/projects/:id/product/clusters/activation with {cluster, active} (404 activating a cluster nothing declares; deactivating is always allowed; idempotent re-setting writes neither a row nor an actor event), and GET /api/projects/:id/product/clusters lists every declared cluster with its item count and activation state (admin/approver read). With at least one active cluster, the selection policy applies every existing exclusion first and THEN prefers a surviving candidate whose item declares an active cluster over the ordinary order — falling through to the ordinary ranking when none survive; with no active cluster the output is byte-identical to before this increment. GET .../product/next-pick carries active_clusters and selected_from_active_cluster; the Product canvas renders a Clusters panel (declared clusters, counts, activation state, an Activate/Deactivate control gated the same way the candidates panel's decision control is). (RM-310 inc-3c) Closes the residual the two paragraphs above stated: once a story resolves to a governing roadmap_item (resolveRoadmapItemForStory), a project-key version that would resolve it to a DIFFERENT item, or to none, is refused (403, governing_item_changed) — moving a story between two use cases under the SAME item is still allowed. A project-key version that omits use_case_lineage_id carries the current link forward instead of silently dropping it (recorded as binding_carried_forward); sending it explicitly, including null, is judged as a move. A project-key CREATE of a story/use_case, or a version that first binds a previously ungoverned story, is refused (active_cluster_binding) when the resulting governing item declares a cluster currently ACTIVE for the project — otherwise authoring is unaffected: a project key can still create new stories/use cases under any item not in an active cluster, or a story with no governing item at all. Both refusals share the existing product_artifact .selection_input_refused ledger action and its one-row-per-project-per-hour bound; an admin user or the instance admin key is unaffected by either rule.
Public surfaces:
PATCH /api/projects/:id (loop config — admin, every field)PATCH /api/project (loop config — self-service, conditionally writable as of spec 319/RM-083: only on a project whose loop an admin already established, and then enabled/backlog_path/backlog_source/merge_policy:human_all only; a resulting auto_low_risk is refused 403)POST /api/projects/:id/loop/resume (spec 377/396 — admin-only; the one act that clears any loop halt: a post-merge-canary halt or a select-stage fault halt)POST /api/projects/:id/loop/items/:ledgerKey/approve-merge (spec 460 inc-1a/RM-324 — requireNamedAdmin only; approves landing a parked_human item at exactly the verified head its readiness row is bound to; executes nothing itself)
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.mddocs/design/agent-inputs.md
Limitations: Outstanding (RM-048/RM-084): inc-4 dogfood (retire the bash Governor, run Weftra itself on the platform loop) — inc-3's canary + auto-revert closed by spec 377, see below. human_all is production-sound and live-proven on a customer project (chessdemo, 2026-08-26). Was missing from this manifest entirely until 2026-08-26 — the archetype "shipped but invisible to a header skim". Spec 319 (RM-083) split the arming privilege by resulting merge_policy: a project key may now self-arm the human_all form (no new authority — every cycle still pauses for a human, the same thing the project could already trigger via POST /api/features); auto_low_risk (real autonomous-merge authority) stays admin-only, including a partial project-key patch that would leave an admin-set auto_low_risk in place. Its round-1 security review closed three gaps in that split: a project-key loop: null now DISARMS rather than erases (the admin-only cadence/back-pressure/budget/risk_policy fields are carried over, so clear-then-rearm cannot shed operator limits), backlog_path is validated repo-relative at set time (it is tenant-settable and dereferenced with Weftra's VCS credential), and the self-service loop write commits atomically with its loop_events record. Round 2 added the operator-consent precondition the split had traded away: a project key may only move a loop an ADMIN established on the project (its loop column non-NULL), never create one, so upgrading grants no tenant anything and a self-armed loop always runs inside cadence/budget limits an operator wrote; and the self-armable/admin-only field partition is now derived from one list with a compile-time totality check, so a future LoopConfig field cannot silently fall outside the guards. The final security round applied the same partition to the READ side (a project key gets back only the enabled/backlog_path/merge_policy subfields of loop — risk_policy and the operator's cadence/back-pressure/budget limits are admin-readable only) and unified the ledger so EVERY loop-config change by either principal is recorded (loop_armed/loop_disarmed on an enabled flip, loop_config_changed otherwise — including an admin arming auto_low_risk on an already-enabled loop), committing in the same transaction as the config write on both routes. Spec 377 (RM-084 item 1) closed the first half of inc-3: after EVERY landing the loop itself performs under auto_low_risk, a fresh post-merge canary clones the default branch and re-runs the project's own gate set against the bytes actually there; red reverts the landing (a compare-and-swap --force-with-lease push, never a plain force-push) when it safely can, and either way HALTS the project's loop in a way the scheduler actually honors (isLoopHalted, derived from loop_events, no schema change) and notifies. POST /api/projects/:id/loop/resume is the one admin act that clears a halt. Spec 446 inc-1 (RM-147) added a second selection SOURCE alongside the repo document: backlog_source: "definition_store" makes Weftra's own product_artifacts store (a project's declared outcomes -> roadmap items -> use cases -> stories) the thing the maintainer reads from — Weftra itself selects the next unblocked ready story (excluding one that already has a non-terminal linked feature, so a po_approval project's still-awaiting_approval submission is never re-selected next cycle) and the maintainer role only drafts that story's spec, never chooses. The maintainer authors nothing into the store (no artifact created, no status set by loop code, no escalation filed), but the submitted feature carries story_lineage_id, so the EXISTING story->feature delivery hook advances that story ready -> in_delivery when the feature reaches queued — inside the cycle on a full_auto project, at approval time on po_approval — exactly as it would for a hand-submitted feature carrying the same field, and (as on that path) without an actor event. Self-configurable exactly like backlog_path — the store read is project-scoped either way, so this grants no new read, only a choice between a tenant's own two work sources. No dashboard panel for this yet (deferred to inc-2 alongside the escalation write path and a selection-policy view). Status stays partial: inc-4 (dogfood — retire the bash Governor) is still outstanding. Spec 460 inc-1a (RM-324) gives a parked_human item (ANY merge_policy, not only auto_low_risk) the same merge-readiness machinery auto_low_risk already has: a NEW loop_merge_readiness table binds readiness to one exact head SHA (reviewer, security and build all passing there), recorded/cleared via loop_merge_ready / loop_merge_ready_cleared run events and surfaced as loop_status.ready_to_merge. The one deliberate difference from auto_low_risk's own gate: a high path/content risk signal is RECORDED, never refusing — a human decides on risk here. A NAMED admin (never the shared ADMIN_API_KEY, never an approver, never a project key — same requireNamedAdmin/separation-of-duties bar as candidate disposition/cluster activation) approves exactly one head via the new approve-merge route, which executes nothing itself; the next scheduler tick re-verifies readiness at that head and lands through the IDENTICAL runLoopAutoMerge sequence auto_low_risk uses — no new merge/sync/gate path, no post-merge canary for a human-approved landing. A head move clears both readiness and any pending approval. Spec 460 inc-2 (RM-324) put four loop stalls a human owes something on — a ready-to-merge head, a merge-time verification failure, a stalled revising item, and a halted loop — onto the EXISTING fleet-attention queue (SLA, breach ordering, breach notification through the project's own channels, unchanged for every other item) and added a one-click "Approve head" control to the fleet board for the first of those, posting to the SAME approve-merge route above. A ready item now shows as approve_loop_merge, never the generic merge, so the board cannot invite a provider-UI merge that skips Weftra's own merge-result verification; a merge-time verification failure shows as loop_merge_verification_failed, distinct from both. A halted loop's board item (resume_loop) is shown but never re-notified by the sweep — the halt already sent its own one-time digest. No schema change, no new route, no new config variable. Spec 460 inc-3 (RM-324) resumes a loop item stalled only because a run never reached a verdict, once the cause clears — uncharged against retry_count, capped, and always recorded. Gap A: a code-reviewer escalation whose lineage never produced a verdict (only reviewer_error/reviewer_dispatch_error) is reopened the same way an admin reopen is, naming the superseded reviewer_final by id; a lineage with ANY reviewer_verdict is never touched. Gap B: an implement run failed on a cause Weftra itself believes never ran the tenant's code (infra, sandbox_container_missing) after Weftra's own retry budget was already spent — requeued via a new conditional, uncharged writer (requeueFailedFeatureUncharged). The new selection query (getLoopInfraResumableFeatures) is prior-decision-guarded and registered in RELAUNCH_SELECTION_QUERIES. "Cleared" means not credits/auth-paused, a 30-minute cooldown doubling per resume already taken has elapsed, and the per-item cap (2) is not reached; past the cap Weftra records the fact once and notifies, leaving the item for a human. No new route, guard, config variable or schema column; engine_auth and the autonomous security fixer's human-reopen-only terminal are deliberately excluded. Surfaced as loop_status.infra_resume (identical on the admin and tenant payloads — it carries no principal) plus a channel notification on each resume and on exhaustion.
Fleet Fan Out
Status: 🟢 SHIPPED Audience: Operator
Submit one spec across N enrolled projects at once (with campaign grouping + rollup) — the wedge's sharpest single feature, reachable via the admin fan-out route.
Public surfaces:
POST /api/features/admin/fan-out
Docs:
none declared
Eval Replay Profiles
Status: 🟡 PARTIAL Audience: Operator
Shadow-replay already-shipped features to measure a config change's real effect — an experimentation substrate over the eval harness.
Public surfaces:
none declared
Docs:
none declared
Limitations: inc-2 (persistent toggle, drain, schedule, autonomous selector, fleet-metrics exclusion) outstanding (RM-050).
Model Rejection Auditable
Status: 🟢 SHIPPED Audience: Operator
A refusal by the model/provider is a first-class recorded outcome, not a silent failure — part of the provenance story.
Public surfaces:
none declared
Docs:
none declared
Actor Identity Proven
Status: 🟢 SHIPPED Audience: Admin
Separation-of-duties that demands a NAMED identity and refuses the shared admin role key — authority is not attribution, enforced in code (records actor events).
Public surfaces:
none declared
Docs:
none declared
Limitations: Spec 472 inc-1 (RM-348 a): approvals[].separation reads satisfied only for a verified, NAMED approver — an approval made with the shared admin key, or any other credential that does not resolve to a named person, always reads not_asserted, regardless of the separation policy level or a waiver. Stored packs are not rewritten.
Build Tracker
Status: 🟢 SHIPPED Audience: Operator
The Weftra Build Tracker (spec 320): a self-updating capability-inventory-vs-north-star page, generated from docs/features.yml + the north-star pillar constant — per-pillar coverage, status counts, and a gaps list are all DERIVED from the manifest, never hand-typed, so the page republishes automatically whenever a shipped feature updates the manifest. Being a published page, it renders only user/operator/admin-audience entries — the same audience gate the Capabilities page applies — so internal- and contributor-facing capabilities stay off the public site.
Public surfaces:
npm run docs:build-tracker
Docs:
docs/current/build-tracker.mddocs/OPERATIONS.md
Local Instance Reachable From Sandbox
Status: 🟢 SHIPPED Audience: Operator
RM-176 / spec 413: an agent talks to its LOCAL instance. FA_CONTAINER_BIND opens a sandbox-facing listener that serves only the ephemeral-token /api/internal/* routes (+ /health), and the enforced-egress forwarder publishes a path-restricted relay to it on the run's --internal network — so the MCP-gateway shim, helper-role shim and fa-act reach Weftra from either topology while nothing a project or admin key guards is reachable from a container.
Public surfaces:
none declared
Docs:
none declared
Mcp Gateway Allowlist And Audit
Status: 🟢 SHIPPED Audience: Operator
The MCP-gateway MVP (spec 348 / RM-122, PEP #3's buildable core): an operator-managed, per-project allowlist of MCP servers a run's declared mcp.servers (spec 248) may actually be wired to, plus a Weftra-owned proxy that argument-audits every upstream MCP call to the tamper-evident run_events ledger before forwarding. Closes two governance gaps — no operator approval for a repo-declared server, and a ledger that recorded only {tool, summary} for an MCP call — without the signed-mandate crypto layer (spec 161/163), which remains design-only and slots into the same choke point later.
Public surfaces:
POST/PATCH/DELETE/GET /api/admin/projects/:id/mcp-allowlist (admin-only)POST /api/internal/mcp-gateway/invoke (run-token-authenticated, internal)
Docs:
docs/OPERATIONS.mddocs/design/agent-auth/07-mcp-gateway-and-connectors.mddocs/design/agent-inputs.md
Sandboxed Browser Tool
Status: 🟢 SHIPPED Audience: Operator
A sandboxed browser tool (spec 350 / RM-120): a per-run, operator-granted tool backed by the official @playwright/mcp server that renders a declared app URL entirely inside the fa-runtime-browser DockerRuntime image and returns a screenshot + accessibility-tree DOM + console errors to the agent. The grant is derived from the already-operator-only runtime_image field (never a new tenant-reachable field), routed through the spec-348 MCP gateway over a dedicated in-sandbox transport (never a host spawn), scoped to exactly the Playwright-MCP tool set plus Read (tool-as-grant, spec-303 pattern), and egress-bound to the run's own declared app host(s). Advisory only — sets no verdict, gates no merge. The enabling primitive for spec 286 inc-2's rendered designer review (spec 347).
Public surfaces:
runtime_image: fa-runtime-browser:latest (admin-only run config — grants the tool)
Docs:
docs/OPERATIONS.mddocs/ENGINES.mddocs/design/agent-inputs.md
Advisory Designer Review
Status: 🟢 SHIPPED Audience: User
An opt-in, ADVISORY-ONLY design-review pass (spec 286 inc-1) that posts at most one non-blocking PR comment on a feature whose diff touches a project-declared UI path (designer_paths) — action placement, heading/landmark hierarchy, accessibility attributes, consistency with existing patterns. Since spec 347 (286 inc-2), when a project also declares designer_routes and the sandboxed browser tool (spec 350) is available, the SAME pass additionally renders the matched route(s) in fa-runtime-browser and reviews the rendered page too (screenshot + accessibility-tree snapshot + console errors), falling back to the diff-only note on any render failure. Never sets REQUEST_CHANGES, never feeds the Fixer, never enters a gate, never edits a file, and never blocks a merge — a feature's status/gates/merge eligibility are identical whether this is on or off.
Public surfaces:
designer_review / designer_paths / designer_routes / designer_breakpoints (project self-service config)designer_review (per-feature override)
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/design/agent-inputs.md
Learning Plane Repository Knowledge
Status: 🟢 SHIPPED Audience: User
Learning Plane v1 (spec 351, RM-125, promoted from draft 310): a per-project knowledge_items store (candidate|approved|deprecated|superseded), deterministic bounded retrieval that injects only APPROVED items as a DATA block into the author prompt, hash-pinned run-evidence of exactly which items a run consumed, and a project API where a project key may submit/list its own candidates but never approve. Core invariant: learned knowledge may influence instructions, never grant authority — no field on the model is executable/tool/network/credential/merge/reviewer. Foundation for the Agent Factory program's governed skills (draft 311) and delegation (draft 312). Retrieval also feeds the conformance and security reviewers (spec 548 inc-1, RM-469): same project and repository, only items approved before the feature was submitted, as DATA that can sharpen a review and never pass one.
Public surfaces:
GET /api/projects/:id/knowledgePOST /api/projects/:id/knowledgePATCH /api/projects/:id/knowledge/:kidPOST /api/projects/:id/knowledge/:kid/approvePOST /api/projects/:id/knowledge/:kid/deprecate
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/design/agent-inputs.md
Learning Plane Hybrid Semantic Retrieval
Status: 🟢 SHIPPED Audience: User
Learning Plane inc-2 (spec 354, RM-125-inc-2): adds semantic (vector) retrieval alongside spec 351's keyword/applicability match, fused by Reciprocal Rank Fusion, behind the existing resolveApprovedKnowledgeForRun seam. A portable VectorStore interface (sqlite-vec backend with a brute-force cosine fallback now, a documented 1:1 pgvector migration contract for Postgres) stores driver-neutral float32 embeddings, with tenant scope enforced STRUCTURALLY inside the KNN query, never post-filtered. A pluggable EmbeddingProvider ships LOCAL ONLY (DECIDED, operator 2026-09-01): an in-process ONNX sentence-embedding model (bge-small-en-v1.5, 384-dim) via fastembed — no knowledge text ever leaves the Weftra host. The model artifact is treated as its own supply-chain input rather than as a dependency: Weftra never uses the library's own downloader, sha256-checks the tarball before extracting it and every file before onnxruntime parses it, and does NOT fetch it at all unless an operator opts in (FA_EMBEDDING_ALLOW_MODEL_DOWNLOAD, default false — operators pre-provision the cache instead). An operator-selected model Weftra ships no digests for is never downloaded. An approver's declared applicability governs both signals: semantic similarity re-ranks the items the spec 351 filter admitted and never re-admits one it excluded. Index lifecycle (embed on approve, re-embed on an approved item's edit, remove on deprecate/supersede, explicit-admin-only rebuild) keeps the relational knowledge_items table authoritative; the vector index is a rebuildable accelerator only. An empty/unavailable index falls back to keyword-only, never failing a run. Core invariant unchanged: an embedding is a derived retrieval index, grants no authority.
Public surfaces:
POST /api/projects/:id/knowledge/reindex (explicit admin only — requireAdminAuth + requireExplicitAdmin)
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/design/agent-inputs.md
Learning Plane Governed Skills
Status: 🟢 SHIPPED Audience: User
Learning Plane — governed reusable skills + effectiveness (spec 359, RM-130, promoted from draft 311): a per-project, versioned skills store (candidate|approved| deprecated), a deterministic explainable selector that folds only APPROVED skills into an author or declared-helper-role prompt as a bounded, DATA-framed block appended after the spec-351 knowledge block, and effectiveness metrics (first-pass acceptance, review/security findings per run, rework, verification failure rate, median tokens/ latency, with a comparable baseline cohort and a low-sample confidence band) computed entirely from EXISTING review/verification run-evidence. Core invariant: a skill contributes guidance, never authority — it may DECLARE required capabilities (tools/network) but they are only CHECKED against a run's already-resolved envelope, never unioned into it; an unmet declaration is skipped (capability_missing), never granted. Versions are immutable by content-hash — revising a skill always creates a new candidate version, never mutates the one a prior run consumed, and at most one version of a given skill name is ever approved at a time (enforced by the approve transaction AND a partial UNIQUE index, not merely documented). A project key may submit/list/revise its own candidates but never approve; promotion requires a NAMED admin/approver — the shared ADMIN_API_KEY is refused because it authenticates a role rather than a person, the identity that authored a candidate cannot approve it, and every approval/deprecation is appended to the tamper-evident actor_events ledger. Admin dashboard surface: candidate queue, version lineage/diff, applicability, required capabilities, source evidence, and per-skill effectiveness.
Public surfaces:
GET /api/projects/:id/skillsPOST /api/projects/:id/skillsGET /api/projects/:id/skills/:skillIdPOST /api/projects/:id/skills/:skillId/revisePOST /api/projects/:id/skills/:skillId/approvePOST /api/projects/:id/skills/:skillId/deprecateGET /api/projects/:id/skills/:skillId/effectivenesspublic/skills.htmlpublic/index.html#data-tab:skills
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/design/agent-inputs.mddocs/AGENT_FACTORY_QUICKSTART.md
Learning Plane Pm Readable Candidate Approval
Status: 🟢 SHIPPED Audience: User
PM-readable candidate approval surface (spec 365, RM-133): the human half of the Learning Plane's candidate -> approval trust model. Adds public/knowledge.html, a knowledge candidate-approval UI mirroring public/skills.html (project selector, status filter, cards, admin/approver Approve + Deprecate wired to the existing spec 351 endpoints) — closing the gap where knowledge candidates were API-only. On BOTH public/knowledge.html and public/skills.html, every candidate card now LEADS with a plain-language block — what the item is, why it's proposed (human-readable provenance derived from source_type + evidence count, or "submitted by" the actor name), and what changes if approved (a fixed sentence restating the "influences instructions, never grants authority" invariant) — plus the source evidence rendered as a readable list. Machine fields (applicability/required_capabilities JSON, raw source_refs, content_hash, confidence) stay available but collapsed behind a "Technical detail" disclosure, no longer the first thing a non-engineer approver sees. UI-only: no new route, no schema change, no change to any existing guard — reuses the spec 351/359 APIs exactly as they are. Spec 383 (RM-151 inc-1) added a "Sort: newest | most recurring" control (drives ?sort=recurrence) and a "cited N times" line (recurrence_count) to every card's lead block, and a paired-card path: a knowledge candidate and a skill candidate that Weftra's OWN generation seam mined from one cluster render as ONE card with "Approve as knowledge", "Approve as skill", and "Deprecate both". Byte-identical source_refs are only the lookup key — source_refs is caller-supplied on the tenant-facing submit routes, so pairing also requires Weftra provenance on both rows (knowledge reviewer_finding / skill learned, each with created_by_actor: fa:candidate-generation, neither mintable by a project key); an ambiguous key pairs with nothing, and anything unpaired renders as a normal card. The card shows the skill's own name, description and INSTRUCTIONS, so "Approve as skill" never promotes text the page did not display. Built entirely with createElement/textContent, never an HTML template string, so no repository-supplied text on that path is ever interpolated into HTML. public/skills.html is unchanged. Spec 443 inc-46 (RM-299 part 2): both pages now find a project that actually HAS open candidates instead of defaulting to the newest (usually emptiest) one. A new admin-only GET /api/learning/candidate-counts (src/routes/learning-candidates.ts, mounted at a fresh top-level /api/learning segment) returns, for every project holding at least one candidate of either class, {project_id, knowledge_candidates, skill_candidates} — counts only, computed by two grouped aggregates (countCandidateKnowledgeByProject/countCandidateSkillsByProject, never a per-project loop) rather than a summary/body/title. Both pages fetch it best-effort alongside GET /api/projects; a failure only loses the dropdown's labels and the default-to-most-candidates rule, never the page (a muted note explains). A new pure, DOM-free static module, public/learning-plane-selection.js, is shared by both pages and decides which project to select: a ?project= id in the list, else the project remembered from a prior visit (localStorage['fa_learning_project'], one key shared by both pages), else the project with the most candidates of THAT page's own class (knowledge on knowledge.html, skills on skills.html) when any project has one or more — ties broken by the other class's count, then by project name compared by CODE POINT (never localeCompare, the non-determinism RM-261 flagged), then by id — else the newest project as before. Every selection, including the first automatic one, is persisted to both localStorage and the URL (history.replaceState), so the view is linkable and survives a reload. The dropdown itself is rebuilt with DOM APIs (createElement('option')/textContent, no innerHTML template string), grouped into "Has open candidates" / "No open candidates" optgroups when counts are available. Both pages' loadProjects non-OK branch, and skills.html's candidate-list non-OK branch, now render the server's error text instead of silently leaving the page blank. Spec 443 inc-64 (RM-305 part 1): an Analyze control on every candidate card, backed by two new read-only routes — GET /api/projects/:id/knowledge/:kid/analysis and GET /api/projects/:id/skills/:skillId/analysis — that compute the DETERMINISTIC facts deciding a candidate: no model, no new storage, no repository clone. Same guard as approve/deprecate (requireAdminOrApproverForProject — a project key is refused), and neither route accepts a body or reads any query parameter (everything analyzed comes from the stored candidate row, the project's own security-review ledger, and the project's own repository — never caller input). The facts: rounds per feature (parsed only from Weftra's own r<N>-f<M> finding-id format, never guessed) — the signal that a cluster citing r6-f2/r7-f5/r9-f4 inside ONE feature is one reviewer re-flagging an unfixed finding, not three independent hits; cohesion across the clustered claims' normalized tokens (src/services/finding-class-clustering.ts's own normalizeClaimTokens/isSameClass, so the analysis can never disagree with the miner about what "the same class" means); and, per citation, whether the file is still present and whether the reviewer's originally quoted bytes still locate in it (src/services/review/finding-anchor.ts's locateQuotedBytes — the exact function the security-review carry mechanism uses), read through spec 215 credential resolution (the project's own configured VCS credential when it has one, else the recorded operator-default token). path_status is present/not_returned/not_read with no absent-vs-unreadable split, because the VCS providers collapse a deleted file, a 404, a 403, a rate limit, an over-size file and a network error into one empty result — so not_returned claims only "the repository returned nothing", never "the file was deleted". The response never carries repository file text or an excerpt — statuses, counts and line numbers only — and is fail-soft: a missing repo_url/credential, an unresolvable branch, a provider error or an exhausted outbound budget yields resolution.attempted: false with every citation not_read, HTTP 200. A fixed not_claimed array on every response (and restated in the UI) says what this is NOT: a verdict on the candidate, a claim that line_hint_in_range is identity (quote_status is), a check of whether the finding's ask is already fixed at the cited path (deferred to RM-305 part 2), or a claim that a not_returned file was deleted. Bounded by three Weftra-owned constants per call — 50 cited refs, 20 distinct repository paths, 300 cohesion pairs, with truncated_reasons naming which bound bound — and across calls by an outbound budget of 600 provider calls per project per hour AND 1,200 per hour instance-wide (src/services/candidate-analysis-budget.ts), so a queue-triage session cannot drain the VCS-credential quota. The instance-wide ceiling is the one that bounds the SHARED resource: spec 215 resolves a project with no credential of its own to the recorded operator-default token, so N per-project ceilings bound nothing about that one token. A project with no repo_url is charged nothing, since its analysis makes no provider call. Both public/knowledge.html and public/skills.html render the result beside (never in place of) the existing verbatim evidence, under "Computed facts (deterministic — no model)", built entirely with DOM APIs (createElement/textContent) — no innerHTML template string. Spec 443 inc-66 (RM-305 part 2): layer 2 — a "Read it for me (model)" control beside the computed-facts panel that calls POST /api/projects/:id/knowledge/:kid/analyze / POST /api/projects/:id/skills/:skillId/analyze (same admin/approver guard). ONE fenced, tool-less, analysis-mode model call answers four bounded plain-English slots — what the finding claims, what it wants changed, whether it generalises into a rule or is one bug, and whether it still applies at the cited path — over the candidate's own stored text, the recomputed layer-1 facts, and at most 5 bounded repository excerpts (40 lines/8 KB total, read at the SAME resolved commit sha the facts route anchors to, never a branch). Shown as an attributed MODEL reading, never as Weftra's assessment, and it can never approve/deprecate/modify anything or feed any gate. The route accepts NO body (any key is refused 400 unknown_fields before any read or spend) and ignores every query parameter. Dispatched under the same bounded-admission gate the Agent Solution Builder's workflow interview uses — a free refusal answers 429/503 with retry_after_s, an overrun answers 504 — and cached in-process, keyed on the candidate's content_hash (at most 500 entries; a cache hit spends nothing). Every dispatched call, success or unparseable, appends one candidate_analysis.reading actor event carrying ids/content_hash/model/cost/tokens/outcome only — never the finding text, an excerpt, or the reading itself.
Public surfaces:
public/knowledge.htmlpublic/skills.htmlpublic/index.html#data-tab:knowledgeGET /api/learning/candidate-countsGET /api/projects/:id/knowledge/:kid/analysisGET /api/projects/:id/skills/:skillId/analysisPOST /api/projects/:id/knowledge/:kid/analyzePOST /api/projects/:id/skills/:skillId/analyzepublic/knowledge.html#on:loadAnalysispublic/skills.html#on:loadAnalysispublic/knowledge.html#on:loadModelReadingpublic/skills.html#on:loadModelReading
Docs:
docs/USER_GUIDE.mddocs/security/authz-surface.jsondocs/security/derived-state-inventory.jsondocs/design/agent-inputs.md
Execution Checkpoints
Status: 🟢 SHIPPED Audience: User
Execution Checkpoints (spec 360, RM-128, promoted from draft 312 Part B): a durable, append-only, IMMUTABLE evidence record binding a run's exact Git tree state (base/ tree/commit SHA) to the Weftra governance context in force at a meaningful execution phase (authorized_base, implementation_complete, post_review_fix, verified_candidate, merge_result_verified) — actor role, engine/model/profile, spec identity+hash, a policy-snapshot pointer, skills/knowledge pinned by content_hash at creation, a capability-envelope hash, and a verification summary. Core invariant: a checkpoint RECORDS execution state and grants NO new authority. Two phases are MANDATORY on the standard implement flow (authorized_base, implementation_complete); post-fix/ verified/merge are recorded where their seam is already reached. A dirty/uncommitted tree honestly records tree_identity_captured=0 with a reason rather than a fabricated SHA. v1 is record + read + compare only — no restore/rollback (deferred). Read/compare API is tenant-scoped, enforced inside the query (feature -> project -> tenant), never a post-filter. The dashboard feature-detail renders a checkpoint timeline plus a two-checkpoint compare view (changed files + governance delta); uncaptured tree identity always renders as "uncaptured", never a confirmed SHA. Checkpoint recording is best-effort and non-authoritative — a record failure is logged and never fails an otherwise-good run or blocks a gate.
Public surfaces:
GET /api/features/:id/checkpointsGET /api/features/:id/checkpoints/:checkpointIdGET /api/features/:id/checkpoints/:a/compare/:bpublic/index.html#feature-detail:checkpoints
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.md
Governed Delegation Provenance
Status: 🟢 SHIPPED Audience: User
Governed delegation (spec 361, RM-129, promoted from draft 312 Part A): a structured, OPTIONAL delegation request — objective, scope (paths/mode/spec_text/max_result_size, always advisory this release — a helper gets no filesystem access to any real repo), output_contract (analysis|patch|verification; only analysis runs end-to-end this release, patch/verification are declared and recorded but resolve to analysis-equivalent handling), and a wall-clock max_ms budget — layered over spec 248's existing declared helper roles, plus durable parent-child provenance for every delegation attempt. Core invariant unchanged from spec 248: the parent chooses WHAT it needs done and names an allowed role; Weftra alone resolves HOW that role executes (engine/model/tools/credential/network/permission policy) through the exact same functions the parent's own flow used — none of those are readable from the delegation call, so a hostile request supplying them is simply never consulted. Each attempt appends an ADDITIVE helper_delegation run event (no database migration; a deterministic synthetic child_run_id, never a new row) carrying the resolved role, engine, model, objective_hash + a bounded preview, scope + a HONESTLY recorded scope_enforcement (advisory, never claimed as a boundary), a capability_snapshot_hash, applied/considered skills, the token+wall-clock budget, and an explicit outcome (one of nine values, including timeout/scope_violation/cancelled) — never a silent empty success. An optional second tool lets the parent report accepted/ignored/superseded disposition for a completed delegation's output; unreported reads as unknown. The committed feature provenance artifact gains a human-readable "Helper Delegations" section for author-context delegations. Depth-1 and the per-run invocation cap are unchanged.
Public surfaces:
invoke_helper_role: optional objective/scope/output_contract/max_ms fields (MCP tool)report_helper_disposition (MCP tool)POST /api/internal/helper-roles/disposition (run-scoped ephemeral bearer token only)run-event types: helper_delegation, helper_delegation_control, helper_delegation_disposition, helper_delegation_disposition_control.fa/provenance/<id>.md "Helper Delegations" section
Docs:
docs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.md
Learning Plane Auto Fire Knowledge Candidates
Status: 🟢 SHIPPED Audience: User
Learning Plane self-learning inc-1 (spec 364, RM-132): auto-fires the existing conservative knowledge-candidate source (spec 351's ReviewerFindingRecurrenceCandidateSource) from applyPrResolution the moment a feature reaches merged — best-effort and non-blocking (a generation failure is caught/logged and never reverts the already- committed merge transition), scoped to only that feature's own project, and idempotent via the existing alreadyCovered source_refs guard so repeat merges over the same evidence never duplicate a candidate. Auto-proposed candidates are stamped with the stable system actor fa:candidate-generation so an approver can tell them apart from a human/project-key submission. Core invariant unchanged: approval stays strictly human — this path only ever writes status='candidate' rows, never approved. Candidate text was rewritten to be PM-readable: summary/body now lead with plain-language what/why/what-changes-if-approved instead of a bare finding count, with the raw claims and source_refs kept present but demoted to a secondary section. Deferred: the analogous skill-candidate source (RM-132 inc-2) and the candidate-approval UI (RM-133) — this increment's surface is the existing GET /api/projects/:id/knowledge?status=candidate API. Spec 383 (RM-151 inc-1) replaced the per-FILE grouping with per-CLASS clustering (src/services/finding-class-clustering.ts's clusterFindingsByClass, a deterministic Jaccard similarity over the finding's own claim text at threshold 0.5) so ONE recurring pattern across several files yields ONE candidate citing every file, instead of one candidate per file it happened to surface in — the shape that produced 89 near-duplicate candidates from a single lesson on 2026-09-02. The same spec added recurrence_count and ?sort=recurrence to this route (no schema change) so the queue can be triaged by how often a pattern recurs, not just by recency. Weftra's own what/why/what-changes paragraphs quote no repository text; the cluster's seed claim appears in the SUMMARY, labelled there as a verbatim quotation and rendered through toQuotedLine, which flattens control characters and rewrites the double quote and backtick so a claim cannot close Weftra's quotation and continue in Weftra's voice. Spec 443 inc-56 (RM-304 part 1) corrected the recurrence signal this source relies on: the class-match predicate is now the 0.5 Jaccard ratio ANDed with an absolute MIN_SHARED_CLASS_TOKENS=3 floor (isSameClass, shared with the effectiveness grader so the two cannot drift), recurrence now gates on cluster.distinctFeatures rather than raw finding-instance count (so repeated review rounds of ONE feature no longer read as a repository-wide pattern), only findings from features that have reached merged are considered (an in-flight feature's finding may still be fixed or reverted), and the "why it's being proposed" sentence reports the feature count alongside the file count.
Public surfaces:
GET /api/projects/:id/knowledge?status=candidateGET /api/projects/:id/knowledge?sort=recurrence
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Learning Plane Auto Fire Skill Candidates
Status: 🟢 SHIPPED Audience: User
Learning Plane self-learning inc-2 (spec 367, RM-132): the analogous SKILL twin of learning-plane-auto-fire-knowledge-candidates. A new LearnedSkillCandidateSource reads the same recurring security-review-finding-by-file evidence (listSecurityVerdictInstancesForProject, RECURRENCE_THRESHOLD=2) and proposes one conservative source_type='learned' skill candidate per recurring file, with deterministic templated text (no LLM). generateAndPersistSkillCandidates fires as a sibling best-effort try/catch right next to the spec-364 knowledge call in applyPrResolution, at the same post-merge seam — independent of it, so a throw in either never reverts the merge transition or blocks the other. Idempotent via the same alreadyCovered-equivalent shared-source_ref guard. Every row is status='candidate', never auto-approved — approval stays the existing POST /api/projects/:id/skills/:sid/approve, no new approval path. Candidate text leads with plain-language what/why/what-changes-if-approved; repository-supplied text (file path, finding claims) is quoted/clamped/control-char-stripped and confined to a delimited evidence section. No new UX surface: renders in the existing skills candidate queue (public/skills.html + GET /api/projects/:id/skills?status=candidate). Spec 383 (RM-151 inc-1) replaced the per-FILE grouping with the same per-CLASS clustering the knowledge twin uses (src/services/finding-class-clustering.ts's clusterFindingsByClass, threshold 0.5), so this source's sourceRefs for a cluster are byte-identical to the knowledge candidate's for the same evidence — the key public/knowledge.html looks a pair up by, on top of the Weftra-provenance check (source_type + fa:candidate-generation actor) it requires before rendering two rows as one card. Weftra's own leading paragraphs quote no repository text; the seed claim appears only in the skill NAME, labelled as a verbatim quotation and passed through toQuotedLine, which also neutralizes the quote characters that would let it close Weftra's quotation. The same spec added recurrence_count and ?sort=recurrence to this route (no schema change). Spec 443 inc-56 (RM-304 part 1) corrected the recurrence signal this source relies on, the same correction as its knowledge twin: the class-match predicate is now the 0.5 Jaccard ratio ANDed with an absolute MIN_SHARED_CLASS_TOKENS=3 floor (isSameClass, shared with the effectiveness grader), recurrence gates on cluster.distinctFeatures rather than raw finding-instance count, only findings from features that have reached merged are considered, and the "why it's being proposed" sentence reports the feature count alongside the file count.
Public surfaces:
GET /api/projects/:id/skills?status=candidateGET /api/projects/:id/skills?sort=recurrence
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.md
Learning Plane Retrospective Evidence And Grades
Status: 🟢 SHIPPED Audience: Admin
Learning Plane — hourly retrospective, evidence half (spec 379 inc-1, RM-155): grades whether the knowledge/skills applied to each run actually fit and worked, by ARITHMETIC alone, no model call. The retriever now records the truth about truncation — knowledge.applied_to_run/skill.applied_to_run events gain a dropped[] list ({id, reason: 'bytes'|'count', fit}) for every item that matched but was capped out, and every included item gains its own fit (path_fit|broad|misfit), computed at retrieval time from the same applicability/changed-paths context the retriever already reads. A closed, optional targets field ({test_files, finding_classes}, capped at 20 entries each) can be declared on a knowledge or skill item to name what it is trying to improve — never a retrieval selector, carried untouched through the candidate-to-approved path, and back-filled for a pinned-test file name found in the item's own body when the caller declared none. A per-project watermarked hourly tick (FA_LEARNING_RETRO_INTERVAL_MS, default 1h, 0 disables; FA_LEARNING_RETRO_MAX_FEATURES per pass, default 50) grades every newly-terminal feature's run evidence (applied/dropped items, touched files, per-attempt test-gate results, per-round security findings, fixer rounds, cost, wall-clock, retries, human intervention) into fit/truncation/effect (applied_but_failed|flipped|no_signal) per item plus a per-window recurrence table (finding classes on 2+ distinct features) — writing one learning_retrospectives row per pass (model: 'off'), a learning.retrospective actor event, and a per-feature learning.run_graded run event, all bounded and redacted (FA_LEARNING_RETRO_MAX_EVIDENCE_BYTES, default 48 KB). Two admin/approver-only read routes return aggregates and grades only — no finding body text, no human_action free text, no operator email. The older per-file recurrence miner (learning-plane-auto-fire-knowledge-candidates/-skill-candidates above) is a separate, additional, opt-in producer that defaults OFF (FA_RECURRENCE_MINER=on to keep it running). Spec 443 inc-45 (RM-299 part 1) closed the gap that OFF-by-default left: this hourly pass now MINTS what it grades — as the last step of every pass that lands, it calls the same project-scoped mint seam (src/services/learning-candidate-mint.ts) the per-file miner calls, proposing knowledge/skill candidates (status: 'candidate', actor fa:candidate-generation) from its own per-class recurrence view. An idle pass mints nothing and writes no event. Every mint attempt, from either producer, leaves exactly one run event — learning.candidates_generated (knowledge, skills, trigger, retrospective_id?) or learning.candidates_skipped with a reason of miner_off (per-file miner only), no_recurrence, already_covered, or generation_error — so "switched off" and "nothing to learn" are distinguishable on the ledger. No model call anywhere in this pass (model: 'off' stays); no auto-approval (every minted row stays status: 'candidate'); the model-authored proposal half and the Learning Plane pages' own defaults/error-handling are later increments. Spec 443 inc-56 (RM-304 part 1): findingClassMatchesTargets (an item's declared targets.finding_classes vs. a run's actual security findings, for the effect grade) no longer re-implements the class-match ratio — it now delegates to isSameClass (src/services/finding-class-clustering.ts), the SAME predicate the candidate miners use, so the two decisions cannot drift.
Public surfaces:
GET /api/projects/:id/learning/retrospectivesGET /api/projects/:id/learning/retrospectives/:rid
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Governed Work Escalation Record
Status: 🟢 SHIPPED Audience: User
Governed Work Escalation, Increment 1 (spec 366, RM-131, promoted from draft 386 (formerly 329)): a durable, tenant-scoped RECORD + OBSERVATION surface so an agent (or an operator, or an API client on its behalf) can report an out-of-scope need discovered during a feature — WITHOUT gaining any authority to act on it. Core law: discovery is not authority — this increment is inert by construction, with no code path from an escalation to work activation, queuing, or any change to the reporting feature's own lifecycle/status. An escalation carries a bounded classification (blocking_dependency, missing_capability, defect, security, architecture, tech_debt, optimization, documentation, opportunity), a blocking flag, a problem description, an optional caller-suggested (non-binding) candidate_target, and optional evidence entries. Every origin field (tenant/project/originating run/actor/engine/model/transport) is resolved SERVER-SIDE from the feature and its live run — never from the request body. A fixed denylist of authority-shaped fields (skip_approval, force_activate, force_merge, execute_as_admin, bypass_policy, agent_to_execute, credential, priority) is rejected with 400 rather than silently dropped, and problem/evidence description/reference are passed through redactSecrets before the row becomes durable (the floor, not caller diligence — covering Weftra-minted key shapes, GitHub/GitLab/Anthropic tokens, Bearer values and this instance's configured secrets, not every vendor format). The record also names WHO reported it and, once triaged, who dispositioned it, with the role scaffold's named/service/role confidence — the run/engine/model fields describe what was in flight, never authorship. A project key may create/read escalations only for its OWN project's features; an approver only for its LINKED projects; an admin across the instance. Admin/approver-only triage (acknowledged/duplicate/rejected/invalid) is the sole mutation available in this increment — activation/queuing statuses are not settable here. Reads return at most the newest 200 records, and a project may hold at most 500 untriaged escalations (429 beyond that). The feature-detail dashboard renders an Escalations panel (read-only observation); full fleet/demand-count/unlock-fanout views are deferred to increment 6.
Public surfaces:
POST /api/features/:id/escalationsGET /api/features/:id/escalationsGET /api/escalationsPATCH /api/escalations/:idpublic/index.html#feature-detail:escalations
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.md
Work Dependency Graph
Status: 🟢 SHIPPED Audience: User
Work Dependency Graph, Increment 1 (spec 411, RM-170, draft 386 §11/§19/§21 Increment 4 first half): a durable, tenant-scoped work_relationships edge table recording that one work item (a feature or a governed-work-escalation-record) relates to another, with fail-closed cycle detection over the dependency-shaped edges. THIS INCREMENT IS INERT WITH RESPECT TO EXECUTION — no feature status, scheduler behaviour, or queue-selection change exists anywhere in it, and no file under src/services/ is touched. A relationship is one of six bounded kinds: blocked_by, depends_on, unlocks (the dependency edge set, direction-normalized so the same statement made two ways cannot evade cycle detection), and related_to/duplicates/discovered_during (excluded from the walk, may legitimately be mutual). The creating route's from endpoint is always the path feature — never body-supplied; the body carries only relationship/to_type/to_id, validated by a strict allowlist with the same authority-shaped denylist the escalation write surface uses. Every edge is single-tenant, always — the model rejects an edge whose two endpoints carry different tenant_id, including for an admin caller, because increment 2 turns blocked_by into a wait and a cross-tenant edge would let one tenant's merge schedule another tenant's run. A dependency-set create walks the existing graph forward from the proposed target, bounded by MAX_DEPENDENCY_WALK_NODES; a cycle, or a walk too large to verify within the bound, rejects the write (409) rather than admitting it unverified. No free-text column exists in this increment, so there is nothing to redact. A project key may create/read/delete relationships only for its OWN project's features; an approver only for its LINKED projects; an admin across the instance — scoped independently for BOTH endpoints of every edge, with an out-of-scope id returning the SAME 404 as a nonexistent one. Reads return at most the newest 200 edges, and a feature may hold at most 200 relationships (429 beyond that); an identical create is idempotent (201 then 200, one row). The feature-detail dashboard renders a read-only Dependencies panel; the blocked -> re-queued execution lifecycle is deferred to increment 2.
Public surfaces:
POST /api/features/:id/relationshipsGET /api/features/:id/relationshipsDELETE /api/features/:id/relationships/:relationshipIdpublic/index.html#feature-detail:dependencies
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Work Dependency Graph Blocked Lifecycle
Status: 🟢 SHIPPED Audience: User
Work Dependency Graph, Increment 2 (spec 411, RM-170, draft 386 §11/§19/§21 Increment 4 second half): the blocked -> re-queued lifecycle increment 1 deferred. A queued feature with one or more unsatisfied dependencies (the SAME §5 direction normalization increment 1 defined for cycle detection — blocked_by/depends_on name the dependency as the to node, unlocks reverses it) moves to blocked instead of running and failing on the missing prerequisite; once every dependency is satisfied it is re-queued as a FRESH run — never a resume: no workspace reuse, retry_count untouched, resume_flow untouched. Satisfaction is a closed three-way classification: a feature dependency is satisfied only at merged (implemented is deliberately NOT satisfied), unsatisfiable at wont_merge/cancelled; a work-escalation dependency is satisfied at triage acknowledged/duplicate, unsatisfiable at rejected/invalid; a dangling id is unsatisfiable. An unsatisfiable dependency never auto-unblocks — a human wont_merge/ cancelled/rejected/invalid decision stays a decision until a human deletes the blocking edge or cancels the dependent feature (the two escape hatches; no new route). The gate that makes "a blocked feature never runs" true is the DISPATCH QUERY itself (UNSATISFIED_DEPENDENCY_SQL, embedded in getQueuedFeatures), not sweep ordering — a park sweep and a wake sweep (one new agent-engine tick step, slot-independent, skipped while draining) keep the status in sync with what the query already enforces, ledgering work_dependency_blocked/work_dependency_unblocked/work_dependency_unsatisfiable (the last at most once per feature/dependency pair) fail-soft. blocked raises fleet- attention action unblock_dependency at the same SLA as awaiting_approval, is cancellable, and the dashboard's Dependencies panel marks each edge satisfied/waiting/ unsatisfiable and states the two ways out while the feature is blocked.
Public surfaces:
GET /api/features/admin/attention#unblock_dependencypublic/index.html#feature-detail:dependenciespublic/fleet.html#action-queue:unblock_dependency
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Capability Token Substrate
Status: 🟢 SHIPPED Audience: Operator
Spec 362 (RM-126) — Weftra's first signed, scoped, revocable, offline-verifiable credential: a Biscuit (Ed25519) capability token wrapped behind src/services/capability-tokens/, an AuthZEN-shaped decide() seam (v1 = direct opaque scope match, documented Cedar swap-point), and a requireCapability() middleware guard that accepts either a scoped token or a legacy fa_/admin credential mapped through the same role table. The root signing key is generated lazily on first mint, stored only as instance-secrets AES-256-GCM ciphertext, and never leaves the host process. Tokens are sealed leaves (no attenuation in v1), bearer (no PoP enforcement — the cnf fact is recorded for v2), TTL- capped by an operator ceiling, and instantly revocable — fail-closed in every direction: an unreadable store, an ABSENT record, or a record that disagrees with the token's signed facts all refuse (security round 1). Self-service mint/list/revoke over HTTP is spec 363 inc-7's identity-self-service-scoped-tokens entry below; root-key RETIRE remains tsx-only, with no HTTP route or UI.
Public surfaces:
none declared
Docs:
docs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/design/agent-auth/02-capability-tokens.mddocs/design/fa-roadmap.md
Federated Login Github Gitlab
Status: 🟢 SHIPPED Audience: User
Spec 363 (Weftra Identity & Access, RM-127), increment 1: let a human sign in to the Weftra dashboard with a GitHub or GitLab account, exactly as with Google today — OAuth2 authorization-code + a signed, provider-bound state cookie compared in every callback (src/routes/auth.ts), invite-only (the verified provider email must match a pre-provisioned Weftra user, no auto-signup), session cookie issued on success, and an identity row keyed on the provider's stable NUMERIC user id as provider_subject — never the mutable login/username. GitLab maps an address only when GET /user reports confirmed_at; the account state field is not accepted as a stand-in for email confirmation (src/services/gitlab-oauth.ts). PKCE (S256) is sent on both flows and enforced by GitLab; GitHub's OAuth App endpoint ignores it, so on that path the secret and the signed state cookie are what stand behind the handshake. Login only: no link mode, no VCS-handle recording. The access token and client secret stay locals inside exchange*Code → fetch*Profile — written to no store, redirect, or log — and GET /auth/status exposes only boolean flags, never a base URL or secret.
Public surfaces:
GET /auth/githubGET /auth/github/callbackGET /auth/gitlabGET /auth/gitlab/callbackGET /auth/status#githubLoginEnabledGET /auth/status#gitlabLoginEnabled
Docs:
docs/SETUP.mddocs/USER_GUIDE.mddocs/AGENT_FACTORY_QUICKSTART.md
Identity Link Provider To Account
Status: 🟢 SHIPPED Audience: User
Spec 363 (Weftra Identity & Access, RM-127), increment 2: let a logged-in user LINK a Google/GitHub/GitLab identity to the account they are logged into, list their linked identities, and unlink one — binding to the AUTHENTICATED SESSION user only, never to an email lookup (which is how every login callback attaches an identity today). Link mode reuses the existing /auth/<provider>/callback routes (one redirect URI per provider app) and branches on a signed fa_oauth_state cookie field (mode:'link', linkUserId) minted only by the authenticated POST /api/users/me/identities/link/:provider — never derivable or editable client-side. The callback's link branch requires a live session whose req.user.id equals the cookie's linkUserId, refuses to silently re-bind an identity already bound to another user, is idempotent for the owner, and issues no session. Both a link and an unlink are recorded on the tamper-evident actor-events ledger (identity.link / identity.unlink), in the same transaction as the row change, with no email in the metadata. DELETE /api/users/me/identities/:identityId is scoped by a (id, user_id) SQL predicate (self-only, no enumeration) and refuses to remove the local (password) identity. OIDC is not linkable in this increment. The dashboard surface is a "Linked sign-ins" Account panel opened from the user badge (openAccountModal → linkProvider / unlinkIdentity / closeModal in public/index.html): it lists the rows, offers one "Link <Provider>" button per enabled-but-unlinked provider, and shows the session hint when the dashboard is driven by a Bearer key. Carries in the #606 round-3 advisory: httpsGet/httpsPost in all three provider modules carry a 15s WALL-CLOCK deadline over the whole exchange (connect, headers and body read), not a socket-idle timeout, so a provider that trickles bytes cannot hold a login or link callback open; the rejection message is fixed and byte-free. A link-mode state cookie expires in 5 minutes rather than the login window's 10 (OAUTH_LINK_STATE_DURATION_S) — on a provider that ignores PKCE (a GitHub OAuth App) state is the only value binding an in-flight link to the code that comes back, and it is not single-use, so that expiry is the only bound on redemption; docs/SETUP.md §"How it works" documents that residual.
Public surfaces:
POST /api/users/me/identities/link/:providerGET /api/users/me/identitiesDELETE /api/users/me/identities/:identityIdpublic/index.html#on:openAccountModalpublic/index.html#on:linkProviderpublic/index.html#on:unlinkIdentitypublic/index.html#on:closeModal
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/SETUP.md
Identity Oauth Proven Vcs Handles
Status: 🟢 SHIPPED Audience: User
Spec 363 (Weftra Identity & Access, RM-127), increment 3: a VCS handle in principal_vcs_identities was admin-asserted free text — the ONLY authorization check on the @<bot> merge path (getProjectApproverByVcsHandle), the mention dispatch, and merger attribution. Inc-3 records the provider's own authoritative account name (profile.login) as an oauth_proven handle, with provenance (proven_at, proven_via = the identities.id row that proved it), at exactly the three points inc-1/inc-2 already hand Weftra a verified profile bound to a known user (handleFederatedCallback's fast path and invite-only path, and completeIdentityLink's link branch — GitHub/GitLab only; Google records no handle) — and nowhere else. Proving UPGRADES a row an admin already declared and NEVER creates one: proveVcsHandle writes nothing when the user holds no row for that (forge, host, handle) and records prove_refused (reason: no_declaration) instead, so completing an OAuth sign-in can never mint a principal their own entry in the table getProjectApproverByVcsHandle treats as the only authorization check on the merge path (security review round 1, finding 1; parent spec §4.3 "admins pre-stage, users confer authority"). proof (pending | oauth_proven) is written ONLY in src/models/principal-vcs-identities.ts (proveVcsHandle, demoteProofsForIdentity); the admin declare route (POST /api/users/:id/vcs-identities) created pending at inc-3 (inc-4, below, changes that to staged on a provable scope) and has no request field for it. Proof is EXCLUSIVE — a handle already oauth_proven for a different user refuses the second claim before any write, records principal_vcs_identity.prove_refused, and still lets the second user's own login/link complete normally (a refused proof is a by-product, never a login failure). A provider account has one login at a time, so re-proving the SAME identity under a changed login demotes the old handle back to pending (proof_revoked, handle_changed); unlinking the identity that proved a handle demotes every row it proved, in the SAME transaction as the unlink (DELETE /api/users/me/identities/:identityId, proof_revoked, identity_unlinked). The fail-closed resolvers (getUserByVcsIdentity, getUserByHandleForScope / findUsersByHandleForScope, one query each with proof in the projection) let a SINGLE proven candidate win over any number of pending/legacy candidates also matching the same handle — narrowing ambiguity toward the verified party, never resolving a handle that resolved to nobody before to someone unproven; two or more proven candidates (impossible under the exclusivity check, but not trusted from outside it) still fails closed to null. A proof outranks only rows that already existed when it was taken: proof is a one-time snapshot of a mutable provider login and nothing revalidates it, so a matching row created for another candidate AFTER the proof (or a timestamp that cannot be read) marks the set CONTESTED and resolution falls back to the pre-inc-3 fail-closed answer rather than letting a stale proof outrank a freshly declared owner (security review round 1, finding 2). The host a proof is filed under comes from the same extractGitHost parser every project scope is derived with, so a self-hosted forge on a non-default port is not recorded under a host no resolver can match (finding 4). This does NOT yet refuse an unproven handle anywhere — every handle that authorized a merge/mention/attribution before this shipped still does (that gate is inc-6, REQUIRE_PROVEN_VCS_HANDLE, default OFF, LAST). Self-service surface: "proving IS linking" — POST /api/users/me/vcs-identities/prove/:provider (github|gitlab only) is guard-for-guard identical to the inc-2 link-initiate route (shared initiateProviderLink, same mode:'link' state cookie, same live-session requirement), and GET /api/users/me/vcs-identities lists the caller's own rows with the three proof fields. Every privileged act is on the tamper-evident actor-events ledger (principal_vcs_identity.prove / .prove_refused / .proof_revoked) — login-mode actor is the user the callback just resolved (resolvedActorForUser, never __unauthenticated__), link-mode actor is resolveRequestActor(req). Carries in from #607: link-mode OAuth state becomes SINGLE-USE (oauth_consumed_states, a hashed state-cookie value consumed before the code exchange in the link branch of all three callbacks — Google included, though Google records no handle); a replay is refused with the fixed state_reused message. Login-mode state is unchanged (a documented follow-up).
Public surfaces:
POST /api/users/me/vcs-identities/prove/:providerGET /api/users/me/vcs-identitiespublic/index.html#on:proveVcsHandlepublic/index.html#on:renderVcsHandlesSection
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/SETUP.md
Identity Admin Prestage User Proves
Status: 🟢 SHIPPED Audience: User
Spec 363 (Weftra Identity & Access, RM-127), increment 4: after inc-3, an admin-declared pending row still authorized @<bot> merge/mention dispatch/merger attribution the moment it was written — the parent spec's "admins pre-stage, users confer authority" was true for the proof bit, not for authorization. Inc-4 adds a third proof state, staged (VcsHandleProof = 'pending' | 'staged' | 'oauth_proven', no schema change — proof is an unconstrained TEXT column, src/models/database.ts:1029): every NEW admin declaration (linkPrincipalVcsIdentity) on a scope the named user could actually prove now inserts staged explicitly, and the resolver (resolveProvenWins, src/models/principal-vcs-identities.ts) never resolves TO a staged row (never the single-candidate fallback; a staged rival never contests a proof) but counts it as a CLAIM: against an unproven holder a staged row makes the handle ambiguous → nobody (fail-closed), so a self-service unlink's demotion can never hand a handle to a surviving unproven rival. pending survives on every row that predates this increment (inc-3's declared rows) and is STILL written by the legacy users.vcs_login mirror on every vcs_login write (an admin PATCH included; that legacy/* row authorizes on every scope until an operator arms inc-6, REQUIRE_PROVEN_VCS_HANDLE, not yet built) — no DECLARATION path writes it any more: a new declaration on a scope Weftra could run no OAuth round-trip against (a bitbucket handle; a github/gitlab host the CONFIGURED provider does not serve, or a provider that is not configured at all) is REFUSED with 409 and recorded (principal_vcs_identity.add_refused), never written as an authorizing row and never stranded as an unprovable staged one (isProvableVcsScope, src/services/vcs-handle-proof.ts, the same predicate the challenge mint and redeem routes gate on) — on a Bitbucket-hosted project no NEW handle can be declared until a proof route exists, the stated cost of the control. Excluding staged also takes the handle-changed-hands case out of inc-3's CONTEST check for every new declaration, so the .add event records resolves_to_user_id when the staged handle already resolves to a different principal: staging a rival unseats nobody, DELETE does. Demotion (a handle-change or an unlink) now lands on staged, not pending — an unevidenced handle must re-earn authority through proof rather than falling back to a state that still authorizes (ProveVcsHandleResult.upgradedFrom: 'pending' | 'staged' | null). proveVcsHandle treats a staged row as an existing declaration exactly like pending — proving upgrades it in place; recordProvenVcsHandle remains the only writer of oauth_proven. Adds the prove-it challenge: an admin-issued, stateless, HMAC-signed (same signValue/verifyValue primitive as the OAuth state cookie), single-use-at- redemption, user-bound, 7-day link (VCS_PROVE_CHALLENGE_TTL_S, a module constant, no new env var) that runs the inc-3 prove flow against ONE specific staged row. POST /api/users/:id/vcs-identities/:identityId/challenge (admin) mints it — 404 for another user's row, 409 for anything not staged or not provable — and records challenge_issued (challenge_hash = sha256 of the token, never the token). POST /api/users/me/ vcs-identities/challenges/redeem ({token}, live session + CSRF, same guard shape as .../prove/:provider) verifies the token (one fixed 400 for any invalid/expired/ wrong-kind token), checks payload.userId === req.user.id (403 otherwise) BEFORE consuming it, consumes it single-use via consumeSingleUseToken (src/models/oauth-consumed-states.ts — a sibling of consumeOAuthState, same table, domain-separated by hashing kind + ':' + token so a challenge token and an OAuth state cookie can never cross-consume; 409 on replay) in the SAME transaction as the challenge_redeemed it records, and only after every check that can still refuse — row still staged and provable, provider configured, authorize URL buildable — has passed, so a 503 leaves the link unspent and the ledger asserting no redemption; then mints the SAME inc-3 prove-flow mode:'link' state cookie with an added challenge: {identityId, forge, host, handle} field. completeIdentityLink (src/routes/auth.ts) evaluates it AFTER the ordinary inc-3 proof already ran, comparing the ROW's resulting state (never re-comparing request input): challenge_completed and the ordinary ?linked= redirect are recorded only when THIS round-trip is the evidence — the proof call targeted that row, the row reads oauth_proven, and its proven_via is the identity this callback verified; otherwise the staged row is left untouched and it records challenge_refused (reason: 'handle_mismatch', expected_handle/presented_handle, ledger-only), and redirects with a new fixed IDENTITY_LINK_ERROR_MESSAGES.challenge_mismatch message (added to both the server set and the client allowlist, which a test asserts stay identical) — the identity link itself still completes per inc-2/inc-3 semantics; only the challenge is refused. A challenge can only ADD a comparison and two ledger rows on top of the unchanged inc-3 path — it cannot widen what a plain prove does. Dashboard: admin Users view gains the "VCS handles" surface inc-3 deferred (a "Stage a handle" form, a proof badge per row, and an "Issue prove-it link" button on staged rows that shows the link once, from the response — never re-displayed); the Account panel's existing "VCS handles" section labels a staged row staged · awaiting your proof; the dashboard strips a ?vcs_challenge= deep link from the address bar immediately (history.replaceState) and either redeems it right away (session already live) or holds it in sessionStorage under one key until sign-in completes. Tenant reachability: none — both new routes 401 a project key.
Public surfaces:
POST /api/users/:id/vcs-identities/:identityId/challengePOST /api/users/me/vcs-identities/challenges/redeempublic/index.html#on:manageUserVcsHandlespublic/index.html#on:issueVcsChallengepublic/index.html#on:redeemStashedVcsChallenge
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.md
Identity Admin Password Invite
Status: 🟢 SHIPPED Audience: Admin
Spec 443 inc-53 (RM-286) — an admin can INVITE a user to set a password, and can SEE whether one is set. Before this, GET /api/users* shipped the raw password_hash column, and starting the reset flow for someone else required SMTP: with no SMTP configured, POST /auth/local/reset-request silently console.warns and the admin had no supported fallback. redactUser (src/utils/redaction.ts) now derives has_password: boolean and OMITS password_hash entirely (by absence, not null); every user read also carries identity_providers: string[] (sorted/deduped, [] when none), resolved for a whole page in ONE batched query (listIdentityProvidersByUserIds, src/models/identities.ts, the getProjectIdsByUserIds pattern). POST /api/users/:id/password-invite (requireAdminAuth — the same guard as its nearest sibling in blast radius, regenerate-key) mints exactly one token via the EXISTING generateResetToken/sendResetEmail (src/services/local-auth.ts, no change to the token scheme, hashing, or the login rate limiter): SMTP configured (isSmtpConfigured()) delivers it by email and the response never carries a link; SMTP not configured, or a configured send that actually fails (sendResetEmail now returns {sent, reason} instead of void), returns a one-time set_password_url to the admin. Refuses 404 unknown user, 503 when isSessionConfigured() is false (never mints a token Weftra cannot verify), and 401/403 for a project key, an approver key, and a submitter key — an approver minting a set-password link for an admin would be privilege escalation. The act is recorded on the actor-events ledger (user.password_reset_issued, {user_id, delivery} only — no token, URL, or email body) fail-closed: a ledger-insert failure responds 500 and issues no link. public/login.html gains a static, unconditional hint beside "Set / forgot password?" pointing at the admin path — never conditioned on the submitted address, so the existing no-user-enumeration property (POST /auth/local/login returns the identical 401 for "no password", "wrong password", and "no such account"; reset-request always returns 200) is unchanged. The dashboard Users table gains Password (set/not set) and Identities columns and a per-row "Invite / reset password" action; a delivery: "link" response shows the one-time link once, copyable, built with DOM APIs only (never an innerHTML attribute, never the page URL), cleared from the DOM when dismissed.
Public surfaces:
POST /api/users/:id/password-inviteGET /api/userspublic/index.html#on:invitePasswordReset
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Identity One Time Email Sign In Link
Status: 🟢 SHIPPED Audience: User
Spec 480 inc-2 (RM-363 a): a person signs in with a one-time link emailed to their address, instead of a password. POST /auth/local/link-request {email} mints a 32-byte base64url token via mintSignInLink (src/models/sign-in-links.ts, a self-contained model that creates its own sign_in_links table lazily, the same pattern local-secret-material.ts uses — database.ts is not touched), storing only a sha256 hash with a 15-minute expiry, and emails it with sendSignInLinkEmail (shares transport construction with sendResetEmail). The response is 200 with an identical body for a known and an unknown email (no account enumeration), and {ok: false, email_configured: false} before any lookup when SMTP is not configured. Requests are rate-limited at 3 per submitted email and 10 per source address per fixed 15-minute window (not a rolling one), counted whether or not the account exists. POST /auth/local/link-redeem {token} consumes the token with one atomic UPDATE (consumeSignInLink, success only when changes === 1, so two concurrent redemptions can never both succeed), refusing with one uniform 401 for an unknown, expired, already-used, or deleted-user token, rate-limited per source address on failed attempts only. POST-only by design — a GET would let a mail scanner or link previewer spend the single use before the person clicks. A successful redemption records user.sign_in_link.redeem on the actor-events ledger ({method: 'email_link'} only) in the SAME transaction as the consume, so a ledger-write failure rolls the consume back and the token stays redeemable; it then upserts an email_link identity and issues the exact same session cookie every other sign-in method issues. The login page (public/login.html) gains a request panel (disabled with the existing no-email notice when emailDeliveryConfigured is false, via applyEmailDeliveryStatus) and a confirm panel for ?signin-token=, which strips the token from the address bar with history.replaceState before any network call and redeems only when the person clicks Sign In — the page never signs in on load. Reset tokens and sign-in tokens are not interchangeable at either route; neither touches password_hash, role, or API key. No new account, project link, or environment variable is ever created by either route.
Public surfaces:
POST /auth/local/link-requestPOST /auth/local/link-redeempublic/login.html#on:doLinkRequestpublic/login.html#on:doLinkRedeempublic/login.html#link-request-btnpublic/login.html#link-confirm-btn
Docs:
docs/SETUP.mddocs/USER_GUIDE.mddocs/OPERATIONS.md
Identity Federated Sign In By Subject
Status: 🟢 SHIPPED Audience: User
Spec 502 (RM-403): federated sign-in (Google, SSO/OIDC, GitHub, GitLab) resolves an account by the provider's stable account id, and an email match may only make a FIRST link, never to an administrator. One resolver (resolveFederatedSignIn, src/services/federated-sign-in.ts) is used by all four login-mode callbacks: a row for (identities.provider key, subject) wins and its user signs in even if the provider email changed (an owner that no longer exists is refused identity_owner_missing and the email is never consulted); with no row, the email may link only when the provider verified it, the account has no identity at that provider yet (provider_already_linked otherwise) and the account does not hold the admin role (admin_requires_explicit_link otherwise; an administrator links a provider from the Account panel while signed in, the existing link flow, unchanged). upsertIdentity now throws IdentityOwnershipError before any write when the row belongs to a different user, so no caller can re-bind an identity. A first link writes the identity row and an identity.link actor event (via: first_sign_in, no email) in one transaction; the session still goes through issueSession (user.session_issued). Every refusal is recorded as identity.sign_in_refused (metadata exactly {provider, reason}, actor the unauthenticated principal) and redirects to the login page with a fixed message naming the fix. Invite-only refusal, OIDC_ALLOW_SIGNUP provisioning, link mode, local password, email-link and API-key sign-in are unchanged. Upgrade note: an existing administrator who never signed in with a provider must link it from the Account panel (SSO has no Account-panel Link control).
Public surfaces:
GET /auth/google/callbackGET /auth/oidc/callbackGET /auth/github/callbackGET /auth/gitlab/callback
Docs:
docs/SETUP.mddocs/USER_GUIDE.md
Identity Multi Role Named Submission
Status: 🟢 SHIPPED Audience: User
Spec 363 (Weftra Identity & Access, RM-127), increment 5: role becomes a SET (UserRole = 'admin' | 'approver' | 'submitter', users.roles JSON array, sorted/deduped/non-empty), and users.role stays the DERIVED PRIMARY (admin > approver > submitter) — written ONLY by models/users.ts (deriveLegacyRole), so every existing user.role === 'admin' | 'approver' read keeps meaning exactly what it always did. The users.role CHECK constraint is widened via a table-rebuild migration (migrateUsersRoleConstraint, a faithful sibling of migrateAutonomyModeConstraint) — string-edits the live sqlite_master DDL, refuses to guess on unrecognized DDL, PRAGMA foreign_keys = OFF around the rebuild, foreign_key_check before commit — because four child tables cascade on users(id) (project_approvers, identities, principal_vcs_identities, project_roles). POST/PATCH /api/users accept roles and/or the legacy role shorthand (disagreeing values 400); redactUser adds roles; user.role_change fires on a SET change with from_roles/to_roles alongside the primary from/to. The spec-273 submitter project HAT becomes authorizing for EXACTLY ONE act: a new route, POST /api/features/user, guarded by a new requireSubmitterAuth (session or user API key holding submitter or admin; a project key or the shared ADMIN_API_KEY get 401; no dev-mode open-pass in any mode), lets a submitter file a feature on a project they hold the hat for (admins exempt) with THEIR OWN credential as the recorded submitter — submitter_email is that person, submitter_verified is always true, submitter_claimed_email is never written. This closes the KNOWN GAP the project-key path names: a human who submits with a tenant key is recorded as the project's service principal, so separation of duties (spec 229) could never bind them even when they also hold approver standing on the same project. POST /api/features itself is unchanged and stays the machine/tenant submission path, gap and all, by design. Body validation is shared with POST /api/features through one extracted helper (validateTenantSubmissionBody) — a submitter arms exactly what a project key can arm and the two paths cannot drift apart. requireAdminOrUserAuth gains an explicit admin-or-approver predicate so a submitter-only user is refused (403) on every route it fronts; requireUserAuth (self-service /me/*) admits a submitter unchanged. The capability-token substrate's role→capability mapping (LEGACY_ROLE_CAPABILITIES) and its project-scoped check both gain submitter. Self-service: GET /api/users/me adds roles and submitter_projects; GET /api/users/me/features additionally returns a submitter's own submissions, redacted like a project-key view. Dashboard: the Users view's role select becomes three checkboxes; the approver-projects modal becomes a per-project access list with Approver and Submitter toggles; the account panel shows roles and "Can submit on". Deferred to a later increment: the dashboard submit form and the fa CLI do not learn the user-key submission path. Spec 571 (RM-503): the dashboard's project pickers (Features, Users and Campaigns filters, the web-reading preview select and the Users → Access modal) search the server with a type-to-filter box (GET /api/projects?q=&limit=20) instead of reading one loaded page, so every project is reachable. The Access modal lists a user's linked projects with a search-to-add picker and reads their hats in one call, GET /api/users/:id/project-hats (admin only; project id, name and hats only).
Public surfaces:
POST /api/features/userPOST /api/usersPATCH /api/users/:idGET /api/users/meGET /api/users/me/featurespublic/index.html#on:renderUserspublic/index.html#on:manageApproverProjectsGET /api/users/:id/project-hats
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/design/roles-and-principals.mddocs/security/authz-surface.json
Identity Self Service Scoped Tokens
Status: 🟢 SHIPPED Audience: User
Spec 363 (Weftra Identity & Access, RM-127), increment 7: a logged-in user mints a short-lived, project-bound, capability-scoped Biscuit token (the spec 362 substrate) whose scope can never exceed what they already hold on that project, uses it on exactly two routes, and revokes it instantly. POST /api/users/me/tokens mints — grantable capabilities are the closed SELF_SERVICE_GRANTABLE_CAPABILITIES set (features:submit, features:approve; never admin:* or tokens:mint), each one checked at mint time against the SAME per-project link requireCapability demands at use (isApproverForProject / hasProjectHat), 403 naming any excess rather than silently narrowing; TTL is clamped by clampTtlSeconds, never a tenant-lengthenable value; the token value is returned exactly once. GET /api/users/me/tokens lists only the caller's own rows (no cnf_pub/root_key_id/token bytes); DELETE /api/users/me/tokens/:jti revokes one instantly and idempotently. All three are requireUserAuth-only — a project key, the shared ADMIN_API_KEY, and a token itself are all refused 401 (a token cannot mint tokens). Consumption is two NEW named, exported guards (src/middleware/scoped-token-guards.ts, mechanically detectable by tests/authz-surface.test.ts's function-name walk, unlike mounting requireCapability's anonymous closure directly): requireSubmitterAuthOrScopedToken replaces requireSubmitterAuth on POST /api/features/user; requireAdminOrUserAuthOrScopedToken replaces requireAdminOrUserAuth on POST /api/features/:id/approve. Each guard's legacy branch (no Bearer, or a Bearer resolving as a user/project/admin key) is byte-identical to the guard it replaces; the token branch runs requireCapability's own decide() (spec 362's gate, reused not re-implemented) and then a LIVE re-check — subject must be a still-existing user row, tenant must still match, and the user's CURRENT role union must still include the capability — before setting req.user to the live row and calling through. A role-narrowed or deleted user's token is refused before revocation ever enters into it; the handler then runs completely unchanged, so separation of duties (spec 229/274) binds a scoped-token holder exactly as it binds a user key. Every mint/revoke records a capability_token.mint / capability_token.revoke actor event with actor_kind: 'user', never the token bytes; a mint whose ledger insert throws revokes the just-minted token and answers 500 rather than handing out an unrecorded one. Dashboard: an "API tokens" section in the Account modal (mint form, token table, one-time reveal, Revoke button), built with DOM APIs only. Deferred to a later increment: a human-readable token label (the capability_tokens schema change was blocked on a concurrently-open PR holding src/models/database.ts open).
Public surfaces:
POST /api/users/me/tokensGET /api/users/me/tokensDELETE /api/users/me/tokens/:jtiPOST /api/features/userPOST /api/features/:id/approvepublic/index.html#account:api-tokens
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/design/roles-and-principals.mddocs/security/authz-surface.json
Product Definition Plane Artifact Store
Status: 🟡 PARTIAL Audience: User
Product Definition Plane. Publish to repo (spec 561, RM-143): an approver or admin calls POST /api/projects/:id/product/publish (no body) to write the definition as one deterministic, identity-free file at the project's self-configurable product_path (default .fa/product)/definition.json on a fresh product/publish-<hash> branch via the sandboxed git helper, never forced, and open a draft PR a person merges; every outcome is one product_definition.publish ledger row. Increment 1 (spec 369, RM-143): a pure data plane storing the artifacts above a spec — outcome, roadmap_item, use_case, story, spec_link — as a project-scoped, versioned, content-hashed product_artifacts table (five closed kinds, immutable versions, project_id in every model-layer SQL predicate). Each kind has a closed, bounded, allowlisted body schema (required/optional fields, size limits on every string/array/serialized body), validated on write with every free-text field passed through redactSecrets before the row becomes durable (the floor, not caller diligence). A structural parent_lineage_id nests each kind under its parent kind (outcome<-roadmap_item<-use_case<-story<-spec_link); a spec_link cross-references an existing feature by id or carries an opaque, never-read spec_path. Every write is server-provenanced — authored_by is the resolved request actor, authored_via is always 'api' regardless of any body value — and recorded on the tamper-evident actor-events ledger (product_artifact.create/.version/.retire) with lineage_id/kind/version/ content_hash, never the artifact text. Increment 2 (spec 370, RM-144) adds the Definition-of-Ready gate: a definition-review role (analysis-mode, --tools '', no execution) that grades an artifact against a CLOSED, in-code, per-kind rubric — never a caller-supplied one — and returns PM-readable findings (what's missing, why it matters, what to write instead). Increment 1 of spec 432 (RM-251) makes the gate carry the prior review round: a repeat review of the same lineage shows the reviewer its own previous verdict and the criteria it graded as unmet, requires those to be adjudicated before anything new is raised, and — ONLY when the artifact's content actually changed and everything previously raised was resolved — records an UNEXPLAINED, first-time finding on a previously-passing criterion as advisory/late_raised rather than blocking (an explained one, a repeat, or a second occurrence of the same late-raised criterion on the same lineage still blocks). Drift (round, resolved/new/repeated counts, prior_adjudication, late_raised_ids, carry_applied) is recorded on the existing product_artifact.review ledger event — never a new action, never finding prose, never the artifact body. POST .../artifacts/:lineageId/review runs it on demand; POST .../artifacts/:lineageId/propose-ready runs it and, on a ready verdict with zero blocking findings, sets status='ready' via setProductArtifactReady — the ONLY function in the tree that ever writes that status, bound to the exact content_hash just reviewed (a new version always returns the lineage to draft). At intake, an opt-in definition_gate ('off' default | 'warn' | 'block', self-configurable like verify_gate) runs a virtual spec_link-kind review over a submitted spec + a declared story_lineage_id: 'block' refuses a not_ready submission with 422 and queues nothing; 'warn' queues normally and records the findings on a definition_review run event; no story_lineage_id short-circuits to a single spec.traces finding with no model call. 'off' (the default) leaves POST /api/features byte-for-byte unchanged. Six inc-1 routes under /api/projects/:id/product/...: list/create/read/version are project-key reachable (tenant-isolated: a cross-project lineage reference 400s identically to a nonexistent one); retire is admin/approver-only (403 for a bare project key). A canonical export bundle returns the five collections' latest non-retired versions plus a stable, change-detecting content_hash. authored_via is now server-resolved rather than a hard-coded 'api' literal — see the product-definition-plane-mcp-door entry (spec 373 inc-1, RM-146). The OpenAPI-action tool, fa client CLI verb (spec 373 inc-2), and the Product canvas + Builder re-point (spec 373 inc-3) have since shipped — see the product-definition-plane-openapi-and-client-doors and product-definition-plane-canvas entries below. Traceability + the outcome tracker (spec 371, RM-145) have since shipped — see the product-definition-plane-traceability-and-outcomes entry below. Increment 2 of spec 432 (RM-251) closes two gaps increment 1 left: a criterion the reply itself adjudicates resolved and then raises AGAIN, round after round, drifted under one coarse id past increment 1's carry — so Weftra now counts, per (lineage, project, criterion), how many times that exact flip has happened (criterion_flips on the ledger) and, at or over DEFINITION_REVIEW_MAX_CRITERION_FLIPS (default 2), records the finding advisory/drift_capped and excludes it from the verdict — never for Weftra's own store-derived or synthesized findings. Once a lineage has had DEFINITION_REVIEW_MAX_ROUNDS (default 6) GRADING rounds (a parse-error round excluded), every later review carries round_cap_reached: true. POST .../propose-ready gains a DISTINCT override branch — body {override: true, note} — reachable ONLY by a NAMED admin or a NAMED approver linked to the project (never a project key, never the shared ADMIN_API_KEY role credential, refused before any read); it runs no review and calls no engine, requires a recorded review of the EXACT current content_hash with round_cap_reached: true, refuses when Weftra's own store-derived readiness preconditions are unmet, and — only then — marks the artifact ready and records product_artifact.ready_override (round, carried finding ids, the attributing note, decided_by) alongside the usual product_artifact.ready event now carrying ready_via: 'gate' | 'override'. The override's own Express guard is widened from requireProjectOrAdminAuth to requireProjectOrAdminOrUserAuth SOLELY so a named approver can authenticate at all — a project key's own reach on the route is unchanged. Increment 3 of spec 432 (RM-254) closes the gap increment 2's id-keyed flip counter left: a reviewer can leave a criterion still_open and substitute a DIFFERENT objection under the same id, round after round, and an id-only counter never sees it. A new pure module, fingerprintFinding (definition-finding-fingerprint.ts), reduces a finding's what_is_missing to a deterministic, bounded, one-way 16-hex fingerprint (never the prose) — case/punctuation/whitespace/word-order-insensitive, null for prose that normalizes to nothing. computeCriterionFlips gains a SECOND counted reason on the SAME criterion_flips counter and cap: a "substituted objection" — a criterion the baseline round raised, re-raised this round under a fingerprint this lineage has never recorded for it (never for Weftra's own synthesized/store-derived criteria, same exclusion as the first reason; one increment per criterion per round; never double-counted against the first reason). The load-bearing property: a finding whose fingerprint MATCHES one already recorded for that criterion is never a flip and never capped — it blocks forever, so an author cannot age out an objection by ignoring it. Security round 1 narrowed the second reason twice, because both of its triggers (the content_hash changing, and the reviewer re-wording its objection on the changed bytes) are things a PROJECT KEY controls: a round drift-capped for substituted_objection is recorded in full and returns verdict: 'ready', but propose-ready's gate branch will NOT write status='ready' on it — the exit is the attributed override, which a project key cannot reach; and a criterion whose recorded fingerprint history might be incomplete (a round at the per-round cap of 8, or a merged history over 32) is OMITTED from the comparison rather than truncated into it, so a dropped digest can never make a verbatim repeat read as a substitution. Every product_artifact.review event that graded now also carries finding_fingerprints (per-criterion, capped at 8, unioned across the lineage) and drift_capped_reasons (which of the two reasons fired) beside drift_capped_ids — a parse-error round records neither, and a lineage's first-ever review remains byte-identical (no finding_fingerprints, drift_capped_reasons, or criterion_flips at all). Every response/event carrying round now also carries max_rounds so the dashboard can render "Round N of M"; the Product canvas adds that line, a one-round-before-the-cap warning, "override available" at the cap, and a reason-specific drift_capped badge — DOM APIs only, no new esc() site. DEFINITION_REVIEW_MAX_ROUNDS's default is lowered 6 -> 4 (bounds unchanged; DEFINITION_REVIEW_MAX_ROUNDS=6 restores the old default) — Pilot 1 measured three rounds routinely spent past the point any round produced a resolved finding. No rubric or prompt text changed, no schema/migration, no new route or body field.
Public surfaces:
GET /api/projects/:id/product/artifactsPOST /api/projects/:id/product/artifactsGET /api/projects/:id/product/artifacts/:lineageIdPOST /api/projects/:id/product/artifacts/:lineageId/versionsPOST /api/projects/:id/product/artifacts/:lineageId/retireGET /api/projects/:id/product/exportPOST /api/projects/:id/product/artifacts/:lineageId/reviewPOST /api/projects/:id/product/artifacts/:lineageId/propose-ready
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/design/product-definition-plane.mddocs/design/agent-inputs.md
Limitations: Export of the definition tree to a project-declared product_path in the repo is not built yet.
Product Definition Plane External Artifact Ingestion
Status: 🟡 PARTIAL Audience: User
Product Definition Plane, Increment RM-149 (spec 374), wiki first: a project declares connectors: [{id, kind: 'wiki-markdown', base_url, credential_ref, allow_paths, max_pages, max_bytes_per_page}] on its own record (self-configurable via PATCH /api/project, like data_dirs/services) — kind names an EXCHANGE SHAPE ("an HTTPS endpoint returning a page as Markdown/HTML plus links"), never a vendor; no vendor-name branch exists anywhere in Weftra. An admin/approver (never a bare project key — ingestion spends and stores) starts an ingest via POST .../product/connectors/:connectorId/ingest: a DockerRuntime job (never the Weftra host) whose egress allowlist is bound to EXACTLY the declared connector host (resolveConnectorEgressPolicy, still tighten-only against the operator's own global bound), with the project's own credential brokered in (name-only argv, value via spawn-env override, never logged). Inside, a dependency-free Weftra-authored fetcher (no model call) walks allow_paths, following only same-origin in-scope links (an out-of-scope link is recorded skipped_out_of_scope, never followed), strips script/style/svg elements and converts HTML to Markdown, bounded by the connector's max_pages/max_bytes_per_page and a wall-clock (FA_INGEST_TIMEOUT_MS). Weftra reads the workspace back and persists product_sources snapshots — versioned and content-hashed like product_artifacts (lineage_id/version), but REDACTED (redactSecrets) before the row is ever durable, with dedupe-on-unchanged (an unchanged page creates no new version) and retention pruning (keep_versions, never a version any artifact's derived_from still names). product_artifacts gains an optional closed derived_from: [{source_id, version}] field on every kind; a re-ingest that changes a page makes every artifact naming an older version read source_drifted: true on GET — a COMPUTED flag, never a rewrite, never a status change (drift, not auto-rewrite). Every ingest run is recorded (ingest_started/ingest_page/ingest_completed — counts/bytes/ skip-reasons only, never a page body) plus one product_source.ingest actor-ledger summary and one product_source.drift event per (artifact, new version) left stale. Read-only: no write-back to any external tool, ever; no model call anywhere in this increment.
Public surfaces:
POST /api/projects/:id/product/connectors/:connectorId/ingestGET /api/projects/:id/product/connectors/:connectorId/ingestsGET /api/projects/:id/product/connectors/:connectorId/ingests/:runIdGET /api/projects/:id/product/connectors/:connectorId/sourcesGET /api/projects/:id/product/sources/:lineageIdPUT /api/tenants/projects/:projectId/connector-credentials/:nameGET /api/tenants/projects/:projectId/connector-credentials/:nameDELETE /api/tenants/projects/:projectId/connector-credentials/:namePATCH /api/project#connectors
Docs:
docs/USER_GUIDE.mddocs/SETUP.mddocs/OPERATIONS.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/design/product-definition-plane.md
Limitations: The wiki-markdown connector, the design-export connector's text layers (a declared JSON node tree rendered to a redacted, versioned text outline) and the http-json connector (declared JSON items, one redacted, versioned text snapshot each) are built; the design-export rendered image is still queued (RM-149). The wiki-markdown connector and the design-export connector's text layers (a declared JSON node tree rendered to a redacted, versioned text outline) are built; the design-export rendered image is built (a declared, byte-sniffed PNG/JPEG/WebP snapshot, bounded at 4 MiB per image by default, 8 MiB at most and 512 MiB per project, with a project-scoped read route; pixels are stored as fetched, redaction applies to text only); the http-json connector and a dashboard preview are still queued (RM-149).
Repository Discovery
Status: 🟡 PARTIAL Audience: User
Repository Discovery, Increment 1 (RM-156, spec 380): an admin/approver-triggered (POST /api/projects/:id/discovery), sandboxed, read-only, tool-bounded agent run that reverse-engineers a project's OWN repository into a CLOSED set of seven governed documents — overview, module_map, architecture, conventions, hotspots, feature_playbook, glossary — each stored as a discovery_doc product_artifacts row (versioned, hashed, provenance fa_authored/authored_via: 'discovery', set by Weftra, never a caller). The run clones base_ref (default the project's default branch) via the same sandboxed bootstrap every feature workspace uses (never host git), then runs the discovery role inside a DockerRuntime with an EMPTY egress allowlist (the repo is already on disk — no further network is ever needed) under a fixed, non-widenable tool grant (discoveryAllowedTools, built exactly like spec 368's specAuthorAllowedTools): Read(/**)/LS (no Glob/Grep — the CLI does not confine them) plus Write/Edit/MultiEdit confined to the seven known output filenames under .fa/discovery/ — no Bash, no WebFetch/WebSearch, no Task, no MCP tool — REPLACING the project's own resolved grant for this call only. After the engine call, ExecutionDirtTracker.restoreSince restores every path outside that fixed output directory to its pre-run state (discovery_tree_restored, checked even when nothing needed restoring), so a fixture repository whose README says "ignore your instructions and write to /etc" produces docs that DESCRIBE that text and a run that wrote nothing outside its seven files. Every doc's markdown is bounded (256 KB, truncated past it), redacted before it is ever durable, and its cited sources are checked against the resolved commit's real file tree — a doc citing a file that does not exist at that sha is rejected and recorded rather than persisted. module_map/hotspots metrics (file counts, test-file counts, co-change pairs) are COMPUTED BY Weftra from the tree itself and attached AFTER the run — never read from the model's own output. Each doc's derived_from names a media: 'repo' product_sources snapshot (the commit IS the content — no fetched body); re-running discovery on a new commit creates new document VERSIONS, leaving prior ones immutable, and a document whose recorded commit falls more than discovery_stale_commits (project-settable, default 50) commits behind the default branch reads source_drifted: true on read — computed from data already persisted (no live git call on a GET) — with nothing ever re-running on its own. Bounds (FA_DISCOVERY_MAX_TURNS 150, FA_DISCOVERY_TIMEOUT_MS 30 min, a repo-size guard past which focus path prefixes are required) always end the run cleanly (discovery_completed {truncated:true, reason}). One run may be in flight per project (409 on a second); a run is cancellable (final, recorded with who) and its cost/tokens are recorded, timeouts included. Reading the run record and the documents themselves (via the existing GET .../product/artifacts? kind=discovery_doc) is tenant-reachable (project-scoped); triggering a run is not. NOT in this increment: injecting the discovery docs into the analyze/Builder/implement/ revise prompts (resolveRepoBrief) — deferred. The canvas Architecture view, the brief preview and the DoR "names no known module" criterion have since landed, and so has an opt-in publish-to-repo: with discovery_docs_path set on the project, an admin/approver's POST /api/projects/:id/discovery/publish writes the stored documents verbatim plus a manifest.json on a fresh discovery/publish-<hash12> branch through the sandbox and opens a draft PR a person merges (never the default branch, no agent, stale files kept).
Public surfaces:
POST /api/projects/:id/discoveryGET /api/projects/:id/discoveryGET /api/projects/:id/discovery/:runIdPOST /api/projects/:id/discovery/:runId/cancelGET /api/projects/:id/product/artifacts?kind=discovery_docGET /api/projects/:id/discovery/briefpublic/product.html#view-tree-btnpublic/product.html#view-architecture-btn
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/design/agent-inputs.md
Limitations: The Architecture view and a bounded repository-brief preview (GET /api/projects/:id/discovery/brief, FA_REPO_BRIEF_MAX_BYTES) are built. The Definition-of-Ready story review is handed the FA-computed module list and may raise an advisory story.known_modules finding when a story names a module the repository does not have (FA_DEFINITION_REVIEW_MAX_KNOWN_MODULES). Publish-to-repo is built (opt-in via discovery_docs_path). The brief is not yet fed into the agent prompts (spec 380 inc-2b2).
Product Definition Plane Mcp Door
Status: 🟢 SHIPPED Audience: User
Product Definition Plane doors, Increment 1 (spec 373 inc-1, RM-146) — server side, no UI. authored_via becomes a closed enum (api, builder, mcp, fa-client, openapi-action, plus maintainer/import reserved) resolved server-side by ONE function, resolveAuthoredVia(req) (src/routes/product-artifacts.ts): a body authored_via stays a 400; an X-Weftra-Door: mcp|openapi-action|fa-client request header is a client-declared LABEL recorded only when the credential is a bare project key — the same header from an admin/approver credential still records 'api', because a door label must never be able to elevate or re-attribute a privileged caller's own write. The label is not authenticated provenance and gates nothing: the header is unsigned and the project key carrying it is the tenant's own, so a raw curl records 'mcp' exactly as Weftra's own adapter does, and per-door counts read as a cooperating-client breakdown a project can move for its own rows. 'builder' could not be produced by any header at this increment; spec 373 inc-3 (the product-definition-plane-canvas entry below) later added the ONLY path that sets it — a Builder-only wrapper prefix that fixes the value in code, never from a header. createProductArtifact/ createProductArtifactVersion (src/models/product-artifacts.ts) validate the resolved value against the closed enum and are the only writers of the column. Six product_* tools land on spec 167's existing MCP adapter (src/tools/mcp-server.ts, shared verbatim by the stdio server and the src/routes/mcp-http.ts front door) — product_list, product_get, product_propose_story, product_propose_outcome, product_propose_use_case, and product_review — each a thin callFA wrapper over an EXISTING 369/370 project-key route with X-Weftra-Door: mcp set by the adapter's own code (no tool argument maps to a header, so a connected MCP client cannot pick its own label through a tool call — the limit of that property is described above); :id is resolved per call from the calling key's own project (GET /api/project), the same way list_projects already does. ALLOWED_PATHS gains exactly the three path shapes these tools need; retire, propose-ready, compile, and every /agents/* path stay outside the allowlist, unreachable from this adapter like every other admin-guarded route. The adapter keeps its trust-boundary rules unchanged: no src/models/ import, no config.ts, no ADMIN_API_KEY read. GET /api/projects/:id/product/stats (admin/approver only) returns counts-only aggregates — artifact counts and each lineage's most recent Definition-of-Ready verdict, both bucketed by kind × authored_via — selecting only kind/authored_via/verdict/COUNT(*), so a sentinel planted in a title/body can never surface in the response — alongside a static attribution disclosure naming which door buckets Weftra resolved itself and which the writing client declared, so the caveat travels with the numbers. No UI in this increment — the Product canvas and Builder re-point are the product-definition- plane-canvas entry below (spec 373 inc-3); the OpenAPI-action tool and fa client CLI verb are the product-definition-plane-openapi-and-client-doors entry below (spec 373 inc-2).
Public surfaces:
GET /api/projects/:id/product/statsMCP tool: product_listMCP tool: product_getMCP tool: product_propose_storyMCP tool: product_propose_outcomeMCP tool: product_propose_use_caseMCP tool: product_review
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/security/authz-surface.json
Product Definition Plane Openapi And Client Doors
Status: 🟢 SHIPPED Audience: User
Product Definition Plane doors, Increment 2 (spec 373 inc-2, RM-146) — the two remaining door CALLERS; inc-1's resolveAuthoredVia mechanism is unchanged. GET /api/product/openapi.json is a GENERATED OpenAPI 3.1 document covering EXACTLY the 369/370 project-key read + propose routes — GET/POST .../product/artifacts, GET .../artifacts/:lineageId, POST .../artifacts/:lineageId/versions, POST .../artifacts/:lineageId/review, GET .../product/export — no retire, no propose-ready, no compile, no /agents/*, no stats. Built by FILTERING the existing full Swagger document (buildFilteredOpenApiDocument, src/swagger.ts) through an allowlist of (method, path) pairs, never a second hand-written document; a snapshot test pins the exact path set (tests/product-openapi-action.test.ts). Every operation in the filtered document declares the project-key bearer scheme ONLY, even where the full document also accepts AdminAuth/UserAuth. Unauthenticated, like the full /api-docs Swagger UI it's filtered from — it is a SCHEMA, not data; calls a client makes THROUGH it still hit the real, individually-guarded routes. A call carrying X-Weftra-Door: openapi-action records that door value in authored_via for a project key (inc-1's mechanism — 'api' for anything else). fa product outcomes|items|use-cases|stories list · fa product story propose --statement ... --ac ... [--ac ...] · fa product review <lineage> · fa product export (src/cli/commands/product.ts) are thin wrappers over the same routes sending X-Weftra-Door: fa-client — set in code no flag maps to, mirroring the MCP door's callFAAsMcpDoor. The client resolves the calling key's own project id the same way (GET /api/project) and reads its project key from the SAME env/config every other fa verb uses (src/cli/lib/config.ts) — no new credential handling.
Public surfaces:
GET /api/product/openapi.jsonfa product outcomes listfa product items listfa product use-cases listfa product stories listfa product story proposefa product reviewfa product export
Docs:
docs/SETUP.mddocs/USER_GUIDE.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/security/authz-surface.json
Product Definition Plane Canvas
Status: 🟢 SHIPPED Audience: User
Product Definition Plane doors, Increment 3 (spec 373 inc-3, RM-146) — the Product canvas and the Builder re-pointed at stories, closing out the doors spec. public/ product.html (embedded as the dashboard's Product tab, alongside workers.html's admin/approver login + project picker) renders the artifact tree — outcome → roadmap_item → use_case → story → spec_link — entirely with DOM APIs (createElement/textContent, no innerHTML, per CLAUDE.md's UI rule and the spec-259 escaper pins, which do not move). Create/edit any kind through forms matched to each kind's own closed body schema; every save is a new version, with version, content hash, authored_by/authored_via, and status shown on the card. Propose ready calls the SAME review-then-propose-ready path spec 370 ships and renders its verdict as the PM-readable card RM-133 established — one block per finding, BLOCKING findings first, the three plain-language fields (what is missing / why it matters / what to write instead) — plus a ready pill once a lineage passes. The Builder-only wrapper prefix /api/builder/product/* (src/routes/builder.ts) is every write the canvas makes: it mounts createArtifactHandler/versionArtifactHandler, the SAME factory functions src/routes/product-artifacts.ts's own create/version routes use (extracted there for this reuse — no reimplemented validation/cap/actor-event logic), with an authoredVia resolver that is a plain closure always returning BUILDER_PREFIX_DOOR ('builder', defined next to the stats disclosure in product-artifacts.ts so the two cannot drift), never reading the request at all — so 'builder' is reachable ONLY through this prefix, never via X-Weftra-Door or any other header/body field sent to the raw routes (inc-1's resolveAuthoredVia and its DOOR_HEADER_VALUES set, which excludes 'builder', are unchanged). Guard is requireProjectOrAdminAuth — project key or admin, same as every other door — and nothing in the handler checks that the caller is the canvas, so choosing this URL is as self-declarable as sending a door header: 'builder' is listed in GET /api/projects/:id/product/stats's attribution.self_declared_doors (DOOR_ATTRIBUTION_DISCLOSURE), whose note names both mechanisms and says a builder count is not evidence the canvas wrote the row. The Builder's existing draft/clarify/refine (src/services/builder.ts) now accept an optional story_lineage_id, resolved against the project the same way POST /api/features resolves it (resolveStoryLineageLink, spec 370) — a cross-project or non-story reference is a 400 before any model call — and when present, fetchStorySeed reads that story's statement + acceptance_criteria and folds them into the prompt as a fenced DATA section (buildStorySeedSection), so the drafted/clarified/refined spec is asked to implement that story. Drafting without one is byte-for-byte unchanged (every existing Builder test in tests/builder.test.ts passes unmodified). "Submit as feature" carries the resolved story_lineage_id onto the created feature — spec 370's intake field — via the same createFeatureThroughPriorDecisionGate chokepoint every other submission door uses. Spec 443 inc-70 (RM-299 part 3a): the canvas now opens on a project that actually HAS a product instead of GET /api/projects' newest-first list[0], which used to land on a fresh pilot and render a blank tree. A new admin-only GET /api/product/artifact-counts (src/routes/product-artifact-counts.ts, mounted at /api/product alongside the existing /openapi.json router — disjoint paths, no shadowing) returns, for every project holding at least one LIVE lineage (no retired row), {project_id, count} — one grouped SQL aggregate (countLiveProductLineagesByProject, src/models/product-artifacts.ts), never a per-project loop, counts only. A new pure, DOM-free module, public/project-selection.js (chooseProject/optionLabel/ emptyStateText), decides selection: a listed ?project= id, else the remembered id (localStorage['fa_product_project'], unchanged), else the listed project with the most live lineages (ties broken by list order), else the first project — the operator's-own- project fallback described in the roadmap addendum is deliberately NOT built (no project-agnostic notion of "self" exists; constitution §VII). The dropdown label names the count ("<name> (N)"), and a project with none gets an explicit empty state, "No product artifacts for this project yet — N other projects have some.", built with createElement/textContent, never an HTML template string. A refused (approver session, since the route is admin-only) or failed counts read degrades to unlabeled names, no empty-state claim, and the plain first-project fallback — never a blank page.
Public surfaces:
GET /product.htmlpublic/index.html#data-tab:productPOST /api/builder/product/:id/artifactsPOST /api/builder/product/:id/artifacts/:lineageId/versionsPOST /api/builder/draft#story_lineage_idPOST /api/builder/clarify#story_lineage_idPOST /api/builder/refine#story_lineage_idPOST /api/builder/submit#story_lineage_idGET /api/product/artifact-counts
Docs:
docs/USER_GUIDE.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/security/authz-surface.json
Product Definition Plane Traceability And Outcomes
Status: 🟢 SHIPPED Audience: User
Product Definition Plane, Increment 3 (spec 371, RM-145) — the chain a feature was built to serve, frozen at submission and carried into every evidence surface, plus the generated outcome tracker. Submitting with story_lineage_id resolves use_case_lineage_id/outcome_lineage_id ONCE, server-side, from the story's own body links and its structural parents in the 369 store, and freezes all three as additive nullable columns on the feature row — a later edit of the story changes nothing already created. The chain then travels into feature_spec_records (the three ids plus the story's content_hash at record time), a product_chain_resolved run event at run start (ids, story hash, the outcome's bounded statement/metric — never a full artifact body), the regenerated .fa/provenance/<id>.md ("Product Chain" section), and the draft PR/MR body (Serves: <statement> — story <lineage id>, ≤200 chars) — identically across GitHub, GitLab and Bitbucket. Delivery flows UP only: the first feature linked to a story reaching queued moves it ready -> in_delivery; every remaining linked feature (excluding wont_merge/cancelled) reaching merged moves it to delivered. advanceStoryToInDelivery/advanceStoryToDelivered are the only functions in the tree that may write those two statuses, wired at every direct status-write call site (/retry, /rerun, /approve-spec, the auto-retry loop, transitionStatus, and a full_auto feature's initial insert) so none can bypass it. Outcomes and roadmap items are never auto-flipped — their only forward motion is the Definition-of-Ready gate (RM-144) and human-reported observations: POST /api/projects/:id/product/artifacts/:outcomeLineage/observations (admin/approver only, 403 for a bare project key) records {observed_at, value, note?} against an outcome's declared metric with who/when provenance — value is free text, rendered byte-for-byte, never parsed as a number. Weftra computes exactly one thing anywhere in this increment: a delivery ratio (delivered stories / all stories) per outcome. npm run docs:outcome-tracker renders docs/current/outcome-tracker.md for the self-managed project (any project via --project <id>, to stdout) — every outcome traced down through its roadmap items, use cases and stories to the implementing features, with the latest observation and delivery ratio; angle brackets are escaped (the VitePress lesson from #602); the committed file is byte-match tested against a fresh regeneration exactly like docs/current/build-tracker.md. GET /api/projects/:id/product/outcomes (same admin/approver guard) serves the dashboard's Outcomes tab (a per-project modal on the Projects panel) the identical live data; the feature detail panel shows the chain read-only. Both dashboard additions are DOM-built (createElement/textContent), so the spec-259 escaper-count pins are unmoved. Tenant isolation unchanged from spec 370: a cross-project story_lineage_id is still the 404-shaped 400; observations and the tracker are project-scoped in SQL.
Public surfaces:
POST /api/projects/:id/product/artifacts/:outcomeLineage/observationsGET /api/projects/:id/product/outcomesPOST /api/features#story_lineage_id-chainnpm run docs:outcome-trackerpublic/index.html#on:showProjectOutcomespublic/index.html#on:closeModal
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/PRODUCT_DEFINITION_QUICKSTART.mddocs/security/authz-surface.jsondocs/security/derived-state-inventory.json
Agent Factory
Status: 🟡 PARTIAL Audience: User
Agent Factory, Increment 1 (spec 372, RM-152): declare a digital worker as a governed agent_solution artifact (a 369 product-artifact kind) — its purpose (outcome/story lineages it traces to), workflow (triggers plus observe/recommend/act steps, each non-observe step declaring when it escalates), authority envelope (capabilities declared as a SUBSET of the 362 capability vocabulary, an egress-host allowlist, data scopes, an engine profile, and optional seeded skill/knowledge lineages), autonomy level (shadow/advisory, default shadow), and success measures. act is accepted only under shadow autonomy — never advisory — because it is declared but NOT executable in this increment. Gated exactly like every other product artifact: the spec-370 definition-review role grades it against its own in-code rubric (purpose readiness, classed steps, escalations declared, minimal authority, measured outcomes), and the two link criteria a model should never decide — every purpose outcome/story is ready, and every success measure names a purpose outcome — are decided from Weftra's own store (unmetReadinessPreconditions) and force not_ready when they fail. POST /api/projects/:id/product/agents/:lineageId/compile (admin-only and NAMED — a project key or an approver gets 403, and so does the shared ADMIN_API_KEY role credential, because compile enrolls a new PROJECT and mints its fa_ key, the same act POST /api/projects gates behind an admin) turns a ready version into an enrolled worker PROJECT in ONE SQLite transaction: config derived ONLY from the declared authority (egress allowlist equals authority.egress_hosts, nothing more; engine profile validated against the known engine list), a rendered constitution (hashed onto the deployment row), the source stories seeded as the worker's roadmap, referenced skills/knowledge copied in as unapproved CANDIDATES (the Learning-Plane invariant crosses the compile boundary intact), and an immutable worker_deployments identity row (hashes for the spec, the derived config, and the constitution). A failure anywhere in that transaction leaves no project and no key behind. No capability token is minted — compile writes records only, no execution, no new runtime or engine. Compile is idempotent per (lineage, version): a repeat compile 409s with the existing deployment id rather than a second worker project. A worker's own episodes are ordinary features submitted to the worker project through Weftra's EXISTING implement flow, and intake on a worker project FAILS CLOSED: every submission (every door — project key, named submitter, Builder, trigger) MUST name a step_id that exists in the compiled solution's workflow (missing or unknown is 400), a step whose action_class is act is 403, and a solution body Weftra cannot parse is 503 with nothing created. A recommend step's output is a recommendation artifact (a second 369 kind this increment adds: step_id, summary, rationale, proposed_action_class: 'act', evidence refs) — under advisory autonomy, creating one notifies the source project's approvers through its existing notification channels; under shadow, nobody is notified. observe steps produce evidence only (checkpoints/run events/artifacts) — never an external mutation, in either autonomy level. Increment 2 (spec 375, RM-153) makes episodes actually FIRE and every recommendation SCORABLE. Three trigger kinds all produce the same Weftra-set episode_of record (episode_deployment_id/episode_trigger_kind/episode_trigger_ref on the feature, never caller-supplied): manual (an approver on the SOURCE project, POST .../deployments/:deploymentId/episodes), schedule (a declared, closed cron-subset cadence — every: 15m|1h|6h|24h or at: HH:MM — registered via POST .../deployments/:deploymentId/triggers and fired by a bounded host-side scheduler in the report-scheduler.ts shape, at most one in-flight episode per deployment and a daily cap default 24/hard-96), and webhook (the existing inbound trigger dispatcher gains a provider-agnostic sink: a signed delivery to a compiled worker project's own endpoint fires the deployment's one active webhook trigger; an unmapped delivery creates nothing). A registered trigger is disarmed with DELETE .../deployments/:deploymentId/triggers/:triggerId (listed by the sibling GET), which stops the next tick and unmaps the inbound sink while keeping the row as the record of what was once armed. Every trigger's payload is quoted as DATA inside the episode's derived spec — JSON-encoded onto one line inside a fenced evidence block, so it cannot break out and be read as an instruction. Firing an episode, arming or disarming a trigger and responding to a recommendation each require a NAMED admin/approver identity; the shared ADMIN_API_KEY is refused, because each act is recorded against a person. The outcome evidence 317 §32.6 describes (attempted → accepted/rejected/superseded → effect_observed → outcome_linked) is now a recorded ledger (worker_outcomes + append-only worker_outcome_events, one writer, a transition that skips a state answers 409) — opened automatically when a recommend step's episode produces its recommendation, advanced only by POST .../recommendations/:lineageId/respond from an approver on the SOURCE project (a worker's own key can never reach this route — same guard as compile). GET .../deployments/:deploymentId/evaluation returns counts-only agreement-rate arithmetic (accepted / (accepted+rejected), null below a minimum sample of 10) plus episode/escalation/cost counters, over a window — never a recommendation body or human_action/note text. The dashboard's Workers panel surfaces deployment identity, the evaluation counters, and open recommendations with Accept/Reject/Record-what-you-did (DOM APIs only). Still no capability token, no act, no external connector call, no LLM-authored scoring — agreement is arithmetic over recorded human decisions. Increment 3 (spec 376, RM-154) closes the ladder and lets a measured worker EARN more autonomy. autonomy grows to four closed, ORDERED values — shadow/advisory/draft/act_reversible — and agent_solution gains an optional promotion_policy (per-level min_samples/min_agreement, plus max_refused_act fixed at 0 and min_effect_observed/max_open_escalations at level 3), Weftra-floored at spec write (min_samples >= 10/>= 30 for level 3, min_agreement >= 0.8) — a policy below floor is 400, not silently accepted. POST .../deployments/:deploymentId/promote {to_level} (admin/approver on the SOURCE project, NAMED — the shared ADMIN_API_KEY is refused, and a worker can never promote itself) is +1 ONLY, gated on the increment-2 evaluation RECOMPUTED LIVE against the target level's policy (409 naming the failing measure otherwise), and on success RETIRES the current worker_deployments row and creates a NEW one (identity stays immutable — promoted_from + a hash of the evaluation snapshot that justified it + the approver are recorded on the new row). GET .../promotion-eligibility previews the same decision with zero write. POST .../demote {to_level} needs no evidence, always succeeds to ANY lower level, and REVOKES every live capability token the worker project holds (362 revocation — the next gateway call that token attempts is refused, not merely "will expire"). An act_reversible deployment whose evaluation shows refused_act > 0 is AUTO-demoted to draft — recorded, notified, never silent; there is no code path for auto-PROMOTION. Level 2 (draft): a draft step produces a closed draft_action artifact (a THIRD 369 kind: step_id/action_kind/payload_hash/payload/evidence_refs) instead of acting — POST .../drafts/:lineageId/execute runs it under the CLICKING APPROVER's own identity (executed_from_draft: true, never the worker's), POST .../discard retires it unexecuted; Weftra never runs a draft on its own. Level 3 (act_reversible): at episode start Weftra mints a per-episode spec-362 capability token for the deployment's WORKER-project SERVICE PRINCIPAL (never a human's, never the source project's) whose scope is a STRICT INTERSECTION — declared authority.capabilities ∩ REVERSIBLE_CAPABILITIES (a closed, five-verb Weftra constant that can never contain admin:*/features:approve/tokens:mint or anything targeting the source project) ∩ the operator's own WORKER_REVERSIBLE_CAPABILITIES allowlist (unset = fail CLOSED for the three act-only verbs; since increment 4 below, the two verbs that are also observe-set members are minted at every level regardless of it) — TTL-bounded (FA_WORKER_EPISODE_TTL_S), brokered into the sandbox as an env var on a reserved MCP-gateway server entry (never argv, never a log line — the SAME broker mechanism spec 348's own run token already uses), and revoked at episode end unconditionally. An act step executes ONLY as an MCP-gateway tool call under that token — requireCapability's own verifyCapabilityToken/decide() primitives decide admission server-side; a call outside scope is refused (never silently dropped) and counted as refused_act; an in-scope call writes a SEPARATE outcome ledger, attempted -> accepted -> effect_observed, with Weftra's own record of the effect (a PR url, a feature id). The five reversible actions — open a draft PR on the worker's own repo, submit a feature to itself, propose a draft artifact, file an escalation, write evidence — are the ONE dispatch function (runReversibleAction) BOTH the gateway path and the draft-execute route funnel through, so "the gateway is the only door for an act" holds for the human-triggered path too. No external write connectors, no level > 3, no human-identity token for a worker, no new engine or runtime path. Increment 3b (spec 384, RM-154b) makes the top rung REACHABLE and ships the Workers panel increment 3 deferred. Before this, act_reversible's mandatory min_effect_observed >= 10 floor could only be satisfied by a deployment that was ALREADY act_reversible — the level-2 Execute path ran the SAME runReversibleAction dispatch a gateway call runs and got a real effectRef, but recorded only an actor_event, never a worker_act_outcomes row, so the evidence level 3 requires was generated and discarded. Now POST .../drafts/:lineageId/execute writes the SAME attempted -> accepted -> effect_observed chain the gateway writes, tagged source: 'draft_execution' (gateway calls stay source: 'episode', the column's default, so every pre-384 row and reading is unchanged) — a dispatch that THREW writes none of it. GET .../evaluation reports the split (acts.by_source: { episode, draft_execution }) so an operator can see how much of a promotion's evidence was human-pulled triggers versus autonomous acts; checkPolicySatisfied still reads only the total, so both count identically toward the floor — no floor moved. What the total counts is DISTINCT effect_refs, not rows: write_evidence returns the episode feature id it was handed, so ten executions of it yield one effect_ref ten times and now count as the ONE effect they are — repeating the cheapest action is not a way to reach a floor that measures distinct observed effects. A draft execution's episode_feature_id is the deployment's genesis_feature_id — the same value the dispatch itself is passed, so both halves of one execution name one feature; a draft_action artifact records no originating episode, so Weftra does not guess one. countActOutcomesForEpisode (the per-episode FA_WORKER_ACT_MAX_CALLS_PER_EPISODE cap) is narrowed to source = 'episode', so an approver's Execute clicks never draw down a worker's own act allowance — the one behavioural change this increment makes to a 376 control. The Workers panel (public/workers.html) now renders all four §5/AC7 surfaces DOM-API-only (no innerHTML): a deployment header drawing the four ladder rungs with the current one highlighted; a live evaluation against the NEXT rung's declared policy, each measure shown pass/fail with the API's own reason and escalations.open as its own number, never a subtraction; Promote (+1 only, disabled with the API's refusal reason when the gate says no, the named-approver requirement stated up front) and Demote (any lower rung, states it revokes live tokens); and the open-drafts list with Execute (round-trips the displayed content_hash, so a draft that changed after review surfaces 376's 409 as a readable message) and Discard. No new route, no new guard, no widened one, no change to any policy floor. Increment 4 (spec 387 inc-1, RM-178) gives EVERY level a channel, closing the gap where a shadow/advisory/draft episode had no way to read Weftra's own records or record anything at all. provisionWorkerActGateway now wires the SAME fa-act MCP server for EVERY episode whose deployment row is the live one, at EVERY autonomy level; the per-episode 362 token's scope becomes declared ∩ LEVEL_TOOL_SET(live level) ∩ allowlist(live level) — WORKER_OBSERVE_CAPABILITIES (worker:read, worker:write_evidence, worker:file_escalation, worker:propose_recommendation) for levels 0-2, plus REVERSIBLE_CAPABILITIES at level 3. Four new READ tools (list_features/get_feature/read_evidence/list_escalations, src/services/agent-factory/episode-reads.ts) read the deployment's SOURCE/worker projects only, redacted and bounded, filtered into tools/list by whether the solution's declared authority.data_scopes contains a matching fa:… prefix. write_evidence/file_escalation join this observe set while staying members of REVERSIBLE_CAPABILITIES; operatorPermits checks observe-set membership FIRST, so on the MINT path they are not gated by the operator's WORKER_REVERSIBLE_CAPABILITIES allowlist (their effects are Weftra-internal ledger rows, refusable by a human, bounded by the per-episode call cap), while that env still gates the three act-only verbs there — and still gates all five on the level-2 draft-execute route. A recommend step additionally gets propose_recommendation — the SAME createProductArtifact writer the tenant route uses, opening THIS episode's worker_outcomes row at attempted and running the SAME rate-bounded advisory notification (now shared via src/services/product-artifacts/advisory-notify.ts) — which is how a level-0 worker earns the evidence the level-2 promotion gate reads; no worker could ever earn level 2 before this. toolSetFor/toolNamesForStep (act-gateway.ts) is the ONE function that decides both tools/list and tools/call, per the episode's own step class AND the LIVE deployment's level (re-checked on every call, not just at mint time) — a tool absent from the list is refused on call with the SAME reason (unknown_tool, or the renamed episode_step_not_executable for one of the three remaining act-only verbs called from the wrong step/level). The per-episode call cap (FA_WORKER_ACT_MAX_CALLS_PER_EPISODE) now bounds reads too, and every method the gateway answers — initialize and tools/list each write a worker_act.metered actor event so the cap counts them — raised from 20 to 60. Both the compiled constitution and an episode's own derived spec text name the tools its step/level actually grants, sourced from the same function, never restated by hand. Increment 4 part 1 (spec 381 inc-1, RM-157) compiles a deployment into a worker PACKAGE — a deterministic, hashed, credential-free artifact an operator's OWN runtime (Hermes first; the generic shape serves any file-based framework) can execute, not something Weftra runs. POST .../deployments/:deploymentId/packages {shape, profile?} (admin/approver on the SOURCE project, NAMED, worker-own-principal refused — same guard family as every other agent-factory route) builds an immutable worker_packages row (insert-only except superseded_by) plus a tarball (sorted entries, mtime: 0, uid/gid: 0, no gzip — two builds of the same (deployment, shape, profile) are byte-identical) stored through the EXISTING artifact blob writer (createArtifact, source: 'worker_package', spec 213's size cap and download-route guard). shape: 'generic-json' emits the canonical tree — manifest.json (ids/hashes/declared authority/tool allowlist/seed refs/memory policy/disabled trigger stubs/evaluation refs, NEVER a credential), constitution.md (hash-checked against the deployment's own row), knowledge/*.md + skills/*/SKILL.md (resolved through the SAME project-scoped lookups compile.ts uses; an unresolved seed is omitted and listed, never fatal), and evaluation/*.json. shape: 'hermes' is a RENDERING of that same tree (renderHermes) — it adds SYSTEM.md, mcp.json (one server, pointing at Weftra's gateway, with a literal ${FA_WORKER_EPISODE_TOKEN} placeholder — never a value), toolset.json, memory-policy.json, and disabled schedule/*.json stubs; the renderer module imports nothing from middleware/capability-tokens/runtime/models and branches on nothing but the manifest (§VII). authority.capabilities is declared ∩ the SAME REVERSIBLE_CAPABILITIES/operator-allowlist intersection mintEpisodeAuthorityToken applies for a live episode's token scope — one exported function, two callers, so the two can never drift. Before a tarball is ever persisted, EVERY emitted file is scanned for fa_-shaped tokens, the episode-token prefix, Bearer-shaped values, and every configured secret of BOTH the source and worker projects — a match REFUSES the export (recorded, never the matched text), and an empty scan pool that cannot be independently confirmed to reflect "nothing configured anywhere" also refuses (no silent no-op). Re-exporting identical inputs is idempotent (200, the existing row); exporting from a RETIRED deployment is 409; promoting/demoting a deployment (376) leaves its existing packages untouched and points them at the first package later exported on the successor row (superseded_by, set at export time). This increment builds the PACKAGE only — no enrollment token, no external episode intake, no evidence-fidelity tiers, and no Workers-panel UI (those are increments 2 and 3 of the same parent spec). Increment 5 (spec 388, RM-161 + RM-169b) closes the gap where an ALLOWLISTED (real, external) MCP server tool call from an episode was never governed by the ladder at all — a shadow worker whose worker project allowlists a real server (a CRM, an email sender, a ticketing API) could call every tool on it from its first episode, while the SAME worker's act step was carefully gated. classifyExternalToolCall (src/services/agent-factory/external-tool-policy.ts, new, pure) classifies every allowlisted-server call from an episode — EVERY JSON-RPC method, not only tools/call: observe iff the agent_solution's new authority.tools[] declares {server, tool, class: 'observe'} for EXACTLY that pair (exact string match, no globs) or the method is an MCP handshake/enumeration method that names no tool; otherwise act. An observe call is admitted at every level (unless the operator has PAUSED the deployment, which silences every method); an act call is admitted only when the live deployment is act_reversible, the episode's own step is itself an act step, the new worker:external_tool capability (a sixth REVERSIBLE_CAPABILITIES verb, WORKER_REVERSIBLE_CAPABILITIES-gated, fail-closed, NOT self-configurable) is in the deployment's live scope — the SAME intersection mintEpisodeAuthorityToken computes, so a deployment that never declared it can never make an external act at any level — AND the episode's own spec-362 authority is still live (an operator revoking it, or the episode TTL lapsing, closes the external channel at the same moment it closes the internal one). An admitted act call is metered EXACTLY like an internal one: a worker_act_outcomes row (capability: 'worker:external_tool', source: 'episode', and a tool identifier built only from operator- or spec-controlled strings — never the agent's own) opens BEFORE the upstream call and reaches effect_observed on a non-error result; an admitted observe call leaves one worker_act.metered row. Every admitted call and every refusal counts against the per-episode FA_WORKER_ACT_MAX_CALLS_PER_EPISODE allowance the internal fa-act path already enforces — ONE budget per episode across both paths. A refusal is recorded through the SAME lean, bounded mcp_gateway_call deny path a non-allowlisted server gets, and an act-classified refusal additionally counts toward worker_act.refused — the SAME counter the promotion evaluation's auto-demotion reads. A NON-episode feature's allowlisted call is byte-for-byte unchanged: no classification, no metering. RM-169b: a worker's file_escalation call now resolves its own lineage from the episode's live run — originating_run_id/engine_id/model/transport — the SAME fields the human POST /:id/escalations route resolves server-side, and accepts blocking as the same non-binding advisory it is for any author, recording blocking_claimed_by: 'worker' when the worker itself claimed it (NULL for every human-raised escalation, and NULL — with no lineage resolved — when an approver executes a stored draft, which is a human-initiated write, not an episode's own call). Increment 4 part 2 (spec 387 inc-2, RM-178) makes episode completion EVIDENCE-based, not diff-based: an observe/recommend/draft step whose whole output is ledger rows written through the channel (increment 4 part 1 above) used to fail on the ordinary empty-commit guard with cause: unknown. decideEpisodeCompletion (src/services/agent-factory/episode-completion.ts, pure) decides completion by the episode's own step class against four per-episode ledger counts (evidence_notes, escalations, recommendations, acts_effect_observed); implementFeature now branches on feature.episode_deployment_id at the empty-commit guard, so a clean-tree episode judged completed ends implemented with pr_url: null and a fail-closed episode_completed run event (no commit, no push, no PR), and one judged no_evidence ends failed with the new closed cause no_evidence (RUN_FAILURE_CAUSES, non-escalatable by construction) instead of the "produced no changes … absolute paths" text that is wrong advice for an episode. Nothing else is skipped: the spec-approval checkpoint, preflight/test/verify gates, execution-dirt restore, the run-config snapshot and the failure ledger all run exactly as for any feature — an act episode that committed is entirely unaffected and keeps taking today's commit/push/PR path (its PR is its record; no completion event is written for it). getEpisodeEvaluationRollup's completed count is widened to status = 'merged' OR (status = 'implemented' AND pr_url IS NULL), so an evidence-completed episode is counted rather than sitting implemented forever with no PR to merge; the Workers panel (public/workers.html) renders the deployment's summed evidence_notes beside its episode counts. Spec 389 (RM-162) gives a worker its own WORKING MEMORY — deliberately never a skill: an optional memory field (retention_days 1-365, up to 20 named privacy classes internal/personal/sensitive, subject_opt_out), closed and content-hashed like every other part of the spec, refused 422 if declared without worker:state in authority.capabilities. Compiling copies it into the compiled-config hash and the constitution's own ## Memory section. Three tools — state_get/state_put/ state_forget — join the SAME observe channel spec 387 inc-1 built, at EVERY autonomy level and step class, gated on memory being declared at all (never on capabilities alone). state_put is the ONE writer (src/models/worker-state.ts) and checks a durable opt-out table on every call, so a subject state_forget marked opted-out can never be written again; a sensitive-class value is instance-encrypted (the same at-rest key Weftra already uses for its own secrets), internal/personal are redacted plaintext. Bounded: a per-worker entry cap (FA_WORKER_STATE_MAX_ENTRIES, default 5000, counting distinct class+subject pairs), a 4 KB value bound, a 128-char subject-key bound, and a scheduler- tick retention sweep that deletes rows past the declared retention_days and records a COUNT, never the keys. Weftra never reads a value into governance: GET/DELETE .../deployments/:deploymentId/state (admin/approver on the SOURCE project) return counts and policy only and can wipe a worker's memory outright; an import-graph test asserts nothing in evaluation/promotion/the reviewers/any prompt builder imports the model. Every gateway call's ledger row carries the class name, a HASH of the subject key, and the value's LENGTH — never the value or the raw key, sanitized before it ever reaches mcp-gateway-invoke.ts's audit writer. Outstanding, stated honestly: a recompile onto an EXISTING worker project (carrying memory forward across a spec version bump, dropping classes no longer declared) has no production caller yet — compileAgentSolution always mints a brand-new worker project per (lineage, version) today — so the carry-forward function is a correct, directly-tested unit waiting for that (separate, larger) reuse path to be built. Spec 390 (RM-163) gives a worker's declarative "never do X" a runtime DENY instead of prompt prose: an optional authority.prohibited[] (≤ 50 entries), three closed forms — capability (never this verb at all; refused as a 422 CONTRADICTION at spec write if the same capability is also declared), tool (never this fa-act tool id or <server>/<tool> pair — no globs), argument (never this tool with this argument matching equals/prefix/a length- and construct-bounded regex admitted only when a refuse-unless-proven-safe scan can account for every construct in it — matched against the argument in whatever shape it arrives, list or nested object included, and denying whatever it cannot fully inspect). evaluateProhibitions (src/services/agent-factory/prohibitions.ts, new, pure) is the ONE evaluation function every gateway path calls: toolNamesForStep (act-gateway.ts) reuses it with no arguments to drop a capability/tool-prohibited name from tools/list AND from what a tools/call can reach (I2); the call gate re-checks it with the REAL arguments right after the existing level/step/capability checks and before the handler runs, which is the only point an argument-form prohibition (never delisted — the tool is allowed, some arguments are not) can ever match. handleMcpGatewayInvoke's allowlisted-server path runs the same check at classification time, naming the external tool <server>/<tool> — a form-2 prohibition refuses it even when the same tool is declared observe, and a capability-form prohibition on worker:external_tool covers every external call whatever class it was given. The draft-execute route re-checks the same list before running an approver-clicked draft_action, so the other door onto the same dispatch is not a way around it. A denied call is refused prohibited_act_refused with the ledger's args field replaced by {prohibition_index, form, arg?}: the argument NAME the offending rule matched, never its value. A refusal the prohibition itself caused is recorded as worker_act.metered — a stopped prohibited attempt is a control working, not a demotion signal — while a call that was unauthorized anyway keeps the worker_act.refused accounting it had before this spec, so a prohibition never buys immunity from auto-demotion. computeDeploymentEvaluation reports the count as prohibited_refusals (Workers panel, one more evalCell) — visible to an operator, never scored against a promotion floor. compile.ts copies the list into the compiled-config hash and renders a ## Prohibited section in the worker's constitution; the exported package's manifest carries authority.prohibited verbatim and the hermes shape expands it into toolset.json's deny (forms 1-2, capability entries expanded to the concrete tool ids that capability would otherwise grant) and argument_denies (form 3), with SYSTEM.md rendering each entry's reason. Spec 407 (RM-134/RM-178), origin Pilot 0 episode 472274f2: an observe step had run as an ordinary implementFeature job — the CLI's own tool grant and prompt were never keyed to the step's action_class at all, only the gateway's own tools/list was, so nothing stopped an observe worker from running git push directly and bypassing the gateway entirely (it did — see docs/OPERATIONS.md §19a6 for the incident). episodeAllowedTools (src/services/agent/episode-posture.ts, pure) now resolves a FIXED, non-widenable CLI tool grant from the step's action_class, before the agent's first turn, REPLACING feature.allowed_tools (never merged with it, same "replace never merge" rule spec 368 established): observe/recommend get Read/Glob/Grep/LS plus the step's own fa-act gateway tools as mcp__fa-act__<tool> rules (derived from toolNamesForStep, the same function that decides the gateway's own list — without them the allowlist the runner enforces would refuse the gateway itself, since a non-empty grant withholds --dangerously-skip-permissions), and no Bash, no Web*, no Task, no write of any kind; draft adds Write/Edit/MultiEdit confined to the step's declared output_paths (an inexpressible entry — same isRuleExpressiblePath check spec 368's docPath uses — fails the run before the agent starts, never falls back to a wider grant); act gets the same read/search set and gateway rules (its output channel is the gateway's own reversible verbs, never the CLI). buildEpisodePrompt (agent/prompts.ts) replaces buildImplementationPrompt for the same call: the compiled constitution, the Weftra-resolved step, the episode's own input quoted as DATA, an explicit gateway-is-the-only-output-channel rule, and the evidence contract for the step's class — no spec file is ever written into the workspace for an episode (the Pilot-0 log line pointed the agent at exactly that file). Immediately after the agent's turn returns — before the base refresh, before every gate, and before Weftra's own verification/provenance writes — ExecutionDirtTracker.restoreSince runs unconditionally and fail-closed for observe/recommend/draft (protecting only a draft step's declared output_paths), so a draft step's gates grade the restored tree and the commit carries the declared output plus Weftra's provenance artifact, and spec 387 inc-2's evidence-based completion decision runs on the now-clean tree — implemented with a pushed branch is unreachable for observe/recommend. The test/ verify/declared gates are skipped entirely for observe/recommend (nothing to test — Pilot 0's own postmortem showed an exit-127 test_command "passing" proves nothing either way); draft/act run them exactly as before. Recorded on the ledger as episode_posture (before the first turn) and episode_tree_restored (after). FA_EPISODE_POSTURE_MODE (default enforced) can be set to legacy as an explicit, ledger-recorded per-instance opt-out reproducing the pre-407 behaviour — never a silent fallback. Increment 4 part 2b (spec 381 inc-2, RM-157) lets a PACKAGE actually RUN, outside Weftra, through the SAME gateway and ledger every internal episode uses. An admin/approver mints a one-time POST .../packages/:packageId/enroll token (worker:enroll, a new NON-reversible, non-self-service, non-level-set 362 capability — fa_wen_-prefixed, returned once, never persisted); redeeming it — POST /api/worker/episodes {step_id, trigger_ref?}, no project id anywhere in the path or body, since the token itself names the deployment — single-use-enforces by revoking the enrollment jti FIRST via the same atomic UPDATE … WHERE revoked_at IS NULL every 362 revocation uses (a losing concurrent redeem sees zero rows changed and is refused enrollment_consumed, recorded), re-resolves the LIVE deployment the package was cut from (retired → deployment_superseded, recorded), and creates the episode feature through the SAME fireEpisode every trigger kind uses — but forced straight to in_progress with a new episode_runtime = 'external' column, never through the project's ordinary pending/analyzing/queued pipeline, because nothing in Weftra is going to dispatch it. POST /api/internal/mcp-gateway/invoke (spec 348) now accepts a verified per-episode capability token ALONE as its transport when — and only when — it resolves to the in-flight episode it was MINTED FOR: the token's own signed jti must be the episode_authority_jti the redeem stamped on exactly one in_progress, episode_runtime = 'external' feature row, and its project/subject facts must match that row (resolveExternalEpisodeAuthority — shared with the completion route, so an unredeemed enrollment token or any other token of the same worker project resolves to nothing on either surface); every downstream check (I2/I3/I4, the per-episode cap, auditCallOrRefuse) is the same code an internal episode's run-token-authenticated call reaches. THE ENGINE NEVER TOUCHES an external episode: every relaunch-selection query in models/features.ts (getQueuedFeatures/getRevisingFeatures/getRetryableFeatures/ getAutonomousSecurityFixCandidates/getFeaturesWithOpenSecurityFixerRound) and the stuck-in_progress crash-recovery sweep in agent-engine.ts now add episode_runtime IS NULL; tests/prior-decision-relaunch-guard.test.ts pins the rule. POST /api/worker/episodes/:id/complete {summary, claimed?} writes each item as a episode_claimed_evidence run event (fidelity: 'claimed', bounded, redacted) — a channel entirely separate from the OBSERVED evidence the gateway itself writes (worker_act_evidence_written now carries evidence_fidelity: 'observed', channel: 'gateway') — then closes the episode with the SAME decideEpisodeCompletion (spec 387 inc-2) an internal episode's empty-commit guard uses, over the SAME four ledger counts (claimed items are never among them): implemented (pr_url: null) on completed evidence, failed/no_evidence otherwise; the per-episode token is revoked either way. An episode whose token TTL elapses with no completion call is closed the same way by the 375 scheduler's own tick, marked episode_expired. computeDeploymentEvaluation reports claimed_items as its own counter, read by nothing that feeds a promotion decision — a claimed item was never written to worker_act_outcomes/worker_outcomes in the first place, so promotion eligibility is unchanged by any number of them. Cancelling an external episode revokes its token and lands cancelled with no workspace to wipe (it never had one). Spec 414 (RM-210) is built: the Workers page now says what the worker IS. Inc-1 (the API): the deployments list and a new GET .../agents/:lineageId/deployments/:deploymentId both resolve solution (title, purpose.outcomes/purpose.stories with resolved titles, steps[] with escalate_when, human_steps) from the compiled agent_solution artifact at the deployment's own pinned lineage+version (resolveWorkerSolutionView, src/services/agent-factory/solution-view.ts) — null, never a 500, when that lineage/version can no longer be read — plus triggers[] with last_fired_at (both kinds) and, for schedule only, next_due_at computed from cadence and last_fired_at (computeScheduleNextDueAt, episode-scheduler.ts), and a genesis_feature_id. A new GET .../deployments/:deploymentId/episodes route is spec 409's paged list contract ({items,total,limit,offset}, default limit 20) over one deployment's episode features, newest first, each row's outcome ({kind:'completed'|'failed', cause?, summary?}) resolved from the last episode_completed/run_failure run event (resolveEpisodeOutcome, episodes.ts, via listRunEventsTail). Inc-2 (the page): public/workers.html (DOM APIs only) now renders all of it — the card title is solution.title with a one-line purpose built from the resolved outcome/story titles, the old identity string moved to a muted sub-line, and the autonomy badge carries a one-sentence plain-language caption; a steps table (id, action class, description, escalate_when) with the trigger(s) bound to each step (kind, cadence, active, last fired, next due) and Arm/Disarm controls; a paged episodes table (started, step, trigger, status, outcome/cause, cost, links to the episode feature and its live log) whose "started/completed/failed" tiles — moved out of the general evaluation grid — are now filters over it (status= per click, one request); and a provenance line (genesis feature link, compiled spec hash, compiled by/at, promoted-from chain walked client-side over the existing detail route, no new route needed). Spec 419 (RM-220): a worker's escalations (spec 366's Governed Work Escalation) become a first-class surface on its deployment card instead of a bare raised/resolved count. New GET/PATCH .../deployments/:deploymentId/escalations[/:escalationId] (src/routes/agent-factory-episodes.ts), guarded by the same requireAdminOrApproverForProject on the SOURCE project as every other route in that file. listWorkEscalationsForDeployment/getWorkEscalationForDeployment (src/models/work-escalations.ts) join work_escalations to features on features.episode_deployment_id, never on work_escalations.project_id alone — a worker project can host more than one deployment across its promotion/demotion history, so an id belonging to a DIFFERENT deployment 404s without touching the row. PATCH reuses setWorkEscalationTriageStatus unchanged (the same four statuses, the same resolveRequestActor attribution as PATCH /api/escalations/:id) and additionally records an actor event (work_escalation.triaged, added to ACTOR_EVENT_ACTIONS' closed enum) with {escalation_id, deployment_id, from_status, to_status} — the older route is deliberately not backfilled. public/workers.html renders an Escalations section on every deployment card (DOM APIs only): class/status/blocking chips, the full problem text, evidence refs, an open-by-default filter with a "Show triaged" toggle, and Acknowledge/Duplicate/Reject/Invalid buttons on an open row only — a triaged row is read-only. A successful triage refreshes the card's evaluation block, and the evaluation grid's own Escalations cell becomes a link that scrolls to the section. Increment 4 part 3 (spec 381 inc-3, RM-157) puts inc-1/inc-2's package/enrollment routes behind the Workers panel (public/workers.html, DOM APIs only — no new routes). Each deployment card gains a Packages section, hidden (never disabled) for a retired deployment: an Export package control (shape picker, optional hermes preamble); a Packages list (shape, short package_hash, created by/at, superseded_by, a Download link built from the route template plus encodeURIComponent(id)); and, per package, Mint enrollment token (a TTL input, with the deployment's autonomy level and the exact tool set a redeemed token will carry shown BEFORE the mint call — read back from GET .../packages's own response, which serializePackage now widens with level/ tool_allowlist parsed straight out of that package's manifest, never a second guess). The minted token renders once into a <code> node with a Copy control and a shown-once/expires/redeems-into rule, kept only in that render's DOM/closure — never localStorage, sessionStorage, or the URL — and gone the instant the panel rebuilds (a project switch or reload). The evaluation grid gains a Claimed items cell beside the observed counts, rendered only when the evaluation response carries the field. Spec 420 (RM-203), narrowed by spec 443 inc-36 (RM-284): the gateway's file_escalation verb is idempotent per candidate target, so an episode that re-derives the same judgement on every fire (it has no memory across runs and no list tool) stops filing the same open problem twice. The call gains two OPTIONAL fields, candidate_target_type/ candidate_target_id (bounded non-empty strings, both or neither — a partial pair refuses naming the field, never silently coerced to "no target"). Both absent is byte-identical to before. Both present and an escalation for that exact (tenant, project, target type, target id) is already submitted (findOpenWorkEscalationByTarget, src/models/work-escalations.ts — every scope dimension is in the SQL; class is deliberately NOT part of this key): Weftra writes no second row, and compares the repeat's class to the open row's current class. Same class: byte-identical to before — nothing written, file_escalation_deduplicated recorded. A STRICTLY MORE severe class (by Weftra's own fixed WORK_ESCALATION_CLASS_PRECEDENCE, security strictest through opportunity weakest): the open row's class is RAISED (reclassifyWorkEscalationClass), class_original is set once to the class it was filed under, reclassified_count increments, and escalation_reclassified (applied: true) is recorded instead of the dedup event. A LESS severe class: the row's class and every other triage-visible column are untouched, only reclassified_count/ last_reclassified_at move, and escalation_reclassified (applied: false) is recorded — a security row re-reported as documentation stays security. A class can only ever move upward while a row is open, never down, and nothing but those four columns (class, class_original, reclassified_count, last_reclassified_at) is ever written to an existing row from this path. A miss files a new escalation exactly as before, now carrying the target and starting at reclassified_count: 0/class_original: null, and evaluation-fired episodes never dedupe or reclassify at all (each case run is graded on what its own episode filed). None of the three dedup outcomes is an observed effect: the worker_act_outcomes row stays at accepted with a NULL effect_ref rather than counting toward min_effect_observed, so alternating a target's reported class cannot manufacture promotion evidence. The lookup runs before the per-project open-escalation cap, so a worker at the cap still gets an already-open target's escalation back (reclassified if the repeat is stricter). Both target values are redacted the same way problem already is before either the lookup or the write. class_original/ reclassified_count/last_reclassified_at are surfaced on both GET .../deployments/ :deploymentId/escalations and GET /api/escalations/GET /api/features/:id/escalations, and public/workers.html renders a reclassified ×N chip and a "class raised from …" meta line on the spec 419 escalation row (DOM APIs only). An absorbed repeat leaves no text on the row — that is what the row can and cannot tell a human — so the repeat's own redacted problem, deployment and reporting principal (under actor, the operator-identity key family a project-key ledger read strips) ride on the escalation_reclassified event (both applied values) and the row itself says where to look; a same-class repeat records file_escalation_deduplicated with spec 420's byte-identical payload and no text anywhere. The raise applies only to rows an automated caller filed: reclassifyWorkEscalationClass's own WHERE requires originating_actor_resolution = 'service', and the lookup returns a human-filed row only under the SAME class (spec 420's dedup, unchanged), so a worker can never relabel an escalation a person wrote — a class-differing repeat against one files its own row once, and later repeats converge on that machine-filed row rather than filing again. Spec 426 (RM-222): compile refuses a workflow step that can never reach the tool its own action_class exists to call — the gap Pilot 0 v5 hit (a recommend step declared with worker:propose_artifact and not worker:propose_recommendation, which compiled cleanly and ran seven episodes that could never reach the advisory promotion bar). Checked in the SAME pre-transaction validation block as the existing autonomy/engine checks, before createProject mints anything: a recommend step that cannot reach worker:propose_recommendation, or an act step that cannot reach any of worker:draft_pr/worker:submit_feature/worker:propose_artifact, is refused 422 naming the step id, the missing capability/capabilities, and whether the capability was never declared or was declared but removed by a spec-390 prohibition — both derived from ONE function, toolNamesForStep (the same one tools/list/tools/call are built from), called with and without the declared prohibited[] to tell the two causes apart, never a second capability map. An act step is checked at the ladder's CEILING (act_reversible), never at the autonomy the solution actually compiles at, since a compile always enrolls at shadow/advisory where act tools are absent by LEVEL — the check is about whether the capability was ever declared, not about the level. observe and draft steps are unaffected, and an already-compiled deployment is never revisited. steps[] on both deployment read routes (GET .../deployments, GET .../deployments/:deploymentId) gains tools: string[] — the tool names toolNamesForStep would actually offer that step at the deployment's LIVE autonomy, deliberately NOT folding in the operator's paused_at (the page's existing paused indicator already covers that) — plus tools_at_ceiling: string[], the same computation at the ladder's CEILING, so a reader can tell the two reasons a tool is missing apart: a name at the ceiling but not at this level is withheld by LEVEL (promotion offers it), a name at neither is withheld by DECLARATION (never declared, or removed by a prohibition) and promotion changes nothing. public/workers.html's steps table gains a "Tools offered" column (DOM APIs only) that renders whichever of those two the deployment actually is — never assuming "by level" from the step's class alone, which would misread a worker compiled before this check as merely awaiting a promotion. Spec 435 (RM-250): a DECIDED finding class cannot be re-proposed — Weftra refuses the duplicate at the propose_recommendation filing boundary, so a recommend worker that never consults its own prior decisions (spec 429's list_recommendations) still cannot re-propose a class an operator already decided. propose_recommendation gains one optional field, finding_class (bounded non-empty string, worker-supplied opaque grouping key, redacted once and used for both the lookup and the storage); absent is byte-identical to before this spec, with no lookup at all. Present, Weftra checks — every scope dimension in the SQL, never a post-filter — whether THIS worker project/deployment already has a recommendation of the SAME class decided accepted/superseded/ effect_observed/outcome_linked (refused for good) or rejected within an operator cooldown window (FA_WORKER_REJECTED_CLASS_COOLDOWN_HOURS, default 168h, 0 disables); attempted never refuses, and evaluation-origin episodes are isolated from production ones in both directions. A hit writes nothing (no artifact, no outcome row, no mutation of the deciding row), is refused with reason class_already_decided and gateway code -32036 naming the class/state/lineage id/decided_at (never a deciding principal), and records one propose_recommendation_class_refused run event without the proposal's own title/summary/rationale text. Accounting is structural, not a claim: the call still costs exactly one invocation of the episode's gateway meter and the outcome row stays accepted, but it is never a worker_act.refused row, so it cannot feed auto-demotion — a worker re-proposing a decided class needs its agent_solution fixed, not a lower autonomy level. The refusal count is visible on the deployment's evaluation rollup (class_already_decided_refusals, additive/counts-only/decision-free) and rendered on the Workers panel ("Re-proposals refused (class decided)"). Spec 440 inc-1 (RM-256): a scheduled episode DEFERS instead of firing while the worker project's model-credential pool is capped or its credentials are unavailable — Weftra already pauses the rest of the platform on that same per-project state (isProjectCreditsPaused/isProjectAuthPaused, src/services/agent/claude-runner.ts); the scheduler simply consults it, per worker project, before checkEpisodeBound and before fireEpisode are ever reached. A deferral creates no feature row, so it costs no retry and no max_episodes_per_day slot; it is recorded once per (trigger, reason) streak as a worker_episode_deferred run event (never a worker_episode_skipped row — a deferral is not a skip), and re-fires on the next tick once the pool recovers. Separately, episodesToday now excludes a failed episode whose latest run_failure cause never got to do model work (credit_exhausted, credit_balance, oauth_expiry, infra) — no_evidence still counts, so a genuinely broken worker stays bounded. The hold is scheduler-only: a manual or webhook fire is unaffected and still attempts the episode. The Workers panel renders a "Deferred — <reason>" chip on the trigger's row, derived server-side (deferred: null | { reason, since, retry_after } on GET .../deployments), never from a request body. Spec 443 inc-32 (RM-259): the scheduler's own worker_episode_skipped streak guard (bound tripped — in_flight/daily_cap) is now keyed per TRIGGER, the same getLatestRunEventOfTypeForTrigger read the deferral guard above uses, instead of per deployment — a deployment with more than one active schedule trigger previously never matched its own streak and wrote a fresh ledger row every tick, forever. The Workers panel renders a "Skipped — <reason>" chip beside Deferred, derived server-side (skipped: null | { reason, since } on GET .../deployments). Spec 439 (RM-255): a declared external tool (authority.tools[], spec 388) now carries a required, closed, bounded input_schema — {type: 'object', properties?, required?}, one level deep, mirroring ToolInputSchema (act-gateway.ts) in field names only. validateExternalTools (schema.ts) refuses any entry missing it, naming the exact field path; {"type":"object"} with no properties is the valid, explicit "takes no arguments". compileAgentSolution refuses (400) a stored artifact whose declared tool lacks it — the defense-in-depth half, since schema.ts never re-validates a row already in product_artifacts. WorkerPackageManifest.tools.external_tools carries every declared tool verbatim, sorted by server then tool; the hermes shape's toolset.json carries the same array and SYSTEM.md gains a "## Declared external tools" section listing each tool's required arguments, read straight off the manifest. This is a correctness and legibility control, not a security boundary: Weftra does not validate an upstream server's arguments against it, and spec 388's classification/admission ladder is unchanged. Spec 444 inc-1 (RM-173) — "Weftra as a tool any agent calls": the worker read vocabulary (list_features, get_feature, read_evidence, list_escalations, list_recommendations) is now reachable directly at Weftra's existing Streamable-HTTP MCP front door (POST /api/mcp, spec 167 inc-2), with no package, no compile step, and no raw fa_… project key — an external runtime authenticates with a new, signed, expiring, revocable tool-access capability token (fa_wtc_-prefixed) instead. TOOL_CALLER_GRANTABLE_CAPABILITIES (src/middleware/auth.ts) is a new, closed, non-self-service constant (today just ['worker:read']); POST/GET/DELETE .../deployments/:deploymentId/tool-access mints/lists/revokes it, guarded byte-for-byte like the spec-381 inc-2 enrollment mint (admin/approver on the SOURCE project, NAMED — the shared ADMIN_API_KEY is refused). src/services/agent-factory/worker-vocabulary-mcp.ts builds the five tools as THIN WRAPPERS over the EXISTING episode-reads.ts functions — the same read surface the fa-act gateway serves an episode, never a second implementation — so a call through this door and the identical call through the internal gateway return byte-identical payloads by construction. Which of the five a token reaches is bounded the same way an episode's are: declaredReadTools (episode-reads.ts) composes the deployment's own declared authority.data_scopes ∩ capabilities ∩ prohibited — the three filters act-gateway.ts's toolNamesForStep already applies — so worker:read alone never widens what a deployment exposes, and the mint response's tools array names the real envelope at the moment of granting it. src/routes/mcp-http.ts resolves the deployment a presented token names ENTIRELY from the token's own signed facts, never a request parameter, and refuses (401) a retired OR PAUSED deployment — an operator pause silences this entrance exactly as it silences the internal gateway. The session this credential opens sees only those read tools and none of the project-key surface's other tools, and a project-key session is unaffected (the two sides are different IMPLEMENTATIONS rather than disjoint NAMES: the pre-existing spec-167 tenant surface has its own list_features tool, so that one name exists on both). read_evidence has no calling episode to default to at this door, so its episode_feature_id argument is REQUIRED here (optional everywhere else) — a shared, exported predicate (resolveDeploymentEpisodeFeatureId, episode-reads.ts) is the ONE place "does this episode belong to this deployment" is decided, called by both this door and the gateway's own existing check. Every call — allowed or refused — is recorded on actor_events (worker_tool_access.call, naming the token's jti/caller/tool/outcome, never a call argument or result), under a per-token call budget (120/min per jti) so a bearer holder cannot grow the hash-chained ledger at request rate; the mint and revoke additionally write worker_tool_access.mint/.revoke actor events naming the PERSON who acted (the genesis-feature run event carries only the coarse principal kind, because that ledger is tenant-readable). The token's subject is a namespaced tool_caller+<label>@… principal Weftra derives from the request's caller label, never the label verbatim, so a body string can never be recorded as another project's service principal. The Workers panel (public/workers.html) gains a Tool access panel beside the existing Mint enrollment token panel: list, mint (token shown once, with the tools it reaches), and revoke. Read-only, deployment-scoped: no writes, no acts, no stdio surface — those are later increments (RM-173's roadmap item). Spec 443 inc-58 (RM-285): the Workers and Product panels are usable at phone width (a single @media (max-width: 640px) block per page, every table stacking into a labelled row instead of clipping), and Open recommendations — the Accept/Reject a human actually owes — now leads the worker card, ahead of Escalations, with a live "N unanswered" count. Spec 444 inc-3 (RM-173) adds the EPISODE TRIGGER: a token EXPLICITLY granted worker:trigger_episode gets one more tool, trigger_episode { step_id } — the one verb with no fa-act gateway counterpart (an episode cannot fire an episode) — that starts exactly ONE governed episode through the same fireEpisode bounds an operator's own manual fire uses, deliberately outside every episode-authority ladder so no worker episode token can ever carry it. See "The episode trigger" below. Spec 443 inc-71 (RM-299 part 3b): the Workers tab moved onto the same shared, DOM-free public/project-selection.js picker inc-70 gave the Product canvas. A new admin-only GET /api/product/worker-deployment-counts (src/routes/product-artifact-counts.ts, alongside the existing /artifact-counts route — disjoint paths, no shadowing) returns, for every SOURCE project holding at least one ACTIVE (non-retired) worker deployment, {project_id, count} — one grouped SQL aggregate (countActiveWorkerDeploymentsBySourceProject, src/models/worker-deployments.ts), never a per-project loop, counts only. Default selection order: a listed ?project=, else the remembered choice (localStorage['fa_workers_project'], unchanged), else (when counts are known) the listed project with the most active deployments, else the first project declaring worker_repo_url (the tab's original default rule, kept as the fallback for when counts are unavailable — e.g. an approver session, since the count route is admin-only), else the first project. A project with none gets an explicit empty state, "No compiled workers for this project yet — N other projects have some.", built with createElement/textContent, before the existing compile guidance. A named administrator can bind an ordinary enrolled project of the same tenant to a deployment as its demo repository (PUT/DELETE .../deployments/:deploymentId/submit-target, recorded as worker_deployment.submit_target_bound/.submit_target_cleared); a worker's submit_feature act may then opt in with destination: "bound_repository" and the feature lands in that project under its own autonomy mode and gates. The call can never name the project, and the binding widens no capability.
Public surfaces:
public/index.html#data-tab:workersPOST /api/projects/:id/product/artifacts (kind: agent_solution | recommendation | draft_action)POST /api/projects/:id/product/agents/:lineageId/compilePOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/triggersPOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/episodesGET /api/projects/:id/product/agents/deploymentsGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentIdGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/episodesGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/episodes/:episodeFeatureId/activityGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/evaluationPOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/evaluateGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/evaluationsGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/evaluations/:runIdPOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/recommendations/:lineageId/respondPOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/promotePOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/demoteGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/promotion-eligibilityGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/open-draftsPOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/drafts/:draftLineageId/executePOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/drafts/:draftLineageId/discardPOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/packagesGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/packagesGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/packages/:packageIdGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/stateDELETE /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/statePOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/packages/:packageId/enrollPOST /api/worker/episodesPOST /api/worker/episodes/:id/completePOST /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/tool-accessGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/tool-accessDELETE /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId/tool-access/:jtiGET /api/product/worker-deployment-counts
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/HERMES_QUICKSTART.mddocs/design/agent-inputs.mddocs/WALKTHROUGH.md
Limitations: Increment 3 (spec 376, RM-154) is built: the four-rung autonomy ladder is closed, evidence-gated +1-only promotion and free/instant demotion (with 362 revocation) are live, draft_action is a human-executed artifact kind, and a level-3 (act_reversible) episode's act steps execute through the spec-348 MCP gateway under a per-episode, intersection-scoped, TTL-bound spec-362 token. Increment 3b (spec 384, RM-154b) closed the two gaps 376 shipped unreachable/unbuilt: act_reversible can now be reached through the ordinary named-approver route (a human-executed draft's evidence counts, tagged and split by source, without touching the min_effect_observed floor or the per-episode act cap), and the Workers panel (public/workers.html) now renders the ladder, live next-rung eligibility, Promote/Demote, and Execute/Discard. Increment 4 (spec 387 inc-1, RM-178) is built: EVERY level now gets the same gateway, with a level/step/data_scopes-filtered tool set — a shadow/advisory worker can read Weftra's own records and record evidence/ escalations/recommendations for the first time. Outstanding: episode completion is still diff-based, not evidence-based (spec 387 inc-2, not yet built — an observe episode that correctly changes nothing still fails the empty-commit guard); levels beyond act_reversible (broader/impactful/regulated execution) and external write connectors (RM-149) remain reserved/future work — see spec 376 §2.6/§4 and spec 387 §3 scope boundaries. ladder, live next-rung eligibility, Promote/Demote, and Execute/Discard. Outstanding: levels beyond act_reversible (broader/impactful/regulated execution) and external write connectors (RM-149) remain reserved/future work — see spec 376 §2.6/§4 scope boundaries. Increment 4 part 1 (spec 381 inc-1, RM-157) is built: a deployment can be exported as a deterministic, credential-free generic-json or hermes package. Outstanding (spec 381 inc-2/inc-3): the package enrollment token, redeeming it into a run-time capability token, external-episode intake and evidence-fidelity tiers, and the Workers panel's Export/Enroll UI. Spec 390 (RM-163) is built: authority.prohibited[], evaluateProhibitions, the fa-act gateway's list+call filtering and argument-level call gate, the external-tool classification-time check, prohibited_refusals in the evaluation, and the package's deny/argument_denies. Outstanding: nothing declared out of scope by spec 390 itself; the agent-solution-builder's own interview prohibitions[] (free text) is not yet mapped onto this closed field — see src/services/agent-builder/derive.ts's own not_yet_expressible note, which is separate work. Spec 407 (RM-134/RM-178) is built: an episode's CLI tool grant and prompt are now keyed to its step's action_class (fixed, non-widenable, replacing feature.allowed_tools rather than merging with it) instead of running as an ordinary open-ended implementation, and the tree is restored + the evidence-based completion decision always runs before an observe/recommend episode can ever reach a commit/push/PR path. episode_posture_mode: legacy is the explicit, ledger-recorded opt-out. deterministic, credential-free generic-json or hermes package. Increment 4 part 2 (spec 381 inc-2, RM-157) is built: a package enrollment token redeems into a per-episode capability token, an external episode runs through the SAME gateway/ledger an internal one uses, and its evidence is recorded by fidelity (observed from the gateway, claimed only from the completion report — never counted toward promotion). Outstanding (spec 381 inc-3): the Workers panel's Export/Enroll UI. Worker evaluation, increment 1 (spec 391 inc-1, RM-168) is built: evaluation.cases[] (closed, ≤ 100, six expectation kinds — evidence/recommendation/escalation/ refusal/no_state_cross/no_tool_call — Weftra ships no cases or fixtures of its own, §VII) and a runner (src/services/agent-factory/evaluation-cases/{runner,decide}.ts) that fires each declared case as an ORDINARY episode (trigger kind evaluation, base_branch = given.fixture_ref, given.inputs quoted DATA in a new ## Inputs spec-text section, given.state seeded into a run-scoped eval:<runId>: memory namespace wiped when the run ends) through the SAME fireEpisode seam and daily allowance every other trigger kind uses. POST .../evaluate answers 202 with a worker_evaluation_runs row that captures every version binding (spec/constitution hash, model, engine profile, skills, knowledge) BEFORE the first case fires; a tick driver (mirrors episode-scheduler.ts) advances one run one step per tick, deciding each terminal case with a PURE decideCase(expectation, ledgerFacts) — no model grades a case. I1: a case episode's tool set is pinned to the observe set plus propose_recommendation regardless of the deployment's live autonomy, via a Weftra-set marker on the feature's own episode_trigger_kind column — an act_reversible worker's case episode still lists no act-only tool and a draft_pr call is refused. GET .../evaluations / GET .../evaluations/:runId return aggregates and per-case reasons only — never a transcript, never a state value. Worker evaluation, increment 2 (spec 391 inc-2, RM-168) is built: an opt-in promotion_policy.require_evaluation_pass boolean (default false, part of the content hash) that checkEvaluationPassRequirement (src/services/agent-factory/promotion.ts) enforces as a SECOND, additional gate applied to a promotion to any rung — the LATEST complete worker_evaluation_runs row for the worker project must have been captured at bindings (spec_hash, constitution_hash, model, engine_profile, every bound skill/knowledge lineage's version+hash) that still equal the deployment's CURRENT ones (re-resolved live via src/services/agent-factory/evaluation-cases/bindings.ts, the same function the runner itself uses to capture a new run's bindings), with zero failing and zero inconclusive cases; refused 409 naming the missing verdict or the first mismatched binding field (I2). GET .../promotion-eligibility surfaces the identical check's answer as evaluation_requirement, so the preview can never disagree with the real promote call. adversarial is an ordinary class value decided by the same decideCase table as every other case (I3); the runner marks its verdict red_team: true purely so a reader can single it out. Weftra ships no cases of its own (§VII) — docs/AGENT_FACTORY_QUICKSTART.md §20a documents three canonical TEMPLATES to adapt (an injection planted in an input, a prohibited-tool invitation, a cross-subject state probe). The Workers panel (public/workers.html) gained an Evaluate button, the latest verdict beside the promotion-eligibility card with a per-field bindings-match ✓/✗, and a per-case pass/fail list — DOM APIs only, no innerHTML. Increment 5 part 1 (spec 418 inc-1, RM-214) is built: examples/agent-factory/hermes/ — a docker-compose.yml (this repo's Weftra image + the official pinned Hermes Agent image) and deploy.mjs, a dependency-free Node script that exports a package, mints an enrollment token, writes it to a runtime env file (mode 0600, never argv), starts the runtime, and watches Weftra's own ledger for enrollment-redeemed → gateway-observed-evidence → completion-report to land, exiting non-zero naming the first step that never happened. stub-runtime.mjs is the executable definition of "a runtime that can host this package" (read → redeem → call the gateway → report) — what CI exercises in place of a live Hermes. No new src/ route: every call is an existing spec 381 inc-1/inc-2 route. Increment 5 part 2 (spec 418 inc-2, RM-214) — a real episode ran on the official, unmodified nousresearch/hermes-agent:v2026.8.31 image (2026-09-09, episode 35e09221, 50 gateway_observed evidence notes, examples/agent-factory/hermes/RUN-2026-09-09.md) and exposed eight renderer/deployment gaps, all fixed here — "the renderer conforms to Hermes, never the other way round". shapes/hermes.ts now renders config.yaml (a FRAGMENT: one mcp_servers entry running the committed stdio bridge examples/agent-factory/hermes/fa-mcp-shim.py against ${FA_GATEWAY_URL} with ${FA_WORKER_EPISODE_TOKEN}, and a toolsets restriction to that one MCP server) in place of an mcp.json Hermes never read; manifest.json gained workflow.steps (id/action_class/description/escalate_when, verbatim from the compiled solution) so a runtime can be told which step to run without an operator-supplied side file; SYSTEM.md names the fa-gateway MCP server as the tool surface instead of a URL the runtime could never reach; manifest.tools.mcp_gateway_url now names the worker gateway's own invoke route (/api/internal/mcp-gateway/invoke) instead of the unrelated remote-MCP front door (/api/mcp); and docker-compose.yml's hermes service runs as the mounting uid (HERMES_UID/HERMES_GID) with HOME=/opt/data and mounts the operator's own credential file read-only, because the official image does not run as the mounting host user by default. The no-credential scan (scan.ts) now also accepts the one shape this needs — the token's own name as an env-var KEY introducing exactly its wrapped placeholder as the value — without loosening what counts as a leak. docs/WALKTHROUGH.md gained a "Deploy to Hermes" section (the credential file, deploy.mjs, the real-run start script, the Workers tab, and the honest boundary: what a runtime does outside the gateway is invisible to the ledger). Harness repair (spec 474, RM-350) is built: the real-run start script is now run-hermes.mjs — a dependency-free Node script, so it can be driven by the test suite with a fake docker — which reads FA_EPISODE_STEP_ID from the SAME runtime-env file deploy.mjs already wrote (no silent fallback step), merges the exported package's own config.yaml fragment via hermes-home.mjs's composeHermesConfig (the toolsets restriction Weftra exported, never widened — it changes only the two placeholders and the shim's path), and starts Hermes with no --yolo, no -t, no featureagent.instance label. deploy.mjs now defaults its start command to run-hermes.mjs (never docker compose up, which never redeemed anything) and fails the watch within one poll interval of a non-zero start-command exit rather than waiting out the full timeout (raised to 30 minutes by default). The compose hermes service and its Dockerfile are removed — a long-running gateway run service was never a one-episode runner, and a second start path was a second thing to keep true. Repo-less workers, increment 1 (spec 421 inc-1, RM-166) is built: an optional workspace: 'repo' | 'none' on agent_solution (default 'repo', byte-identical), refused at validation time when combined with an act step or worker:draft_pr/ worker:submit_feature (the two capabilities whose whole effect is a repository). Compiling a 'none' solution needs no worker_repo_url; the worker project is created with repo_url: '' and the new workspace_mode: 'none' column (resolveWorkspaceMode, fail-safe 'repo' for null/unknown, admin-only to set thereafter). A recompile that would change a lineage's settled workspace is 409. Every run path — feature intake, manual/schedule/webhook episode fire, the scheduler tick — refuses 'none' fail closed with a typed, ledgered reason naming inc-2; the Workers panel shows "Repository: none — evidence-only worker". Repo-less workers, increment 2 (spec 421 inc-2, RM-166) is built: the run path. implementFeature resolves repoLess once (resolveWorkspaceMode) and provisions an empty, disposable workspace (provisionRepolessWorkspace, no clone/.git/remote/git lfs pull; data_dirs/setup_command still apply) instead of cloning. No base identity, no base refresh, no VCS credential is ever resolved. Test/verify/declared gates are skipped for every step class (only observe/recommend can ever exist on a repo-less worker). After the agent's turn, restoreRepolessWorkspace (execution-dirt.ts) walks the workspace and deletes everything outside the step's declared output_paths — the repo-less equivalent of spec 407's tree restore, stronger because the declared baseline is EMPTY — and records episode_tree_restored with mode: 'workspace_none_empty_baseline'; a walk/delete failure fails the run. Completion is decided solely by decideEpisodeCompletion, unchanged; the commit/push/PR path is structurally unreachable (only observe/recommend can run here, and both always resolve through the evidence-completion branch or EpisodeNoEvidenceError) and carries a repoLess && throw at its top as a defense-in-depth assertion. branch_name is omitted (never a name for a branch that will never exist) and pr_url stays undefined (never null). POST /:id/create-pr and POST /:id/revise (and their /admin/ twins) now refuse 409 for a repo-less project's feature, before any VCS call. An ordinary (non-episode) feature submitted to a repo-less project is still refused 409, now with the permanent message ("accepts worker episodes only"). buildEpisodePrompt takes opts.workspace?: 'repo' | 'none'; 'none' rewrites the WORKSPACE CONFINEMENT paragraph (empty/disposable directory, no repo/branch/commit, every durable result through the gateway) and 'repo'/absent stays byte-identical to before (pinned). The Workers panel's episodes table gained a Branch/PR column (— for a repo-less episode, DOM APIs only); the dashboard hides Create-PR/Address-comments for a repo-less feature. Spec 422 inc-1 (RM-245) is built: FA_WORKER_ACT_MAX_CALLS_PER_EPISODE is now the operator CEILING over a per-episode budget COMPUTED from a step's optional declared budget: {calls_per_item, discovery_overhead, reporting_reserve} (defaults 2/4/8) times Weftra's own count of the deployment's source-project features (src/services/agent-factory/episode-budget.ts's resolveEpisodeBudget), clamped to the ceiling and recorded once per episode as episode_budget_set before its first call. The gateway's per-call admission check (act-gateway.ts:618) now runs TWO independent meters over the SAME durable rows it always summed: a gathering budget for everything but the three report tools, and a reporting reserve for write_evidence/ file_escalation/propose_recommendation alone, so an episode that exhausts its gathering budget still has its reserve — the exact Pilot 0 (2c331fc8) and first-real- Hermes-run failures this closes. A new refusal reason, episode_reporting_budget_exceeded, is excluded from episodes.refused_act/auto- demotion the same way its episode_call_cap_exceeded sibling is; either budget refusal carries a JSON-RPC error.data naming what remains. An episode with no recorded budget event falls back to today's flat-ceiling behavior unchanged. The episode's own rendered instructions now name its budget up front. Deferred to increment 2 (not built here): per-attempt scoping of the meters across a retry, and the Workers-tab consumption panel. Spec 477 inc-5a (RM-351 e) adds list_product_sources / get_product_source: a worker reads its SOURCE project's ingested product sources (latest text version per lineage, metadata-only list; one source's whole-body-redacted body in pages of at most 8,000 characters), enabled only by the fa:product_sources data scope on top of worker:read, on the fa-act gateway and the tool-access MCP door alike. The project comes off the deployment, never an argument. Spec 429 (RM-249) adds a read tool, list_recommendations: a recommend-class episode reads its OWN deployment's prior recommendations (listWorkerOutcomesForDeployment, scoped by deployment_id — no caller-supplied project/episode argument) so it never re-proposes a class a human already decided. Gated by the SAME worker:read capability every read uses, plus a new machine-checked fa:recommendations data-scope prefix; on a recommend step only, that scope is IMPLIED by a declared worker:propose_recommendation (toolNamesForStep's recommend branch extends the data scopes passed to observeSetToolNames for that branch alone — the capability intersection itself, onlyDeclared, is unchanged, and every other class still needs the scope declared explicitly). Each item names lineage_id/version/ title/summary (read off the recommendation artifact; a missing artifact yields nulls, never a throw), state, episode_feature_id, proposed_at, and decided_at (the outcome row's updated_at once decided, else null) — and WITHHOLDS the deciding operator's identity and any human-authored text (principal/human_action/note), spec 334's withholding discipline applied to a worker's own read. No schema change, no new route, no new capability. Spec 433 inc-1 (RM-219) is built: a declared trigger is a registered trigger. workflow.triggers[i] may now declare, at spec-write time, the SAME step_id (a non-act step) and cadence (the closed subset validateCadence already enforces) POST .../triggers accepts as a body — validated by REUSING that same function, never re-expressed, and refusing exactly the same shapes the route refuses (an unknown or act step, a webhook cadence, a manual step_id/cadence, a cadence with no step_id, a second webhook carrying a step_id), each a 400 naming the exact field path. An artifact declaring neither field validates and compiles exactly as before — silence stays silence. Compiling a solution that DID declare a step_id re-validates every declared trigger against the compiled workflow BEFORE the deployment row is created (a bad declaration fails the whole compile atomically — no deployment and no orphan trigger survive it) and then calls the SAME createWorkerTrigger the route calls, once per declared trigger, with createdBy a new reserved, non-human principal (WORKER_TRIGGER_COMPILE_PRINCIPAL = compile@featureagent.local, classified system and never disclosing an operator identity) — read by the SAME scheduler, under the SAME in-flight bound, daily cap, per-episode budget and autonomy ladder as a hand-armed trigger; an act step stays unreachable by a trigger in both paths. One worker_trigger_registered run event is recorded per registered row. GET .../triggers now reports armed_by: 'compile' | 'operator' per trigger (derived by exact comparison against the reserved principal; created_by_kind is unchanged). The Workers page marks each bound trigger with its armed by compile/armed by operator chip and renders "No active trigger can reach this step — it will never run." for a step no active trigger reaches (replacing the neutral "No triggers bound." for that case), so a step nothing can fire — the defect that let Pilot 0's recommend step run zero episodes across seven cycles — is visible rather than silent. Spec 444 inc-1 (RM-173) is built: the worker read vocabulary at Weftra's MCP front door, admitted by a tool-access capability token. Spec 444 inc-2 (RM-173) is built: the ESCALATION WRITE. TOOL_CALLER_GRANTABLE_CAPABILITIES widens by exactly one member, worker:file_escalation — Weftra's own canonical name for RM-173's escalate_work/report_blocker; no alias tool name exists anywhere in src/. Omitting capabilities on mint now yields worker:read ALONE (a deliberate change from inc-1: a write grant is explicit or it does not exist, never inherited silently on upgrade), and a requested worker:file_escalation is refused 400 when the DEPLOYMENT's own compiled agent_solution does not admit it — ONE predicate (deploymentAdmitsEscalationVerb) shared by the mint, the panel's checkbox and the door: the SAME ladder math (resolveWorkerActCapabilityScope) the episode-authority mint uses AND the SAME list-time isToolListedDespiteProhibitions check the fa-act gateway and inc-1's read path already run, both imported rather than restated. The door's canEscalate gate is re-derived from the LIVE deployment row on EVERY request (never cached on the token), so a demotion, a form-1/2 prohibition or a recompile that drops the verb removes the tool on the very next call with no revoke needed; an argument-shaped (form 3) prohibition cannot be decided from a listing, so it is evaluated per call against the REAL arguments before the write, refusing prohibited_act_refused — the same evaluateProhibitions call the episode gateway and the approver's Execute route make, so this door is no longer the one write path that skipped them (round-1 review finding). The write dispatches through the ONE existing writer (runReversibleAction, origin: 'tool_caller', a new third union member) — this module adds no escalation logic of its own, and the tool's name/description/schema are derived from the fa-act gateway's own WRITE_TOOL_DEFS.file_escalation (exported for this, the only other edit permitted in that file) rather than re-declared, so the row this door writes is deep-equal to what an episode's own call writes, modulo the fields that must diverge: blocking_claimed_by stays null even when the payload sets blocking: true (only an episode's own call ever claims that), the principal is the token's namespaced tool_caller+<label>@… subject, and originating_run_id/originating_actor_id stay null — there is no run. Most load-bearing: it is NEVER PROMOTION EVIDENCE. worker_act_outcomes.source gains 'tool_caller' (a TypeScript union member only — no migration, the column has no CHECK), and actEvidencePredicate flips from a deny-list to a closed allow-list, ACT_EVIDENCE_SOURCES = ['draft_execution'], behaviour-preserving on today's two sources — without this an external agent holding an escalate grant could have cleared min_effect_observed and unlocked a worker's own promotion by filing escalations at it. Inherited, not reinvented: the per-project open-escalation cap and the candidate-target dedup apply exactly as they do for an episode — with one origin-keyed narrowing, for the same reason as the fields above: a tool_caller repeat may RAISE the class of an open row only if it filed that row itself, so an outsider can never re-grade the worker's own escalation in place (it files its own row instead — the treatment a class-differing repeat against a human-filed row already gets); inc-1's per-jti call budget gains a stricter WRITE sub-budget in the SAME window (10/minute, tracked by a writeCount field on the existing callBudgetByJti entry — still the only module-level Map in the file). Every call, allowed or refused, records the existing worker_tool_access.call actor event; the escalation's problem text is never copied into it. public/workers.html's Tool access mint form gains capability selection (read-only default, an explicit "may file escalations" checkbox disabled with a stated reason when the deployment doesn't admit the verb) and the Workers-tab Escalations list now shows WHO filed each row — the worker's own episode, a person, an external tool caller, or an unattributed principal, classified POSITIVELY from the principal's own shape so an approver's Execute click is never reported as the machine's own judgement — still zero innerHTML. That list (and its triage PATCH) also reaches the escalations that have no episode at all: both non-episode origins key their row to the deployment's GENESIS feature, whose episode_deployment_id is NULL, so the spec-419 deployment bridge gained a second, equally-scoped leg — otherwise rows that consume the project's open-escalation cap would be neither visible nor triageable on the one surface built for triaging them. Spec 444 inc-3 (RM-173) is built: the EPISODE TRIGGER. TOOL_CALLER_GRANTABLE_CAPABILITIES widens by one more member, worker:trigger_episode — the ONE verb this door admits with NO fa-act gateway counterpart at all (an episode cannot fire an episode); it is deliberately outside every episode-authority ladder (in no REVERSIBLE_CAPABILITIES/ WORKER_OBSERVE_CAPABILITIES/level set), so no worker episode token can ever carry it. A requested grant is refused 400 when the deployment's compiled agent_solution declares no workflow step at all — the only deployment-side fact available for this verb, since (unlike file_escalation) it is never a member of a solution's declared authority.capabilities. The door's canTriggerEpisode gate re-derives the token's grant AND the deployment's live declared prohibitions (form 1/2) on EVERY request, same discipline as canEscalate, with deliberately no ladder/scope term (there is none to re-check). The registered tool's input schema is EXACTLY { step_id: string }, .strict() so any other property is refused BY NAME by the MCP SDK's own schema layer before the handler runs — payload/trigger_ref/ base_branch/inputs/episode_of/a budget field never reach it. Firing dispatches through the EXACT SAME fireEpisode function (src/services/agent-factory/episodes.ts) an operator's own manual fire calls — this door adds NO trigger logic of its own and re-implements NONE of its bounds (one in flight, the daily cap, live-workflow-step, act-step-level refusal, the in-transaction re-check); a refusal carries fireEpisode's own status and message verbatim. episode_trigger_kind is recorded 'manual' (the closed column's spec-381-inc-2 precedent — no schema change) and episode_trigger_ref is tool_caller:<jti>, Weftra-derived from the token, never a person's name or email. A worker_episode.fired actor event is recorded with the token's namespaced principal and origin: 'tool_caller'. Because the trigger-kind column alone would read as a human action, GET .../deployments/:deploymentId/episodes additionally returns fired_by ('tool_caller' / 'schedule' / 'webhook' / 'evaluation' / 'other', POSITIVELY classified from that episode's own worker_episode.fired actor event, one BATCHED query per page — never one per row) and fired_by_caller; public/workers.html renders a tool-caller-fired row as external tool caller · <label> in the episodes table's trigger cell instead of the plain manual an approver would otherwise misread as a person. Each call consumes both the call budget and the write sub-budget, exactly like the escalation write. Outstanding (inc-4, queued, not started): write_evidence (deferred again, explicitly — episode-scoped by construction, no run to attach a third party's notes to), submit_feature, propose_recommendation, and whether the stdio adapter (src/tools/mcp-server.ts) ever carries this vocabulary at all. Spec 476 inc-1 (RM-359 a-c), found on the 2026-10-09 live Hermes run: the exported tool restriction did not apply because shapes/hermes.ts wrote a top-level toolsets key, which nothing in a Hermes CLI run reads — fixed by rendering platform_toolsets.cli: ["fa-gateway"] instead. The key and the bare mcp_servers name were NOT checked against upstream source in this increment (hermes_cli/tools_config.py at the pinned tag could not be fetched from the build sandbox) — they stay unverified until the live run. hermes-home.mjs's composeHermesConfig now requires platform_toolsets: in the fragment. The reference harness (run-hermes.mjs) now starts the Hermes container on a per-run --internal Docker network with no route off the Docker host of its own (created once per run, its name and the proxy's carrying a random per-run suffix), fronted by a new dependency-free allowlist proxy (egress-proxy.mjs: CONNECT and plain-HTTP, host:port only, one log line per allow/deny decision, saved to egress.log in the run directory before the proxy is removed) that is itself connected to the ordinary bridge network as the one route off the host — the allowlist is the gateway's own address, the model provider's (api.anthropic.com:443 for anthropic; any other provider needs HERMES_EGRESS_ALLOW, or the run fails at a named step before any container starts) and any HERMES_EGRESS_ALLOW entry the operator adds. --internal does not, on many Docker engine versions, stop the container reaching ports the Docker host itself listens on; nothing in the harness blocks that path. run-hermes.mjs also now exits non-zero whenever the completion response's outcome is not completed (including a missing field), so deploy.mjs's watch loop fails within one poll interval instead of waiting out its full timeout on a no_evidence run — the second live-run symptom this closes. None of this has been confirmed against an installed Hermes binary yet; the operator's post-merge check is documented in docs/HERMES_QUICKSTART.md. Spec 477 inc-1 (RM-351 a) is built, PARTIAL: a third package shape, openclaw, carries the same governed rendering (authority, prohibitions, the gateway-only tool surface, the ledger) to a second external runtime — AGENTS.md, openclaw.json (a fragment naming the gateway as the only MCP server, the sandbox on, its tool policy's allow side limited to the gateway's own tools, both placeholders literal), OPENCLAW-README.md, and the same toolset.json/memory-policy.json/schedule/* the hermes shape emits for the same manifest (pinned byte-identical). buildWorkerPackage's dispatch is now exhaustive (generic-json / hermes / openclaw, a never-typed throwing default). Nobody has run this against a real OpenClaw — the configuration key names are UNCONFIRMED, stated as such in the renderer's own constants block, in the package's own OPENCLAW-README.md, and in docs/USER_GUIDE.md. Spec 477 inc-2 (RM-351 b) is built: a reference harness, examples/agent-factory/openclaw/, runs an exported openclaw package in a container through one tested start path (run-openclaw.mjs, with a pure composeOpenClawConfig), sharing its docker and egress helpers with the Hermes harness. CI proves the configuration OpenClaw is started with carries only the exported tool surface; it never runs a real OpenClaw, so the key names stay UNCONFIRMED. Spec 477 inc-3 (RM-351 c), the messaging channel act, is built (tests/spec-477-inc-3-worker-send-message.test.ts). Outstanding (not started): a live run that confirms or corrects those key names, the model/provider setting (RM-351 d), and the sales-demo quickstart (RM-351 e). Spec 476 inc-2 (RM-359 d) is built: an external episode's completion report (POST /api/worker/episodes/:id/complete) accepts one more optional field, usage (model/provider/input_tokens/output_tokens/cost_usd, every key typed and bounded, strings redacted) — what the external runtime itself says it spent, recorded as ONE episode_runtime_usage_reported run event, fidelity: 'claimed', the same claimed- evidence discipline episode_claimed_evidence already carries: never checked against anything, never changing outcome, never added to total_cost_usd/a token column/the usage rollup/any promotion input. An unknown key, out-of-range value, non-object, or empty usage is a 400 naming the field, nothing recorded. The episodes list route (GET .../deployments/:deploymentId/episodes) gains runtime ('external'/'internal', from features.episode_runtime) and runtime_reported_usage (that event's validated keys plus reported_at, or null) per row — cost_usd keeps meaning Weftra-measured, untouched. A new read route, GET .../episodes/:episodeFeatureId/activity (requireAdminOrApproverForProject, same guard/bridge as the list beside it; a project key is refused, and an episode id of a different deployment 404s), returns one episode's run events through ONE exported allowlist of event type → returned fields (EPISODE_ACTIVITY_EVENT_FIELDS, src/routes/agent-factory-episodes.ts) — mcp_gateway_call (server/method/tool/allowed/reason/class/level, deliberately never args), worker_act_evidence_written (reusing read_evidence's own note mapping), episode_claimed_evidence, episode_runtime_usage_reported, episode_completed, and the run-failure event — each item carrying seq/at/type/fidelity ('observed'/'claimed'), oldest first, paged, working identically for an internal or external episode. public/workers.html's episodes table (DOM APIs only): an external row shows — for measured cost and, when usage was reported, a runtime-reported: ... line with a disclaiming tooltip; the Logs link is shown only for an internal episode (an external one has no Weftra-run job log at all); every row gets an Activity control opening a paged panel of the new route's items, with a visible claimed mark.
Agent Factory Runnable Walkthroughs
Status: 🟢 SHIPPED Audience: Operator
Spec 417 inc-1 (RM-213) — the Agent Factory quickstart (§14–§17) as a script CI executes instead of a page of curl commands that rots the moment a route or field changes. examples/agent-factory/ holds the Phase-0 GOVERNANCE pilot (docs/design/agent-factory-path-to-pilot.md §2 — dummy realtor fixtures, Weftra as the stand-in runtime, proves the governance loop and nothing about an agent core): fixtures/*.json (an outcome, a use_case, a story, and an agent_solution with an observe step and a recommend step, one authority.prohibited[] entry, one evaluation.success_measures[] entry, and a memory policy — all dummy data, no real customer or repository named anywhere in the directory) and run-governance-pilot.mjs (dependency-free Node, fetch only; reads FA_BASE_URL/ FA_ADMIN_KEY/FA_WORKER_REPO_URL from the environment ONLY, never argv; exits 2 with a usage line and never echoes the key when FA_ADMIN_KEY is unset) — creates a source project, submits the fixture chain through the product store's one door (outcome → use_case → story → agent_solution, each proposed ready), compiles a shadow deployment, arms an hourly schedule trigger, fires one episode via POST …/episodes, polls until it ends, prints its evidence (worker_episode_started, then episode_completed or a run_failure cause), reads the evaluation and promotion-eligibility, and responds to the recommendation the episode produced — exiting non-zero if any step's expected evidence is missing. tests/examples-agent-factory-walkthrough.test.ts runs the identical step sequence in-process (supertest against createApp(), an in-memory database, the engine mocked exactly as tests/agent-factory-episodes.test.ts mocks it — no real model call, no real sandbox) so the walkthrough cannot rot silently: a renamed or removed route fails this file with the step's name in the assertion (demonstrated by the file itself, deliberately hitting a nonexistent path inside the same step-labeled runner the live script uses). The "episode ends" step writes the same evidence-based- completion ledger rows implementFeature itself writes (recordRunEvent(..., 'episode_completed', ...), updateFeatureStatus(..., 'implemented')) directly, standing in for the async agent-engine run a plain createApp() test harness never starts — full sandboxed-episode-execution coverage stays in tests/agent-factory-episodes.test.ts/tests/spec-387-inc2-*.test.ts/ tests/spec-407-episode-posture.test.ts. docs/AGENT_FACTORY_QUICKSTART.md §14/§16/§17 each carry a one-line pointer at the top to the runnable form. No new route, field or capability — every request the script makes is one the quickstart already documents. Increment 2 (spec 417 §3, RM-213/RM-134) adds the CODE pilot: run-pilot-0.mjs, the same shape of script for docs/design/agent-factory-path-to-pilot.md §8 G0 — Weftra's own Retrospective worker, a real agent_solution on Weftra's own project (not dummy data), already authored in pilots/pilot-0-retrospective/. It probes FA_HELPER_CALLBACK_BASE_URL/health first (the sandbox-facing listener RM-176/spec 413 added) and names the FA_CONTAINER_BIND fix when unreachable, resolves Weftra's own project from FA_SELF_PROJECT_ID (never guessed — which project IS Weftra is operational data, not something the platform may hard-code per CLAUDE.md's project-agnostic Core Law), finds the existing Pilot 0 lineage and recompiles it (RM-215 reuses the lineage's worker project rather than minting a second one), arms the hourly watch_verdicts schedule trigger, fires that step's episode immediately instead of waiting an hour, waits for it to end, and asserts implemented with an episode_completed event, at least one evidence note, zero pushes, and that the episode appears on the deployment's episodes endpoint (spec 414 inc-1, the Workers tab). A run_failure whose cause names a clone/branch error is recognized and named as RM-217 (a known, separately-tracked compile-then-clone gap) rather than left as an opaque failure — this script does not fix RM-217. Default mode prints a dry description of every step and touches nothing; --live is what an operator runs to produce RM-134's evidence, attached to docs/design/fa-roadmap.md by the operator, never generated by the script itself. tests/examples-agent-factory-walkthrough.test.ts gained a matching in-process case that loads the real pilots/pilot-0-retrospective/agent-solution.json artifact (its actual four-step shape: watch_verdicts/grade_learnings observe, propose_candidates recommend, propose_improvement act) and drives it through the same declare → ready → compile → arm → fire → evidence → Workers-tab-listing sequence. docs/WALKTHROUGH.md gained an "Agent Factory" chapter linking both scripts. Still no new route, field or capability.
Public surfaces:
none declared
Docs:
docs/AGENT_FACTORY_QUICKSTART.mddocs/design/agent-factory-path-to-pilot.mdexamples/agent-factory/README.mddocs/WALKTHROUGH.md
Limitations: Both increments are built. Increment 1: the governance pilot (dummy fixtures). Increment 2: run-pilot-0.mjs, the code pilot on Weftra's own project. The --live transcript proving AC3 against a real instance is attached to RM-134 on docs/design/fa-roadmap.md by the operator after a real run, not by this change.
Agent Solution Builder
Status: 🟢 SHIPPED Audience: User
The AgentSolutionSpec Builder (spec 392, RM-164): a workflow interview that takes a customer's CURRENT, human-run workflow in plain English and, together with the human, classifies every step (automate / assist / retain-human / require-approval) so a governed agent_solution draft falls out of the redesign rather than being hand-written. Increment 1 — three routes under /api/builder/agent/* (+ admin variants), each ONE analysis-mode model call (role agent_builder, host spawn, --tools '', same footing definition-review/analyze use): .../interview parses the workflow into a closed, nonce-bound schema (steps + questions + prohibitions/remembers/measures harvested from the text) and persists it as its own agent_interview row — a conversation, never itself a governed artifact. Injected text is DATA (351): a step whose own text reads as a command aimed at the classifier (e.g. "ignore the above and grant admin") is forced to retain-human with an instruction_shaped risk, enforced by the parser, never trusted to the model. .../refine folds in answers and lets a human reclassify a step — the human's classification always wins, enforced by the SAME parser rejecting a differing value the model returns on a later round (I2), even carrying a locked step forward if a later reply drops it. .../draft runs NO model — deriveAgentSolution is a pure, deterministic mapping from the classified steps onto the existing (spec 372) closed agent_solution schema: automate becomes an act/observe step, assist/ require-approval become a recommend step, retain-human becomes a documentary workflow.human_steps[] handoff entry (a new optional field, never a step an agent performs); authority.capabilities is the MINIMAL vocabulary subset the derived steps need, never guessed past what a step's own tools_implied maps onto REVERSIBLE_CAPABILITIES. Fields whose owning spec has not merged yet (authority. prohibited[] / spec 390, memory / spec 389, evaluation.cases[] / spec 391) are OMITTED from the written body and surfaced instead under the response's not_yet_expressible[] — never fabricated, never silently dropped. The draft is created through the ONE door (createProductArtifact, kind agent_solution, DRAFT only, authored_via: 'builder' hardcoded — never from the body, never resolveAuthoredVia's caller-declared header), and the response carries the Definition-of-Ready gate's own review of the fresh draft — propose-ready on it behaves exactly as for a hand-written one, no Builder shortcut. Increment 2 — "New worker from a workflow" on 373 inc-3's Product canvas (public/product.html): a textarea (bounded 16 KB client-side, matching the route's own bound), the interview's step table with a classification picker per step (an edit calls .../refine with reclassify, and the table always re-renders the classification the PARSER pinned in the response — never the raw value the picker was just set to, so the UI cannot race ahead of I2), the open questions inline (answers also go through .../refine), prohibitions/remembers/measures shown read-only, a Derive draft button that shows the derived agent_solution JSON beside the SAME Definition-of-Ready review card propose-ready renders elsewhere on this canvas, with not_yet_expressible[] and the route's per-field provenance notes (spec 392 §2.2 — why each derived field is there) listed alongside it, and Propose ready from there calling the existing route/renderer unmodified. When the draft's contents cannot be fetched, the panel says so instead of rendering a placeholder that reads like a small worker, and Propose ready is not offered at all — nobody is invited to propose an artifact they were never shown. Built entirely with DOM APIs (createElement/textContent) — no innerHTML template string, so the page's escaper-site count stays at 0.
Public surfaces:
POST /api/builder/agent/interviewPOST /api/builder/agent/interview/:id/refinePOST /api/builder/agent/interview/:id/draftPOST /api/builder/admin/agent/interviewPOST /api/builder/admin/agent/interview/:id/refinePOST /api/builder/admin/agent/interview/:id/draft
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/design/agent-inputs.md
Identity Require Proven Vcs Handle
Status: 🟢 SHIPPED Audience: Operator
Spec 363 (Weftra Identity & Access, RM-127), increment 6: after inc-3/inc-4, a pending/staged-turned-legacy or users.vcs_login-mirror handle still authorized @<bot> merge, mention dispatch, and merger attribution the moment it resolved — nothing yet REFUSED an unproven handle. Inc-6 adds REQUIRE_PROVEN_VCS_HANDLE, an operator-only, env-only, default-OFF switch read once at startup (config.requireProvenVcsHandle, src/config.ts — only true/1, trimmed and case-insensitive, arm it; anything else, including unset, is off, fail-safe). Not a runtime setting (absent from PUT /api/admin/settings/runtime's editable allowlist) and not a project/feature field (absent from SELF_CONFIGURABLE_PROJECT_FIELDS) — a project key has no path to it. Enforcement is at the ONE chokepoint, applyProofRequirement (src/models/principal-vcs-identities.ts), applied to the VALUE resolveProvenWins returns in both getUserByVcsIdentity and getUserByHandleForScope (and findUsersByHandleForScope filters its whole result list the same way): when the flag is on and the winning principal's grouped candidate is not proven (any matching row oauth_proven), the answer becomes null. Applied AFTER resolution, never by filtering the candidate set first — filtering first would blind inc-3's CONTEST rule to unproven rows, a LOOSENING. The invariant: result(ON) is always result(OFF) or null, never a different non-null answer (stricter-only). Because a legacy row (users.vcs_login mirror) can never be oauth_proven — its forge is not a real OAuth provider — every legacy-only approver stops authorizing under the flag until a real forge/host handle is declared and proven for them. The four existing authorizing consumers need no code change: getProjectApproverByVcsHandle (src/models/users.ts, used by @<bot> merge in src/services/flows/weftra-merge.ts, mention dispatch in src/services/mentions.ts, and inbound PR-review-trigger dispatch in src/routes/triggers.ts) and resolveMergerActor (src/services/feature-actors.ts, spec 274 merger attribution — an unproven merger resolves unmapped, composing with separation_policy's require_proven SoD level with zero changes to separation-policy.ts). The two INFORMATIONAL readers of the same table — resolves_to_user_id on the admin declare response and on listPrincipalVcsIdentitiesView — deliberately read the link holder with the flag ignored (resolveVcsIdentityForScope(...).linked), so a rival claim stays visible to the admin who just staged over it. That unmapped has a cost the runbook states plainly: it also makes spec 274's merger/submitter comparison indeterminate, so no merge_actor_collision row is written for a merge the flag refused — on the VCS-host detection path the only artifact a self-merge leaves. So the suppression is recorded per-act instead: feature_actors_resolved carries merger_proof_withheld: true exactly when the handle DID link to a principal and the proof requirement is what emptied it (resolveVcsIdentityForScope; the key is absent, not false, otherwise, and it is withheld from a project-key ledger read), and docs/OPERATIONS.md §4j-2's detection rule gains a third limb that alerts on it. Refusals are legible, not silent: the existing mention_unauthorized/mention_authorized run event, the weftra_merge_refused (unauthorized) ledger row, and the pr_review_runs unauthorized refusal each gain proof_required: config.requireProvenVcsHandle (folded into the error text for pr_review_runs, which has no metadata column). No new writer of proof, no schema change — this increment adds a read-time gate only. GET /api/admin/vcs-proof-readiness (requireAdminAuth) is the readiness view an operator needs BEFORE arming the flag, in TWO halves because the flag governs two resolvers with different reach: approvers/ would_lose_authority (rows in project_approvers, matched forge+host+legacy like loadScopedCandidates) and attribution/would_lose_attribution (every principal holding a row matching a project's own forge+host, approver or not, because resolveMergerActor resolves role-independently — without it an operator could read would_lose_authority: 0 and still lose attribution on arming). ready is computed per HANDLE STRING (the unit applyProofRequirement refuses) with a link ready only when every one of its handles is, and over-estimates loss rather than under-estimating it; the view is read-only with no ledger row. The dashboard's Settings tab renders it as a "Proven-handle enforcement" panel (Enforced/ Not-enforced badge, both summary counts, a table of not-ready links from both halves) built with DOM APIs only (createElement/textContent), so the spec-259 escaping pins do not move.
Public surfaces:
GET /api/admin/vcs-proof-readinesspublic/index.html#on:loadVcsProofReadinesspublic/index.html#on:renderVcsProofReadiness
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/AGENT_FACTORY_QUICKSTART.mddocs/SETUP.md
Evidence Pack
Status: 🟢 SHIPPED Audience: User
The Evidence Pack, Increment 1 (spec 403, RM-192): a canonical, Ed25519-signed statement per feature binding head SHA -> spec hash -> approvals -> verdicts -> gates -> containment -> cost, assembled entirely from the run-event ledger and the feature/spec/checkpoint records — nothing recomputed, nothing accepted from a request. Signature is Ed25519(root_key, sha256(canonical_json(statement))), with key_id = sha256(public_key)[:16]; the public key is published (unauthenticated, rate-limited) at GET /api/instance/verification-key since a verifier may hold no Weftra credential at all. GET /api/features/:id/evidence-pack returns {statement, signature, key_id} (?format=markdown for the human page); scope is a project key to its own feature (404 otherwise), an approver to its linked projects (403 otherwise), an admin unrestricted. POST .../evidence-pack/verify cryptographically checks a caller-held pack's signature against the published key (never a re-derive) and records a bounded mismatch. The pack is tenant-safe by construction by default — tenant-view ledger seq, no blocking count, separation verdicts folded to the tenant-safe set, containment from the run's own config snapshot with operator defaults withheld; an admin caller of the GET route alone receives the operator form, which names the configured separation level, and the statement says which audience it was built for. Approval identities are spec-334 withheld-safe classifications (named/service/unverified), never an email — the pack is a portable, signable artifact, so it carries no PII regardless of who is asking. On merged, the pack is captured as a feature artifact (evidence-pack.json, source fa_capture), best-effort, alongside the existing spec-360 checkpoint capture. Increment 2 (spec 403 §2) posts this same pack as a verifiable PR check and adds the standalone verifier — see the provenance-check capability below. Spec 443 inc-10 (RM-262 o): verdicts.code_review[] gains stale: boolean|null — a pure projection of that entry's own head_sha (non-null only when its round proved the binding) against the pack's own head_sha, never a fresh read; null when the entry carries no head_sha at all (unknowable, never reported false). Spec 443 inc-25 (RM-282): the statement gains an OPTIONAL declared_check_runs array — one entry per engine_pass_tool_summary row with a non-zero count of the agent's own declared-check invocations, each naming the pass, the field (test_command/verify_command), the count, and the event_id it was read from — never the command text. Omitted entirely when every count is zero (the diff_fingerprint? precedent), so a feature with nothing counted produces a statement, digest, and signature byte-identical to before this increment; v stays 1. Spec 443 inc-30 (RM-269): three additive honesty gaps closed. (1) statement.branch — OPTIONAL {head_sha, observed_at, event_id, ahead_of_pack}, read from the newest pr_checks_observed ledger event (the spec-402 push poll) that carries a string head_sha — a signed, event-id-bearing, ZERO-EGRESS fact Weftra already records, never a live provider read (no VCS/provider import in evidence-pack.ts; buildEvidencePackStatement stays synchronous). Says whether the last observation Weftra WROTE DOWN names a commit other than statement.head_sha (a CHECKPOINT, written only for Weftra's own commits) — a signal the pack previously had no field for at all. It is an observation with a timestamp, NOT a currency claim: Weftra appends that row only when the PR's check SET changes, so a match is never proof the branch has stayed put, and both the field docstring and the markdown Branch line say so. Omitted entirely when no such observation exists — the KEY is absent, not null, so the branch BLOCK contributes NO BYTES to such a feature's statement, digest or signature. Read that as this block's own contribution, NOT as a claim about the increment: the same increment's verdicts.security[*].stale does change the statement bytes — and therefore the digest and signature — of any feature carrying a security_verdict row. v stays 1 either way (both additions are additive). (2) stale now exists on every verdict class, via one shared helper (resolveStaleAgainstStatementHead): verdicts.security[*].stale follows the exact same rule code_review has since inc-10, and a new OPTIONAL verdicts.repro[] (one entry per repro_review_verdict row, {verdict, head_sha, stale, exit_code, baseline_exit_code, event_id}, omitted when there are none) gives the SHA-keyed repro gate the same honesty. (3) the round's conformance_verdict ledger event now carries the SAME top-level head_sha/ head_bound pair its sibling reviewer_verdict already reported (one resolution per round, no new fetch) — distinct from, and coexisting with, the evidence-CLASS block's OWN always-unbound evidence.head_sha/.head_bound (spec 415). Spec 470 inc-1 (RM-344): the dashboard gained a collapsed, admin/approver-only Evidence section on the feature detail view (public/evidence-pack-view.js) that reads the pack only when opened, renders its markdown as plain text (never innerHTML), verifies a held envelope's signature through the existing verify route, and downloads the signed envelope as JSON — presentation only; the routes' own scope (resolveEvidencePackFeatureScope) is unchanged, and no route/guard/scope/rate-limit changed. Spec 470 inc-2 (RM-344 a): the section's feature id, head commit and signing key id are each copyable, and it lists the curl commands to re-check the pack outside the dashboard with their own Copy controls — every command is built by a pure, UUID/origin-validated function and names the credential only as the literal $WEFTRA_API_KEY, never a real key.
Public surfaces:
GET /api/features/:id/evidence-packPOST /api/features/:id/evidence-pack/verifyGET /api/instance/verification-key
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/WALKTHROUGH.md
Limitations: Spec 472 inc-1 (RM-348 a): approvals[].separation reads satisfied only for a verified, NAMED approver — an approval made with the shared admin key, or any other credential that does not resolve to a named person, always reads not_asserted, regardless of the separation policy level or a waiver. Stored packs are not rewritten. Spec 472 inc-3 (RM-348 c): each approvals[] entry also carries submitter_kind (named|service|role|unmapped|absent) and configured_level, and separation widens to five verdicts, first match wins: not_asserted (approver not a named person), indeterminate (submitter not a named person, so no comparison is possible), self_approved (same named principal), not_evaluated (policy level off), satisfied. The statement carries a signed top-level audience. The tenant form (project keys, approver-role users, the artifact writer, the provenance check, and any caller without an explicit audience) emits configured_level: null and folds self_approved/not_evaluated into not_asserted; only an admin caller of GET /:id/evidence-pack receives the operator form. The verify route re-derives in the held statement's audience. Recorded, not refused: no approval route changed.
Provenance Check
Status: 🟢 SHIPPED Audience: User
The Evidence Pack, Increment 2 (spec 403 §2, RM-192): posts the evidence pack as a verifiable PR check (${botHandle}/provenance — never a literal brand string) on every head Weftra observes, plus a standalone verifier. Posting mechanism per provider (VcsProvider.postCheck): a GitHub check-run whose output.summary carries the pack's full human page and output.text a fenced JSON envelope ({v, statement, signature, key_id}) — self-verifying from the check alone; a GitLab commit status or Bitbucket build status, both short-description-only, linking back to the evidence-pack page (full verification for these two goes through the statement.json / GET /api/verify path instead). Conclusion is success or neutral — NEVER failure (I5): success requires ALL THREE — the pack's own head_sha matches the checked head, every verdict recorded against that exact head is a pass, AND (spec 443 inc-5, RM-262 k) Weftra holds an established host-anchored verification anchor for that exact commit (src/services/verification-anchor.ts's resolveVerificationAnchorState) — a declared verification command actually ran and exited 0 for this commit, and the commit carries the host-written evidence bytes; a verdict for a different (or unrecorded) head is excluded, never scored as a failure. Spec 443 inc-10 (RM-262 o): a code-review (reviewer_verdict) entry now participates once its round proved a head binding — an APPROVE at the checked head can contribute to success exactly like a security PASS; a non-APPROVE at the checked head yields neutral/ verdict_not_pass instead of being silently excluded; a verdict at any other head, or with no binding, is still excluded and never scored as a failure. Every conclusion names a reason (verified | pack_head_mismatch | verdict_not_pass | verification_anchor_cleared | verification_anchor_absent), fixed precedence, first match wins — visible in the check's own title, in a short labelled unsigned section prefixed onto the summary ("Verification anchor — host ledger, NOT part of the signed statement below", <= 600 chars, never touching the signed envelope), and on the provenance_check_posted run event alongside verification_anchor. This closes the QMD pilot regression where a commit whose anchor was CLEARED (evidence artifact excluded from the commit) still read success/"verified" — a false safety claim. Dedup is now the quadruple (head_sha, conclusion, content_fingerprint, reason); a legacy event with no reason re-posts once rather than matching silently. The content_fingerprint component deliberately excludes the ledger's own seq range, since posting the check appends its own ledger event and a naive full-statement hash would never reach a fixed point. Spec 402's CI-check classifier already recognizes any ${botHandle}/* check as policy (never remediated) — no change needed there. Posted on the spec-402 push poll and on Weftra's own pushes (the implement / implement-spec-kit flows' PR-open step). GET /api/verify (with a statement=… query param) is a PUBLIC (no auth), rate-limited route that answers the SIGNATURE question only — {valid, reason?, key_known} computed from the caller's own bytes plus the published keys, with no feature lookup and no ledger read, so its answer is identical whether the named feature is live here, belongs to another tenant or never existed. Staleness is answered only where the requester can be scoped: POST /api/features/:id/evidence-pack/verify returns current_last_seq. npx weftra verify <pr_url|statement.json> (src/tools/verify.ts, host-side, full local DB access like diagnose.ts) verifies a local file directly, or looks up a PR url in this instance's own database and extracts the envelope from its posted check through the project's own VCS credential — deliberately not a generic internet-fetching tool. On a PR url it is HEAD-BOUND: the envelope's own head_sha must equal the head the PR is on now, every check carrying the name is considered rather than the first, and it reports an attestation block (attested head, PR head, ledger positions, and a re-derive of the current pack) so a valid: true cannot be read without seeing what it is true about. Key rotation follows spec 362: a pack (and a check's embedded envelope) signed by a since-retired key still verifies via its own key_id. Increment 7 (spec 443, RM-262 l): posting the check can FAIL — a GitHub personal access token gets a 403 from the Checks API, which is App-only — and this used to be a bare {posted: false}, never reaching the PR and never telling the operator why. PostCheckResult now carries via (check_run|commit_status|build_status|none) and, on failure, a closed PostCheckFailureReason (no_credential|unsupported_url| credential_lacks_check_write|no_affirmable_state|not_found|unauthorized| rate_limited|provider_error|status_unprocessable|unclassified) — never provider/exception text. Spec 478 inc-1 (RM-362 a-c) bounds what a stable head on a credential that cannot write check-runs actually costs: the credential_lacks_check_write denial is remembered per credential (vcs_check_write_capability, re-probed on an interval or immediately on rotation) so checks.create is not repeated every poll, the status fallback is skipped entirely when the status it would send is byte-identical to the one Weftra last actually posted for the head (a fingerprint comparison against Weftra's own ledger, never the provider), and a fallback refused outright with GitHub's 422 (status_unprocessable) gets no further status attempt for that head, ever — though the check-run probe is unaffected and still upgrades the delivery once a credential gains check-write. classifyPostCheckFailure reads HTTP status and rate-limit HEADERS only. On GitHub, credential_lacks_check_write (a PAT) falls back ONCE to a commit status — and only for a success conclusion: GitHub has no truthful non-affirming status state, so a neutral conclusion with a lacks-write credential posts NOTHING (via: 'none', reason: 'no_affirmable_state') rather than a false green/red/pending. Because a commit/build status has no long-form body, postProvenanceCheck now also posts the signed envelope as a single PR comment whenever via !== 'check_run' (bounded to one comment per head, best-effort, never throws) — so the envelope always reaches the PR, not just when GitHub's Checks API is writable. The provenance_check_posted ledger event gains via/failure_reason/ envelope_comment_posted; the dedup key grows to five fields (adding via) so a credential that later gains check-write is retried instead of suppressed forever. Both provenance routes (GET /api/features/:id/provenance and the admin equivalent) serve a provenance_check block built entirely from closed enums/SHAs/booleans/timestamps — tenant-safe by construction, since a PostCheckFailureReason cannot carry provider text. The dashboard's Provenance panel renders a delivery row for it, DOM-built. That envelope comment is Weftra's OWN writing and Weftra never reads it back as review feedback: it leads with an HMAC-signed fa:provenance-envelope marker, and all three sites that read a PR thread back (the revise flow, the merge poll's comment indicator, and mention confirmation) go through one composed filter, excludeFaAuthoredComments — without which a /revise with zero human feedback would run a full paid agent round on Weftra's own envelope, and the dashboard's "PR has review comments" indicator would pin on for the life of every PR. Selection follows the spec-339 narration contract: marker at the HEAD of the body, signature verified under a server-side key, so quoting the marker cannot suppress anyone's comment; a filtered comment is recorded (provenance_envelope_filtered). Spec 470 inc-1 (RM-344 c): detailsUrl now opens the dashboard's own Evidence section for the feature (/?feature=<id>§ion=evidence) instead of the authenticated ?format=markdown API route directly — a reviewer clicking Details from the PR host used to get a 401. Nothing else about the check (name, conclusion, title, summary, externalId, the embedded envelope, retraction/upgrade flags) changed, and the API route itself is unchanged and still reachable.
Public surfaces:
GET /api/verifyweftra/provenance PR check (GitHub check-run / GitLab commit status / Bitbucket build status)npm run weftra:verify
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Trust Page
Status: 🟢 SHIPPED Audience: Operator
npm run trust:export renders docs/current/trust.md — the evidence a security reviewer asks for first (authorization surface, execution boundary, derived-state inventory, test/pin counts, supply chain, ledger/audit, adversarial-review coverage, the #173 incident record, and a fixed "what is NOT claimed" section) generated entirely from committed source files, never hand-typed. Pairs with the positioning change: the launch narrative states that the queue, approval, ledger and policy stay on the operator's infrastructure, engine-neutral, rather than claiming the agent itself always runs there.
Public surfaces:
npm run trust:export/trust
Docs:
docs/current/trust.mdREADME.mddocs/OPERATIONS.mddocs/SETUP.md
Base Refresh Overlap Is Not A Park
Status: 🟢 SHIPPED Audience: Operator
Spec 412 inc-1 (RM-206), operator ruling 2026-09-07: with 100+ developers committing to a project's default branch, a moved base is information for a human to act on, never by itself a reason to fail, retry, or park a run. Two changes to the existing base-refresh check (spec 201 inc-1: before verification, Weftra compares the run's changed files against whatever the base branch changed since the run's authorized base commit). First, a project's declared generated_paths (the same {path, regenerate} declaration spec 385's base-merge-conflict resolution already uses) are removed from BOTH changed-file sets before the overlap decision runs, for every strategy and both the implement and revise flows — a generator output both sides happen to have regenerated is not a conflict, and spec 398's post-turn normalization regenerates it fresh before the gate anyway. The exclusion is evidenced on every run as generated_paths_excluded on the existing base_identity_check run-event, naming exactly which declared paths were actually present in either raw set. Second, an implement run under the default revise_base_strategy: 'branch' (which never re-syncs) no longer escalates-and-parks on a REMAINING (hand-written) overlap: it proceeds to the test gate on its authorized base exactly as if the base hadn't moved, and opens its PR — a human syncs the finished PR later (GitHub's own "Update branch", or /revise with revise_base_strategy: 'merge', which gets spec 385's regeneration and spec 255's per-hunk conflict chooser). Recorded as base_advanced_overlap { authorized_base_sha, remote_base_sha, overlap_paths, main_changed_paths_count } — no base_refresh_escalation failure event, no status transition, no retry consumed. refreshStaleBase() itself is unchanged in what it returns for branch (still outcome: 'escalate', same evidence shape) — the reinterpretation lives entirely in each caller's own outcome handling. Spec 443 inc-13 (RM-264, 2026-09-15) closed the deliberate gap this left on the revise side: /revise under branch was measured discarding five finished rounds on Weftra's own PRs (#709, #713, #715, #716, #720 — the last losing 63 tool calls) because it treated the same overlap as terminal. revise.ts now applies the identical two-arm reinterpretation implement.ts already had — escalate with strategy === 'branch' proceeds (test gate, commit, push, PR updated, base_advanced_overlap recorded, no workspace wipe); escalate for any other reason (a merge-strategy cap exhaustion or a real conflict) still escalates-and-parks exactly as before, reverting to implemented with the branch/PR untouched and nothing pushed. The same increment also closed a revise-side evidence gap: a revise run's base_identity_check now carries generated_paths_excluded too, matching implement's since spec 412 inc-1. merge strategy's resync-and-cap-exhaustion behavior is unchanged for both flows. No new route, no new config field, no new run-event type, no UI.
Public surfaces:
none declared
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Worker Base Branch Provisioning
Status: 🟢 SHIPPED Audience: Operator
Spec 424 (RM-217): a worker's base branch exists before the run needs it, seeded from the prior version, pushed only when the run pushes. Compile still creates no git branch — that would be execution at compile time — but the old claim that "the branch comes into existence the first time a real episode pushes to it" was false: when git clone -b <baseBranch> fails (the compiled worker/<lineage>-vN branch does not exist yet) the implement flow's fallback plain clone used to cut the episode's feature branch straight from the wrong (repo-default) base with no branch ever created and no ledger record — the defect behind Pilot 0 v5's first episode dying at clone … failed (code 128), which an operator had to work around by pushing the branch by hand. Now the fallback arm creates <baseBranch> LOCALLY before cutting the feature branch, seeded from the prior version's tip (worker/<lineage>-v(N-1), via git fetch, falling through to the repository's own HEAD when no prior version exists or its fetch fails) and records base_branch_absent { requested, seeded_from_ref, seeded_from_sha }. The branch reaches the remote only when the SAME run pushes its own feature branch — never a moment sooner, so an observe/recommend episode that completes on evidence alone and pushes nothing leaves the remote byte-for-byte unchanged — recorded as worker_branch_created { branch, from_ref, from_sha }. A plain clone that fails for a reason that is neither auth/network, nor egress, nor a genuinely empty remote now fails the run with a named run_failure { cause: 'base_branch_unavailable', branch, repo } instead of the old opaque code-128. Every git operation is runSandboxGit on the existing implement path — in-sandbox, on the run's already-resolved credential; no new provider method, no route, no schema change. docs/OPERATIONS.md's compile-mechanics section is corrected to describe this instead of the false claim.
Public surfaces:
none declared
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Upstream Clean Candidate Branches
Status: 🟢 SHIPPED Audience: User
For a fork whose upstream maintainer never enrolled, an operator declares an upstream target (upstream_repo/upstream_base, admin-only) and an admin (or an approver linked to the feature's project — the route eagerly spawns sandboxed git containers, so it is an execution-class act, never a project-key one) materializes a squashed candidate branch IN THE PROJECT'S OWN FORK whose tree is the feature's tree minus exactly the platform-written artefact paths Weftra recorded FOR THAT FEATURE (the provenance artefact, a Weftra-authored/approver-edited spec/doc, the verification artefact), plus every path a project-declared upstream_exclude glob matches — which is the project-agnostic mechanism for the wider sweep, since Weftra never recognises "looks like a platform artefact" in someone else's tree, so a fork intending to send candidates upstream declares upstream_exclude covering the platform's directories once. Preparing is gated on the loop's own merge-readiness security posture, both halves: a live escalation-round cap refuses before any verdict is read, and the PASS must be tied to the tree being packaged. POST /api/features/:id/upstream-branch reads no request body field at all. No pull request is opened anywhere in this increment; the cross-repository PR is a later increment (RM-283 part 2).
Public surfaces:
POST /api/features/:id/upstream-branchPATCH /api/projects/:idPATCH /api/project
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Repository Enrolment Survey
Status: 🟢 SHIPPED Audience: Admin
The enrolment survey (RM-287 parts 2-3, spec 443 inc-38 + inc-39): an admin-only, POST /api/projects/propose clones a repository nobody has enrolled yet — shallow, into a throwaway workspace — and reports what Weftra can actually see in it, before anyone types a POST /api/projects body. Collects a bounded, Weftra-defined, project-agnostic set of evidence files (CI config, task runners, package manifests, toolchain pins, Dockerfile/docker-compose.yml, CONTRIBUTING/CLAUDE.md/ AGENTS.md/README.md), each read through the confinement guard the spec-path writer uses (assertSafeWorkspacePath + assertLeafNotSymlink, plus O_NOFOLLOW on the open itself) AND a per-segment lstat walk that additionally rejects the non-escaping ancestor symlinks the shared guard tolerates — so a symlink, file or directory, at any depth, pointing anywhere, is never read through and never becomes a false citation. Reads are bounded AT THE READ (at most max_bytes_per_file of any one file is ever materialized, so an attacker-shipped multi-gigabyte README.md is not a memory lever on the Weftra host); the clone itself is bounded by FA_PROPOSE_CLONE_DEADLINE_MS and not by any disk quota, which the docs state rather than imply. Every dropped/refused path is named in omitted[] with its reason, never silently shortened. Proposes two fields with no model — default_branch/head_sha (from the clone's own HEAD) and runtime_image (the same detectRuntimeImage auto-detector the agent runtime uses — a null value cites the three cases that detector cannot tell apart, rather than claiming nothing matched) — each carrying accepted_by_create_route computed by feeding the value to POST /api/projects's own validators. Since inc-39, the survey ALSO runs one bounded, analysis-mode model call (--tools '', no cwd, --max-turns 1, FA_PROPOSE_MODEL_PASS/FA_PROPOSE_MODEL_DEADLINE_MS) over the evidence it already collected and proposes setup_command, test_command, verify_command, runtime_egress_allowlist and spec_path — each proposal citing an evidence {path, line, excerpt} that Weftra independently re-verifies (path in evidence[], line within the INCLUDED bytes, excerpt a byte-exact substring) before it is ever trusted; a citation that does not verify demotes the field to not_proposed[] with the specific reason (citation_path_not_in_evidence / citation_line_out_of_range / citation_excerpt_mismatch / field_not_proposable / value_unrepresentable), never silently dropped. A closed advisory automated_prs_policy block (with up to 10 cited notes) is reported alongside, gating nothing. proposal_provenance states, on every report the model pass actually ran for, that fencing and citation verification do not stop a hostile repository's text from being cited truthfully — the control is that Weftra never runs any of it; this pass executes nothing it proposes and writes no project, same as the deterministic half. FA_PROPOSE_MODEL_PASS=off skips the pass entirely and says so (model_pass.status: "skipped"); every other failure mode (engine error, timeout, unparseable reply, nonce mismatch) ends the survey status: "done" with the deterministic proposal untouched and model_pass.status: "failed" carrying a bounded, redacted reason — the model pass can never fail the survey itself. Executes nothing FROM the repository — no setup_command, no container exec, no package install; the model call is Weftra's OWN process, in analysis mode with no tools, over evidence Weftra already collected, not an execution of anything the repository shipped — and writes no project (no createProject call, no schema change) — the report is a JSON file under workspaces/logs/, exactly like the env-check report. Since inc-40 (RM-287 pt.4), every report reaching status: "done" also carries a readiness block: a closed 13-class vocabulary (no_ci, ci_runs_no_tests, test_command_undiscoverable, egress_hosts_undiscoverable, toolchain_pin_unverified, package_manager_unrecognised, external_service_required, secret_required_by_tests, formatter_scope_exceeds_ci, proxy_sensitive_tests, automated_contributions_forbidden, automated_contributions_restricted, contribution_policy_absent) naming what makes the repository hard to enroll — before anyone runs an agent against it. Weftra, never the model, assigns each finding's severity (blocker/warning/advisory) and remedy from its own closed tables (src/services/enrolment-readiness.ts); the model's role for the 7 classes it may assert at all is narrower than a field proposal's — name a class and point at a citation Weftra independently re-verifies exactly as above, plus an optional bounded (<=300 character) detail for context — and a finding whose citation does not verify is discarded, never shown uncited; citation_verified: true claims only that the cited line really is in evidence[] byte-exactly, never that it supports the class — Weftra verifies a quotation, not a reading. The refusal list is bounded too (at most the first 100 reply findings are examined, each echoed class string truncated and control-character-stripped), so repository text cannot size what Weftra stores and serves back. The other 6 classes are Weftra's own, computed from the report's structured facts (no CI file present; the pass ran and proposed no test_command/runtime_egress_allowlist; the automated_prs_policy reading). On the three contribution classes derivation: "deterministic" names who owns the RUBRIC — class, severity and remedy are Weftra's tables — while the policy reading itself is the model's: a forbidden/restricted reading becomes a finding only when a note's citation verified, an uncited one is discarded and named in unevaluated[], and the contribution_policy_absent advisory is computed from the evidence (no CONTRIBUTING/CLAUDE.md/AGENTS.md collected) so no reply — including a permitted one — can suppress it. readiness.summary is one of ready / ready_with_findings / incomplete / not_ready, computed in that priority order and never inferred from the model: any blocker forces not_ready; with FA_PROPOSE_MODEL_PASS=off (or any failed pass) every model-derived class lands in readiness.unevaluated[] with the reason, so the summary is incomplete by construction and never a false ready. This increment reads no repository content that inc-38 did not already collect, runs nothing, and writes no project or database row — an execution-dependent finding (a red base, credentials, registries) is the env check's and the capability preflight's (inc-43), which will merge its findings into this same block under the same vocabulary. A later increment (RM-316 f) grew the evidence set with pnpm-workspace.yaml, .yarnrc.yml and a head-only-capped pnpm-lock.yaml (<=1024 bytes, applied at the read), and added a deterministic package_manager fact ({manager, marker: {path, line}, declared} or null) computed from that evidence alone, with no execution: a packageManager field in a non-truncated package.json first, else pnpm-workspace.yaml/pnpm-lock.yaml, else .yarnrc.yml. Two more Weftra-owned, deterministic readiness classes followed (13 -> 15, appended at the end so no existing class's position moves): package_manager_bootstrap_required (warning; fires whenever that fact is non-null, with a Weftra-verified citation at the marker) and build_state_committed (warning; fires whenever a bounded, read-nothing directory walk for the closed .tsbuildinfo suffix — depth <= 8, at most 20,000 entries examined, at most 20 paths retained — finds at least one match; a scan: "truncated" result with zero matches produces neither a finding nor an unevaluated[] entry, so a large repository is never read as incomplete on the strength of a bound alone). Neither class is ever accepted from a model reply. The proposal prompt also gained one fixed, unconditional, project-agnostic paragraph teaching the corepack bootstrap shape (install into a writable directory because the sandbox root is read-only, then prefix every other declared command with the same PATH export, since each runs in its own shell) and a fixed lockfileVersion-to-major-version table, with table-derived proposals capped at medium confidence. The New Project modal renders both new classes through the existing generic finding path, with one addition: under build_state_committed it lists the matched paths (capped at 20, "… and N more" beyond that) and whether the scan was bounded — DOM APIs only, no innerHTML. Lifecycle: a second survey for the same repository while one is in flight refuses 409; more than FA_PROPOSE_MAX_CONCURRENT in flight refuses 429; a report left running whose survey_id didn't survive a process restart reads aborted on the next GET, never a stale success. A clone that outruns FA_PROPOSE_CLONE_DEADLINE_MS is cancelled and killed (markCancelled + killJob on the survey's own per-run job id), and neither the workspace nor the in-flight slot is released until that work has actually stopped — both are released together, so the concurrency cap counts running clones rather than settled promises even when a clone survives the teardown grace. survey_id is a randomUUID and is shape-validated before it is used to build any report/log path. Since inc-41 (RM-287 pt.5) the survey has a dashboard surface: the Enroll Project modal gains a Survey repository trigger (create mode only — Re-detect on an already-enrolled project is inc-42), a bounded poll of the report with a Check again fallback once the poll deadline elapses, and a results region rendering the readiness verdict, findings, unevaluated classes and proposal in the server's own order. public/enrolment-proposal-form.js is the pure half — no DOM, no fetch, no module-level mutable state — and holds the one closed map (PROPOSAL_FIELD_TO_FORM_CONTROL) deciding which of the seven proposable fields pre-fills a real form control versus being shown informational-only; accepted_by_create_route is deliberately NOT that discriminator, since it is null both for spec_path (no such project field exists) and for setup_command/test_command/ verify_command (accepted as plain string columns with no dedicated validator) — reading null as "do not pre-fill" would drop three fields the create route actually accepts. spec_path is always informational, with the reason that POST /api/projects has no such field at all (it is a per-feature override). Every value the report carries is repository-controlled text, so the results region is built with createElement/ textContent only — no innerHTML, no interpolated attribute — the same discipline every other repository-controlled dashboard panel already uses. The modal also gains the two controls the survey could propose into but the form had none for: Runtime egress (a select mirroring project.runtime_egress) and Runtime egress allowlist, a JSON textarea (not one host per line, because an entry may be {host, binaries} — spec 349's narrowing — and a line-oriented control would silently drop binaries on an edit-and-save round trip, widening a narrowed allowance back to host-only). Unparseable allowlist text is refused before the request is sent (in the browser's own dialog, the same idiom the Services JSON control already uses); a blank control sends no key. A survey is bound to the repository URL it read: the region names that URL, and editing it — or surveying a second repository — drops the report and puts back every control the survey filled, so a command read out of one repository can never sit in the form while a different one is enrolled (a field the operator has since edited is left alone and named as kept). That binding is established when Survey repository is CLICKED, not when the survey returns, so the POST round trip is covered by it as well as the polling window: closing the modal or changing the URL at any point stops the poll and invalidates every response still in flight — the 202 included — so nothing writes into the form afterwards. Inside a finding, Weftra's closed-vocabulary severity/summary/remedy are rendered plainly while the model's free-text detail is marked as model-written and not Weftra's verdict, so a sentence a repository engineered into the model's reply cannot be read as Weftra's own. On a successful Create preceded by a survey in the same modal session, the form offers to open the existing environment-readiness check for the project just created — the honest reading of "run env check before Create", since POST /api/projects/:id/env/check needs a project id and so cannot run any earlier than that. This surface adds no endpoint and no src/ change at all — it calls the three routes above exactly as they already exist. Spec 466 (RM-338) makes the form show that a survey is working. From the Survey repository click until the survey reaches a terminal state, the form is locked: the repository URL, every control the survey can fill (read from the same field-to-control map that pre-fills them), the Survey repository button and the Save button are disabled and dimmed, and a submit is refused while the lock holds. The region shows the surveyed URL, the existing live-logs link, the four steps (Cloning, Reading evidence, Proposing, Done) with the current one named in text, and an elapsed time counted on the client from the click. The step comes from one new server field: a report served as running carries an optional phase (cloning, reading_evidence, proposing) written at the three points the job log already names; phase is absent from every terminal report and never proposing when FA_PROPOSE_MODEL_PASS=off. Cancel survey is a client-side stop only: it stops the dashboard waiting and unlocks the form, and says the survey keeps running on the server until it finishes or reaches its own time limit. No server cancel route exists. The placeholder text of every input and textarea is italic and in its own colour token, distinct from typed values.
Public surfaces:
POST /api/projects/proposeGET /api/projects/propose/:survey_idGET /api/projects/propose/:survey_id/logspublic/index.html#pf-survey-btnpublic/index.html#pf-survey-regionpublic/index.html#pf-submit-btnpublic/index.html#pf-runtime-egresspublic/index.html#pf-runtime-egress-allowlist
Docs:
docs/USER_GUIDE.mddocs/SETUP.mddocs/QUICKSTART.md
Worker Marketplace Template Export Install
Status: 🟢 SHIPPED Audience: Admin
The worker marketplace, increment 1 (spec 445 inc-1, RM-175): a worker's definition chain (outcome -> roadmap_item? -> use_case -> story* -> agent_solution) can leave one project as a lineage-free, content-hashed TEMPLATE and install into another project on the same instance. GET /api/projects/:id/product/agents/:lineageId/export-template walks the chain, replaces every internal id with a symbolic ref (outcome/roadmap_item/use_case/story_N/agent_solution), and returns the result with a content_hash — the template carries no project id, lineage id, artifact id, version, status, author or timestamp, only content. Exporting the same unchanged chain twice is byte-identical. A chain containing a retired artifact is refused (409); one referencing something outside the project, or still naming an internal id after unbinding, is refused (400) without leaking the id. Before returning anything, the assembled template is scanned for credential shapes, this instance's own configured secrets and this project's declared secret values with the EXISTING worker-package scanner (no second scanner), against the same literal pool the worker-package export assembles — a match, or an unconfirmed-empty scan pool, refuses the export. POST /api/projects/:id/product/templates/install writes a brand-new chain from a submitted template: new lineage ids pre-minted before any insert, every __REF:x__ token substituted to its final value, every artifact authored_via: 'template' and status: 'draft' — never ready, so the installed agent_solution cannot be compiled until the target project's own Definition-of-Ready review passes it, same as any other worker. The request body is exactly {template, expected_content_hash?} — an unknown field is refused by name before any write; the hash is independently recomputed and checked; every artifact passes the SAME shape validation the tenant create route uses. All-or-nothing in one transaction. Both routes are requireAdminOrApproverForProject — a project key, including the worker's own, is refused 403 before either route reads or writes anything. Install creates no worker_deployments row, enrolls no project, and mints no fa_ key — installing grants nothing and runs nothing.
Public surfaces:
GET /api/projects/:id/product/agents/:lineageId/export-templatePOST /api/projects/:id/product/templates/install
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/security/authz-surface.json
Worker Marketplace Catalogue Store
Status: 🟢 SHIPPED Audience: Admin
The worker marketplace, increment 2 (spec 445 inc-2, RM-175): an instance-level CATALOGUE gives increment 1's templates a home. POST /api/catalogue/entries publishes a template — explicit admin credential only, never the dev-mode open-pass, never an approver or project key. The request body is exactly {template, expected_content_hash?}; an unknown field is refused by name before any write, and a template with no agent_solution is refused (a catalogue entry is a worker, and its display title has nothing else to come from). Validated with the SAME structural checks POST .../templates/install uses (the shared logic was factored out, not duplicated). Scanned for credential shapes against this instance's own configured secrets with the EXISTING worker-package scanner (publish has no project in scope to scan against). Signed with this instance's Ed25519 root key over the content hash — an INSTANCE attestation that these exact bytes were admitted, never a claim about who published and never a cross-instance trust root; the signature still verifies after the root key is retired and a new one minted. Re-publishing byte-identical bytes is idempotent (200, no second row or event); a stored row under the same hash with different or corrupted bytes is refused (409) and never overwritten; a publish past the 1000-entry ceiling is refused (429). GET /api/catalogue/entries and GET /api/catalogue/entries/:hash let any project key browse — every row is verified (recomputed hash, signature, and every column re-derived from the signed template bytes) before it can be returned; a row that fails is OMITTED from the list (counted in unverified_count only, never named) or refused (409) on direct read. A project key never receives publisher_principal in either response, only publisher_kind — a named admin/approver's principal key IS their email address, and handing it to a tenant in bulk would harvest the operator roster. Declared capabilities and egress_hosts are returned as a labelled DECLARATION only — nothing here compiles, deploys, or bounds them against any project's allowlist. Catalogue entries are insert-only: no route ever updates or deletes one. POST /api/projects/:id/product/templates/install-from-catalogue installs a published entry by hash into a project — same requireAdminOrApproverForProject posture as increment 1's install, body is exactly {content_hash} so a template in the body is refused by construction, the entry's hash and signature are re-verified before anything else, a second credential scan runs against the TARGET project's own declared secret material, and increment 1's own install path runs verbatim from there — new lineage ids, every artifact status: 'draft', no worker_deployments row, no worker project, no fa_ key.
Public surfaces:
POST /api/catalogue/entriesGET /api/catalogue/entriesGET /api/catalogue/entries/:hashPOST /api/projects/:id/product/templates/install-from-catalogue
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/security/authz-surface.json
Worker Marketplace Install Time Bound And Provenance
Status: 🟢 SHIPPED Audience: Admin
The worker marketplace, increment 3a (spec 445 inc-3a, RM-175): an installed template is bounded by the project that installs it, and the deployment it compiles into says where it came from. In compileAgentSolution, before any write, a chain whose agent_solution artifact carries a non-null template_install_id (stamped only by a marketplace install) has its declared authority.egress_hosts checked against the INSTALLING project's own resolved runtime-egress policy (resolveRuntimeEgressPolicy) — mode allowlist: every declared host must already be in it, or the compile is refused (422) naming every offending host and writing nothing (no worker project, no fa_ key, no deployment row, no egress config); mode open (the default): nothing to bound against, so this permits the compile exactly as before. A chain the project authored itself (template_install_id null) is completely unaffected. The refusal writes one agent_solution.compile_refused actor event carrying {reason, source_project_id, lineage_id, hosts} — host names only, never a title, body, or secret. Both install routes (POST .../templates/install, POST .../templates/install-from-catalogue) add one field to their existing 201 body, declared_egress_outside_project_bound: string[] — an ADVISORY computed with the SAME exported helper the compile refusal uses (declaredEgressOutsideProjectBound), read at install time; the install still succeeds in every case and every artifact still lands draft. authority.capabilities is bounded by NOTHING this increment adds — no project-level capability allowlist exists anywhere in Weftra; what actually bounds a compiled worker's capabilities (the ladder-base compile refusal, the episode's declared-capability intersection, the act gateway's declared-tool-only offer, and the tool-access token's declared-set intersection) is unchanged. worker_deployments gained two nullable columns, template_hash and template_install_id, set from the compiled artifact (null for a self-authored chain) and copied unchanged onto every promoted/demoted/auto-demoted successor row. Both deployment read routes (GET .../product/agents/deployments, GET .../deployments/:deploymentId) return installed_from: {template_hash, template_install_id} | null with no guard or status-code change, and the Workers panel (public/workers.html) renders an "Installed from <hash prefix>" line when it is non-null, built with DOM APIs. Two projects installing the same catalogue entry get two independent chains, two worker projects, and two deployments, each starting at the ladder's base with disjoint promotion evidence — promoting one never changes the other.
Public surfaces:
POST /api/projects/:id/product/agents/:lineageId/compilePOST /api/projects/:id/product/templates/installPOST /api/projects/:id/product/templates/install-from-catalogueGET /api/projects/:id/product/agents/deploymentsGET /api/projects/:id/product/agents/:lineageId/deployments/:deploymentId
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Worker Marketplace Catalogue Evidence
Status: 🟢 SHIPPED Audience: Admin
The worker marketplace, increment 3b (spec 445 inc-3b, RM-175): a catalogue entry carries the track record of every install, and an operator can rank by it. A new module, catalogue-evidence.ts, composes computeDeploymentEvaluation (never re-implementing any counting) over every worker_deployments row (live or retired) carrying one template's content hash, plus a count of worker_template_installs rows for that hash, over the ENTIRE lifetime of the instance. GET /api/catalogue/entries and GET /api/catalogue/entries/:hash attach the result as evidence — installs, deployments, episodes completed, effects observed (a SUM of each deployment's own distinct-effect count, not re-deduplicated across deployments), recommendation effects observed, refused acts, and a POOLED agreement rate (summed accepted/rejected across every deployment, divided once, null below the shared minimum sample) — but ONLY for a caller with no req.project, the exact test that already withholds publisher_principal; a project key's response is byte-identical to before this increment, and the rollup is not computed at all on that path. GET /api/catalogue/entries gains ?sort=, exactly published_at (default, unchanged order) or effect (effect_observed DESC, then agreement_rate DESC with null last, then published_at DESC, then content_hash ASC — byte-deterministic, no localeCompare); an unknown value or sort=effect from a project key is refused (400). A row failing its tamper-evidence check is still omitted and counted in unverified_count only, never ranked or rolled up. The rollup is REPORTING ONLY — imported by the catalogue route and its own tests alone, read by no promotion, compile, episode or gateway path, so installing a heavily-used entry into a fresh project and compiling it still lands at the ladder's base autonomy level with zero outcome rows of its own. The Workers dashboard (public/workers.html) gained a Catalogue panel — entries, their evidence line, a sort control, and a caption stating these numbers are activity, not a quality judgement — built with DOM APIs only.
Public surfaces:
GET /api/catalogue/entriesGET /api/catalogue/entries/:hash
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/security/authz-surface.json
Product Definition Plane Import Door
Status: 🟢 SHIPPED Audience: Admin
Product Definition Plane, the canonical IMPORT door (spec 449 inc-1a, RM-310 job 1): a definition tree maintained OUTSIDE Weftra (a roadmap file, a planning-tool export, a spreadsheet) lands in a project's product_artifacts store idempotently, with attribution and a recorded privileged act — the generic platform half of RM-310; Weftra's own converter for its OWN roadmap markdown is separate, out-of-tree work (inc-1b, scripts/), never this door, so Weftra learns nothing about any source document's format (constitution §VII). POST /api/projects/:id/product/artifacts/import (admin/approver only — a project key is 403, the same bulk-privileged-write posture retire/observations already take) accepts { source: {external_ref, content_hash}, artifacts: [{kind, external_ref, parent_external_ref?, depends_on_external_refs?, title, body}] }, 1..50 entries. depends_on_external_refs (roadmap_item only) names the roadmap items an entry depends on by their external_ref; each resolves in the same project, in request order, to the lineage id written into body.depends_on — the refs themselves are never stored, an unresolvable one refuses the whole import (400), and omitting the field on re-import clears the stored dependencies. external_ref (the idempotency key) lives in body_json, validated and stripped once in validateArtifactBody and re-attached after — accepted on the five definition-tree kinds only (outcome, roadmap_item, use_case, story, spec_link), rejected as an unknown field on the four Weftra-authored kinds, and only for a caller that reached the import door: the shared validator refuses the field unless the write path opted in, so the tenant-reachable create/version routes cannot seed the identifier namespace a privileged import resolves against. An external reference is stored and matched byte-for-byte and so cannot be masked; one the redaction floor would alter is refused (400) rather than written, which also keeps credential-shaped bytes out of the hash-chained actor ledger. Per entry, within the project: no matching lineage creates one; a matching lineage with an identical content hash writes nothing (unchanged); a different hash appends a new version of the SAME lineage (versioned) — a retired lineage is never revived. parent_external_ref resolves through the same lookup, in the same project, entries applying in request order so a parent may appear earlier in the same request; it binds at creation — an entry naming a different parent than its lineage already has is refused (400), never silently left where it was, since a new version cannot re-parent and the parent is not part of the content hash. The whole request is ONE transaction: any entry's validation failure, or the import breaching the project's existing MAX_PRODUCT_ARTIFACT_ROWS_PER_PROJECT cap (checked live, never raised or bypassed), rolls back every write the call would otherwise have made and answers 400/429 with nothing written. authored_via is always 'import', never a caller-supplied value. roadmap_item alone gains two optional OPAQUE fields, labels/references (string arrays, bounded, redacted) — the same byte-for-byte, interpret-nothing discipline serialization_lane already documents: no trim, no case fold, no split, no URL validation, no parsing anywhere in this door (§VII load-bearing). Exactly one product_artifact.import row lands on the actor-events ledger per successful import (source ref/hash and the three outcome counts only — never an artifact title or body), inside the SAME transaction as the artifact writes, so a committed import can never exist without it and a refused one writes neither. No new page: the response body (per-entry outcome, counts, row budget), the imported artifacts (already listed by the existing GET .../product/artifacts and rendered on the Product canvas) and the actor event are the whole surface — a dedicated roadmap view is deferred to RM-310 inc-2, once the store becomes the source of truth. Weftra's own first CALLER of that door ships alongside it (spec 449 inc-1b, RM-310 job 2) and lives OUTSIDE src/ by construction: npm run roadmap:export (scripts/roadmap-to-definition-tree.ts) parses Weftra's roadmap markdown into one roadmap_item envelope per [RM-nnn] token — external_ref verbatim, labels mechanical and opaque, chunked at the door's own MAX_IMPORT_ARTIFACTS_PER_REQUEST, imported rather than re-declared — and EMITS BYTES ONLY: no --post mode, no socket, no database handle, no subprocess, and no timer, hook or CI step that runs it (Weftra never polls a repo to mutate its own state). The operator posts each chunk through the guarded route with their own credential, which is exactly what keeps the door's authz and its product_artifact.import actor event on every write a script could otherwise have made unrecorded. Two entries sharing one token are reported with their line numbers and refused (exit 3), never resolved by guessing. Every project-specific convention stays in that script, so the door still knows nothing about any source document's format (§VII).
Public surfaces:
POST /api/projects/:id/product/artifacts/importnpm run roadmap:export
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/security/authz-surface.json
Roadmap Inverse Renderer
Status: 🟢 SHIPPED Audience: Admin
The INVERSE of npm run roadmap:export (spec 449 inc-2a, RM-310 job 2's first half): npm run roadmap:render (scripts/roadmap-from-definition-tree.ts) takes the envelopes the export script emits and prints back the markdown entry blocks they came from, then measures — before anything makes the product store the source of truth for docs/design/fa-roadmap.md — whether the store can actually reproduce the document. Default mode reads envelopes (stdin, or --in <dir> reading roadmap-import-*.json in sorted filename order) and renders each artifact's body.summary to stdout, in envelope order. --check runs the whole round trip in-process over the committed roadmap (or --file's override): parse, build the same envelopes an export would, render each back, and compare against the entry's own span in the source document, classifying every entry into exactly one of seven classes in a fixed precedence (identical, truncated_title, truncated_summary, references_capped, indent_lost, field_disagreement, unclassified — first match wins), reported (stderr, or stdout as --json) with per-class counts and, for every non-identical entry, its token, source line and class. unclassified is the one class that must never appear — a single one fails --check (exit 3) and the test suite; --check exiting 3 over the real, currently-lossy roadmap is otherwise expected and is the finding this increment hands to RM-310 inc-2b, not a defect to fix here. Bounds (MAX_TITLE_CHARS, MAX_STRING_CHARS, MAX_ARRAY_ENTRIES) are imported from the platform schema, never re-declared. Deliberately no sync test over the live roadmap: the self-build loop rewrites that file every cycle (merge marks, turn log), so a byte-match baseline would go red on the loop's own bookkeeping; what is asserted instead is that unclassified stays zero and the entry count stays at or above a recorded floor, both stable under routine edits. Lives entirely in scripts/, never src/ — a renderer for one project's document format is project-specific by construction (§VII) — and emits bytes only: no socket, no database handle, no subprocess, no credential read from the environment, and nothing in package.json, CI or a hook runs it. Never opens the database and never posts to the import door; the operator still does that with their own credential, exactly as inc-1b left it.
Public surfaces:
npm run roadmap:render
Docs:
docs/OPERATIONS.md
Roadmap Document Partition
Status: 🟢 SHIPPED Audience: Admin
The WHOLE-DOCUMENT partition and in-place entry rebuild (spec 449 inc-2b, RM-310 job 2's second half): partitionDocument (scripts/roadmap-to-definition-tree.ts) walks docs/design/fa-roadmap.md once and assigns every byte to exactly one of FRAME or ENTRY, reusing the same boundary rule parseRoadmap already computes rather than a second parsing pass. Entry segments are the spans the converter already derives; everything else coalesces into frame segments. The one invariant: rejoining every segment's text, in order, with no separator, reproduces the source byte for byte — no gaps, no overlap, no normalisation, a trailing newline/CRLF/absent-final-newline all surviving intact. npm run roadmap:render -- --document (scripts/roadmap-from-definition-tree.ts), composable with --check/--json/ --file, rebuilds the document as frame segments verbatim plus each entry segment replaced in place by the block buildEnvelopes actually renders for that token (the same renderer inc-2a shipped, never a hand-built fixture), then compares the rebuild to the source byte for byte: --check reports to stderr (or stdout as --json) and exits 0 iff identical else 3; bare --document prints the rebuilt document to stdout only, for an operator to diff — there is no write mode. Because --document derives its envelopes from the --file document itself, combining it with --in <dir> is refused with a usage error rather than silently measuring the file and ignoring the envelope directory that was passed. The report reuses inc-2a's fidelity classes unchanged per entry and adds document totals plus two lists: oversize (a span over MAX_STRING_CHARS, imported from the platform schema and never re-declared, ascending by line — the list the next increment's write mode is gated on) and unrebuildable (a token occurring as more than one entry, which the store can only hold as a single row per external_ref, so a rebuild cannot know which occurrence's content that one row should replace — named explicitly rather than guessed). The bound decision this increment takes: MAX_STRING_CHARS/MAX_TITLE_CHARS/ MAX_ARRAY_ENTRIES are not widened (a platform bound does not bend for one project's document), and an oversized entry's prose is not spread across other body fields either — the residual is document health, reported for the document's own author to act on, never rewritten by this change. Measured on the committed roadmap: entries cover roughly a quarter of the document's lines and frame the rest, so --check --document exits 3 today by design — that is the measurement this increment exists to make provable, not a defect. Lives entirely in scripts/, never src/ (constitution §VII) — this diff touches no src/ file at all — and emits bytes only: no socket, no database handle, no subprocess, no credential, and nothing in package.json, CI or a hook runs --document automatically. Never opens the database and never posts to the import door.
Public surfaces:
npm run roadmap:render -- --document
Docs:
docs/OPERATIONS.md
Roadmap Guarded In Place Write
Status: 🟢 SHIPPED Audience: Admin
The GUARDED IN-PLACE WRITE (spec 449 inc-2c, RM-310 job 2's third half): applies the store's own entries back into docs/design/fa-roadmap.md in place, or writes nothing at all. scripts/roadmap-from-definition-tree.ts gains --store <dir>, a NEW flag distinct from --in (§32's entry-only render path), reading a directory of roadmap-import-*.json envelopes and using them, rather than envelopes derived from the document itself, as each entry span's replacement block (still body.summary verbatim, inc-2a's render rule). --document --store <dir> prints the store-sourced rebuild for an operator to diff; adding --write (or its by-hand alias npm run roadmap:apply -- --store <dir>) performs the write. A fail-closed gate runs in full BEFORE the target file is ever opened: G1 refuses any entry the toolchain cannot provably reproduce (inc-2b's computeDocumentReport self round-trip, or an oversize/unrebuildable entry) unless it is waived; G2 refuses a document token with no matching supplied artifact (missing is not "leave it alone" — only an explicit waiver is a decision that the store doesn't own the entry); G3 refuses a supplied artifact for a token the document does not contain; G5 refuses the supplied CONTENT itself unless it re-parses as exactly one entry carrying that same token (so a block that smuggles in a second entry, a ## heading, non-entry prose, empty text, or a different token is refused, named per token) and unless it is neither a strict prefix — a truncation — of the entry's own text nor otherwise SHORTER than the span it would replace (a longer or same-length rewording is what this mode applies; shortening an entry is a by-hand edit to the document); and G4 independently re-parses the PRODUCED bytes with the same partitioner the source went through, requiring the same segments in the same order with the same tokens and every frame segment and waived entry span byte-identical. G3 + G5 + G4 together are what make "the write never appends, inserts, reorders or deletes an entry, and never touches the frame" a property of the output bytes rather than of the code path. Any failure names every offending token across every failing gate and writes nothing (exit 3). G1 refuses on the real, committed roadmap TODAY with the shipped waiver file, naming the same oversize/duplicate-token entries §32 measures — correct, not a defect, and neither MAX_STRING_CHARS/MAX_TITLE_CHARS/ MAX_ARRAY_ENTRIES is widened nor is a word of the document's own prose rewritten to make it pass. Waiving is scripts/roadmap-write-waivers.json, a committed file shipped EMPTY ({"version":1,"unmanaged":[]}): a waived token's span is copied from the source byte for byte, never rendered from the store, and the file is refused unless well-formed (malformed JSON, wrong version, an unrecognized key, a token not matching ^RM-\d+$, a duplicate token, or an absent/empty reason). A waiver only ever NARROWS: G5's truncation and shorter-than-the-source checks run on every entry the store would write and a waived entry is copied rather than written, so no waiver or flag combination overwrites an entry with a shortened rendering of itself. There is no blanket override — no force/allow-truncated/yes flag, no wildcard token, no CLI waiver flag at all; waiving is a diff a human reads and commits. The write is atomic (a temp file in the target's own directory, renamed over it, so a failure after computing bytes leaves the original byte-identical and a successful run leaves no temp file behind) and idempotent (a rebuild equal to the source changes no bytes and writes no file; the same successful write run twice is a no-op the second time). A successful run reports the ordered list of changed tokens and their count to stderr, or as JSON on stdout with --json. Nothing wires a write to anything: npm run roadmap:apply spells --document --write as literal argv in package.json, the script reads no environment variable at all, and no CI step, git hook, scheduled job, route or server code path invokes either form — so the command an operator typed is the whole record of whether a run can write. The supplied envelopes are shape-checked before anything counts, compares or splices them (no artifacts array, a kind other than roadmap_item, an external_ref that is not ^RM-\d+$, or a non-string body.summary is a usage error) — and the same check now also guards the pre-existing --in <dir> and stdin render forms, which are caller-supplied input on the same footing and previously rendered a malformed envelope rather than refusing it. Lives entirely in scripts/, never src/ (constitution §VII), and emits bytes and file writes only: no socket, no database handle, no subprocess, no credential, no timer, and it never posts to the import door — the operator still does that with their own credential.
Public surfaces:
npm run roadmap:render -- --document --storenpm run roadmap:apply
Docs:
docs/OPERATIONS.md
Worker Marketplace Catalogue Forks
Status: 🟢 SHIPPED Audience: Admin
The worker marketplace, increment 4 (spec 445 inc-4, RM-175): a catalogue entry is IMMUTABLE but FORKABLE. catalogue_entries.forked_from has existed since increment 2 but was always NULL; this increment makes it real, adding no route, table, column, index, migration, or guard. POST /api/catalogue/entries accepts an optional forked_from (64-character lowercase hex) — checked BEFORE the cap check, the credential scan, and signing: absent stays byte-identical to before this increment (including the 201 body, which carries no forked_from key at all for a root publish); a bad shape, an absent parent, or naming the entry's own hash is refused (400); a parent failing its own tamper-evidence check is refused (409) and recorded with catalogue.entry_verification_failed for the PARENT. Lineage is write-once: re-publishing an existing hash's bytes with the SAME declared lineage is the existing idempotent 200; the SAME bytes with a DIFFERENT declared lineage is a new refusal (409, catalogue.entry_publish_refused reason lineage_is_immutable) — rewriting lineage is exactly the in-place edit immutability forbids, so it is refused even though the bytes match. forked_from is a PUBLISHER'S CLAIM, never proof of derivation — Weftra checks only that the named parent exists, verifies, and is not the child. Both browse routes (GET /api/catalogue/entries, GET /api/catalogue/entries/:hash) gain fork_count on every item — how many entries declare this one as their parent, computed for the whole request with ONE grouped query, disclosed to a project key as well as an admin (unlike the evidence rollup), and still counting a child that currently fails its own tamper-evidence check. The single-entry read additionally gains forks (direct children, newest first, bounded by MAX_FORKS_LISTED, a failing child omitted-but-counted-and-recorded) and fork_ancestry (the parent chain root-ward, bounded by MAX_FORK_ANCESTRY_DEPTH, stopped on a repeated hash, with fork_ancestry_truncated). GET /api/catalogue/entries gains ?sort=forks (fork_count desc, published_at desc, content_hash asc) — unlike ?sort=effect, available to a project key, since fork_count is already disclosed to one. A fork's evidence rollup starts at zero by construction (keyed on the fork's own content hash) and the parent's rollup is unaffected — no new code, pinned by tests. The Workers dashboard (public/workers.html) gained a fork-count line, a "forked from <hash prefix>…" line, a "Most forked" sort option, and a caption — built with DOM APIs only.
Public surfaces:
POST /api/catalogue/entriesGET /api/catalogue/entriesGET /api/catalogue/entries/:hash
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Product Definition Plane Roadmap Candidates
Status: 🟢 SHIPPED Audience: User
Product Definition Plane, RM-311 (spec 451): a roadmap_candidate artifact kind — a structured, evidence-bearing, attributed PROPOSAL for future work, distinct from the definition-tree kinds above. The closed body (summary, rationale, proposed_by {class, ref?}, evidence_refs[] {kind, ref}, optional suggested_lane/suggested_size) is validated the same way every other kind's is (bounds imported from the shared schema constants, redacted free text, unknown fields refused by name) with one addition: evidence is MANDATORY — absent, empty, or all-blank evidence_refs is refused with 400 and nothing is written. The candidate's lifecycle is NOT stored in body or in product_artifacts.status (both would let a later write silently change it); it lives on a lineage-level table, roadmap_candidate_dispositions, one row per candidate, starting at 'candidate' the moment the artifact is created. activated/declined are TERMINAL — a second decision on an already-decided lineage is refused (409, compare-and-set) rather than overwritten; declining requires a non-blank reason. Four routes under /api/projects/:id/product/candidates: create/list/read are admin-or-approver (a project key is refused, matching every other privileged write in this plane), and deciding is narrowed further to a NAMED admin user only (requireNamedAdmin, src/middleware/auth.ts) — the shared ADMIN_API_KEY, an approver, and the dev-mode open-pass are all refused there, because authority is not attribution and this decision needs to be attributable to a specific person. Every successful create and decision appends exactly one row to the tamper-evident actor-events ledger; a refused call writes nothing. Increment 1b adds the AUTOMATED PROPOSER: a producer-agnostic seam (proposeRoadmapCandidates, src/services/roadmap-candidate-proposer.ts) that turns already-computed subjects into candidates through the SAME writers the create route uses, deduplicated against every existing candidate in ANY disposition — including declined, so a decision a named admin made is not re-opened next hour — refusing to write past the project's artifact-row budget, and hard-capped per call. Dedup is two checks ANDed: the seam's exact match on the title it derives (a bounded, REDACTED excerpt plus a short hash of the producer's key — redacted before comparison, because the writer stores the redacted form), plus an optional caller-supplied "have I proposed this before?" predicate for producers whose subject identity is approximate. Its first (and today only) caller is the hourly learning retrospective (src/services/learning-retrospective/tick.ts): a landed pass whose recurrence has a finding class recurring across >= FA_ROADMAP_CANDIDATE_MIN_FEATURES distinct features (default 3) proposes it, up to FA_ROADMAP_CANDIDATE_MAX_PER_PASS (default 3) per pass, chosen by a total order (feature count desc, then class label asc) so the same evidence always proposes the same candidates. Because a class is LABELLED with the raw claim of whichever finding seeded it and consecutive passes grade disjoint feature sets, the retrospective supplies the predicate above and answers it with the SAME isSameClass comparison the grouping itself uses — an overlap test, not equality, so a class whose wording drifts past recognition can be proposed a second time (a near-duplicate to decline, never a re-activation of a decided one). FA_ROADMAP_CANDIDATE_PROPOSER (default on, fail-closed to off on any unrecognised value) is the master switch; a FA_ROADMAP_CANDIDATE_MAX_PER_PASS of 0 records proposer_off rather than pretending everything was a duplicate. The proposer never makes a model call, never sets suggested_lane/suggested_size, and — like every candidate — activates, schedules, or notifies nothing: this stays deliberately PROPOSAL-ONLY, and no selection query anywhere in Weftra reads kind = 'roadmap_candidate', verified by deriving the check from source rather than one hand-written case. Increment 2 adds the dashboard surface: a Candidates panel on the Product canvas (public/product.html, above the definition tree) that loads every candidate for the selected project once (no server-side filter — grouping into Awaiting decision/Activated/Declined/All is client-side, so every count is known without extra round-trips), renders every field including every evidence_refs entry (never truncated; a url kind becomes a link only when it is an absolute http(s) address), and offers Activate/Decline on an undecided candidate through the SAME direct disposition route above (never /api/builder/...). Deciding still requires a named admin user: the page calls GET /api/users/me once and renders the controls disabled (never hidden) with a plain-language explanation whenever that resolves to anything other than an admin role, so affordance never outruns what the server (requireNamedAdmin, unchanged) will actually allow. No route, guard, table or migration was added for this increment — it is presentation only over the three routes increment 1a shipped. roadmap_candidate still does not appear in the canvas's definition-tree kind lists (its parent_lineage_id is always null).
Public surfaces:
POST /api/projects/:id/product/candidatesGET /api/projects/:id/product/candidatesGET /api/projects/:id/product/candidates/:lineageIdPOST /api/projects/:id/product/candidates/:lineageId/dispositionpublic/product.html#candidates-panel
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Vcs Credential Health
Status: 🟢 SHIPPED Audience: Operator
Spec 452 inc-1 (RM-318) — a rejected VCS credential is OBSERVED, classified, and recorded, instead of silently swallowed. Built after Weftra's own operator-default GITHUB_TOKEN answered 401 for an hour on 2026-09-25 with nothing saying so: every GitHub provider method catches its own API error and returns null/[]/false, so a swallowed 401 never reached the credential-scrubbing choke point and PR-merge polling, security-review head-SHA resolution and the self-build security stage all went blind with no trace anywhere. classifyVcsHttpOutcome (src/services/vcs/credential-health.ts) is a pure, total classifier: 401 is always rejected; 403 is rejected UNLESS it carries a rate-limit marker (x-ratelimit-remaining: '0' or retry-after), which is other; everything else is other and neither records a rejection nor clears one. A recorded 403 therefore means "the forge refused this credential for that call", which is narrower than 401's "this credential is not accepted" — a status code alone cannot separate a dead credential from a missing scope, an SSO/IP block or an archived repo. Where Weftra itself knows an operation's non-rate-limited 403 is an endpoint-authorization answer rather than a credential verdict it is recorded as other, no rejection at all: ENDPOINT_AUTHORIZATION_403_OPERATIONS lists postCheck, because GitHub's Checks API is App-only and a PAT — the documented default credential shape — gets a 403 there on a live token, which Weftra already classifies credential_lacks_check_write and already handles with a commit-status fallback. The carve-out is per-operation and 403-only; a 401 from the same call site is still a rejection. One row per (tenant_id, project_id, host) in the new vcs_credential_health table (src/models/vcs-credential-health.ts) carries source_kind (the resolved CredentialSource kind — operator-default | tenant-byo | tenant-brokered, no second credential model), last_status, last_http_status, operation (a Weftra-declared VcsProvider method name, never provider text), consecutive_rejections and rejected_since (the first rejection of the current unbroken run). The withhold is structural: the recorder's input type — and the model-layer writer inputs that actually insert the row — carry only a status code and Weftra-authored literals, so no error message, response body, request URL or token has a field to pass through at either layer. Written on state CHANGES only — always on a rejection, and on recovery only when the row was not already healthy or the last "still ok" stamp is older than a 60s module constant — so a healthy instance does not write per call. Wired into all three VCS forges: github.ts observes via an Octokit hook.after/hook.error pair installed at every one of its 20 new Octokit(...) construction sites (success AND thrown-error paths, both re-throwing/returning unchanged — no provider method's return value, throw contract or error text changes); gitlab.ts/bitbucket.ts observe at the raw-fetch response site, inside their shared glFetch/bbFetch helpers and at every standalone fetch call. host is always the host the credential was actually used against — a project on a host Weftra cannot route falls through to the default GitHub provider, which has no baseUrl and so talks to api.github.com with the operator default, and is recorded under github.com rather than the repository's own hostname. A thrown provider error is observed exactly once, at that same wire-level tap; withVcsProvider's catch deliberately takes no second observation, which would double-count one response into consecutive_rejections. An admin reads the table via GET /api/admin/vcs-credential-health (requireAdminAuth, read-only, no project-key path). Increment 1 raised NO alarm and changed NO control flow by itself — no fleet-attention item, no notification, no dashboard banner, no run-event (run_events is feature-keyed and this observation point holds no feature id) — it only made the fact "was this credential just accepted or rejected?" exist somewhere durable. Increment 2 (RM-318) turns that recorded streak into the two things a human actually sees. A new runtime setting, vcs_credential_alert_threshold (env VCS_CREDENTIAL_ALERT_THRESHOLD, default 3, bounds 1..50, read fresh at use time), decides how many consecutive rejections raise an alert — deliberately with NO accompanying time window, since consecutive_rejections already means "no accepted call landed in between" and a window would stop an instance whose only credentialed traffic is the 10-minute PR-merge poll from ever alerting. recordVcsCredentialRejected now returns the row's post-write consecutive_rejections atomically (a RETURNING clause on the same upsert, no second read), and recordVcsCredentialObservation dispatches a fire-and-forget alert exactly when that count equals the threshold — exactly once per streak by construction, no new column, no in-memory dedup set — and one recovery notification on the clearing edge (an ok after a streak that had reached threshold). A pure computeVcsCredentialAttentionItems (src/services/fleet-attention.ts) derives an admin-only vcs_credential_invalid attention item at READ time: operator-default rows are grouped by host into one item naming the host and the number of affected projects; every other source kind gets one item per row. GET /api/features/admin/attention appends these items only for an unscoped admin caller — an approver-scoped read sees none, and a project key cannot reach the route. Because the item is feature-less (feature_id: ''), the periodic SLA sweep (runAttentionSweepOnce) never tries to notify for it — it already skips any item whose feature does not resolve. The notification itself (sendProjectOperatorNotification, which sendDigestNotification now delegates to byte-identically) goes to each AFFECTED project's own configured channels — Weftra has no instance-level operator channel, so an operator-default outage notifies every affected project once per streak, best-effort (a project with no channels configured simply gets no message; the attention item and the admin read remain the durable surfaces). The text carries only host, HTTP status, the rejection count, rejected_since (UTC), the project's name and a fixed remediation sentence — never source_kind, an operation name, a URL or provider text — and every dispatch also writes one token-free console.warn line. Increment 3a (RM-318 addendum) makes two BACKGROUND callers — the PR-merge poll (pollOnePrMergeStatus) and the native-loop ledger reconcile (refreshLoopLedgerPrStates) — wait out a credential already known to be rejected, instead of hammering the provider every tick (476 log lines in 40 minutes on 2026-09-25, each one spending a shared rate-limit quota). Both opt in via withVcsProvider(..., { background: true }); every other caller (admin reconcile, create-pr, revise, the review/security stages, every git transport path) is unaffected — opts omitted is byte-identical to before. The window is derived entirely from existing vcs_credential_health columns, no new timer or Map: attempt = consecutive_rejections - threshold, window = clamp(base * 2^attempt * (1 + jitter), base, cap) where base is prCheckIntervalMs, cap is a new runtime setting vcs_credential_backoff_max_ms (default 1h, bounds 60s..24h), and jitter is DETERMINISTIC per (tenant, project, host, attempt) — the first 4 bytes of a sha256 of that key read as a uint32, scaled to [0, 10%), so no Math.random and a test can predict the exact window. A rotated credential is tried at once: a new nullable column, rejected_credential_fingerprint, stores a Weftra-computed sha256 fingerprint of the token (never the token itself, never displayed anywhere), computed once from the token the call already resolved; the rejected- observation upsert starts a fresh streak only when the STORED fingerprint is non-NULL and differs from the incoming one, so a NULL (pre-migration) fingerprint never falsely resets a streak or re-fires an alert. An operator GitHub App's hourly re-mint is, by the same rule, always a "rotation" — roughly one retry per mint, which happens to match the cap's own default rate. Each rejection at/past threshold writes one vcs_credential_backoff actor-ledger row (metadata {tenant_id, project_id, host, attempt, until} only — no fingerprint, operation, URL or token) under a dedicated system principal, and exactly one console.warn per streak (only at attempt === 0). No dashboard surface changed — the human-actionable signal is already inc-2's attention item and notification; this only changes Weftra's own background behaviour while a human acts on it. Increment 3b (RM-318) adds expiry FORESIGHT: GitHub returns a github-authentication-token-expiration response header on every successful call made with an expiring token (a fine-grained PAT, or a classic PAT with an expiry set), and Weftra's Octokit hook.after/hook.error pair already saw it and threw it away. GitHubProvider.newObservedOctokit now reads it and hands it to VcsObserver's new, optional, tri-state 4th argument — undefined ("no information": every GitLab/Bitbucket call, always; a GitHub error response with no header), null (a successful response confirming the credential has no expiry to warn about — clears any stored value), or the raw header string. A github_app credential is CLEAR-ONLY — null on success, undefined on error, never a string — since a Weftra-minted installation token lives about an hour and is re-minted automatically (warning on it would be noise spec 458 already owns), while an expiry left on the row by the PAT the App replaced must not outlive that PAT. parseGithubTokenExpiration (src/services/vcs/credential-health.ts) is a pure, total parser for GitHub's two documented shapes (YYYY-MM-DD HH:MM:SS UTC/±HHMM) built with Date.UTC, never new Date(rawString), re-deriving the UTC calendar fields to catch a day Date.UTC would otherwise silently roll over; anything else returns null, treated as "unknown" and never written as a clear. A new nullable token_expires_at column holds the normalised ISO value, written by a plain UPDATE (recordVcsCredentialExpiry) only when it differs — never an upsert, so a row that was never observed stays absent; for the shared operator-default credential that one statement writes every operator-default row on the host, so a rotation clears the grouped warning even for projects with no VCS traffic since. A new runtime setting, vcs_credential_expiry_warn_days (default 7, bounds 1..60), decides how far ahead Weftra warns; vcsCredentialExpiryWarningWindow is the ONE pure predicate shared by the notifier, the attention item and the admin read's expiring_soon field, so the three cannot disagree. The warning is claimed with ONE atomic UPDATE on a second new column, expiry_warned_for (claimVcsCredentialExpiryWarning) — the dedup group is every operator-default row on a host (one shared credential, one notification) or the single row otherwise; a changed expiry (rotation, or the same credential reporting a later date) always differs from the stored value and so re-arms automatically. The claim guarantees "never twice", not delivery: the one send goes through the claiming project when it has a notification channel, else through another operator-default project on that host that does, and is otherwise best-effort (the attention item and admin read are the durable surfaces). A pure computeVcsCredentialExpiryAttentionItems (src/services/fleet-attention.ts) derives an admin-only vcs_credential_expiring attention item at READ time, grouped by host exactly like inc-2's item, and EXCLUDES any row already at/over the rejection threshold (inc-2's vcs_credential_invalid owns that row instead — never both). GET /api/admin/vcs-credential-health gains three per-row fields: token_expires_at, expiring_soon, and backing_off_until (inc-3a's backoff window resolved with current settings, as ISO, or null). The dashboard's GitHub-credential card shows the expiry and any active backoff for the operator-default github.com row(s), built with createElement/textContent, never innerHTML. No VCS call is ever gated or deferred on an expiry; no expiry is ever recorded for a GitHub App installation token; no new route, project-key path or tenant-visible field is added; no fingerprint, raw header or token ever reaches a notification, an attention item, a console line, or the admin read.
Public surfaces:
GET /api/admin/vcs-credential-healthGET /api/features/admin/attention#vcs_credential_invalidGET /api/features/admin/attention#vcs_credential_expiringPUT /api/admin/settings/runtime#vcs_credential_alert_thresholdPUT /api/admin/settings/runtime#vcs_credential_backoff_max_msPUT /api/admin/settings/runtime#vcs_credential_expiry_warn_days
Docs:
docs/OPERATIONS.md
Attention Source Seam
Status: 🟢 SHIPPED Audience: Operator
Spec 443 inc-68 (RM-295 part 2a) — every fleet-attention item except two (the deferral fold and the VCS-credential fold, hand-wired and unchanged by this increment) comes from the features table. This adds a small seam, src/services/attention-sources.ts, so a non-feature source registers instead of becoming another hand-wired block: an AttentionSource interface ({name, collect(scope, now)}), a frozen ATTENTION_SOURCES array (no module-level Map/Set, no state), and collectFromAttentionSources — called by both GET /api/features/admin/attention and the SLA sweep (runAttentionSweepOnce) before the summary/breach computation. A source that throws is logged and contributes nothing; it never 500s the board or crashes the sweep. The first registered source: work-escalation triage. A worker escalation (work_escalations, spec 366/419) sitting submitted had no SLA, no attention item and no breach notification — only the write-side 500-row cap. GET /api/features/admin/attention and the sweep now derive ONE item PER PROJECT (never per escalation — a project can hold up to 500 open rows), grouped by the same "qualifying" definition countOpenWorkEscalationsForProject already uses (status = 'submitted' AND the originating feature is not evaluation-triggered). The title carries counts only (open/blocking); state_entered_at is the oldest qualifying escalation's created_at (aged with parseDbTimestampMs, never new Date(...) — see the host time-zone blindspot); feature_id is that oldest escalation's originating_feature_id, so the existing notifier can route through it. SLA is a new setting, fleetEscalationTriageSlaHours (env FLEET_ESCALATION_TRIAGE_SLA_HOURS, default 24, falling back to 24 on a non-positive/unparseable value — same pattern as fleetStuckSlaHours). No worker- authored text (the escalation's problem, evidence, or any principal/email) ever reaches the item, the summary, or a notification — only counts, ids and timestamps. An approver-scoped read is filtered to their own projects in SQL (getEscalationTriageAttentionRows, src/models/work-escalations.ts), not by a post-filter. public/fleet.html's action queue shows the item with a Triage link to /workers.html?project=<id> (the Workers tab's existing escalation-triage surface, spec 419) built with the same escAttr-escaped pattern as its sibling action links.
Public surfaces:
GET /api/features/admin/attention#triage_escalationpublic/fleet.html#action:triage_escalation
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Security And Deploy Attention
Status: 🟢 SHIPPED Audience: Operator
Spec 443 inc-69 (RM-295 part 2b) — the last two sources on the attention-source seam (src/services/attention-sources.ts), completing RM-295 part (2): every source the roadmap named is now on the fleet attention board. security_disposition (one item per FEATURE): an implemented feature carrying a still-LIVE security_final or security_fixer_final terminal — the security stage capped, or the Security Fixer escalated/rejected a premise, and nothing has decided it yet. Liveness uses the EXACT SAME predicate /fix-security itself uses (listLiveSecurityTerminals, a thin export over collectBlockingTerminals in src/services/review/security-fix-eligibility.ts — no new rule). A coarse SQL pre-filter (getSecurityDispositionAttentionCandidates, src/models/features.ts) narrows to implemented/not-archived/has-a-terminal candidates; the exact per- candidate ledger check then drops anything superseded by name or cleared by a later findings_deferred event. Two simultaneous live terminals still yield ONE item, aged from the OLDER. SLA: fleetSecurityDispositionSlaHours (env FLEET_SECURITY_DISPOSITION_SLA_HOURS, default 24). No finding text, terminal reason, or premise-rejection text ever reaches the item, the summary, or a notification. approve_deploy (one item per PROJECT): a project with at least one deploy_runs row pending_approval (getDeployApprovalAttentionRows, src/models/deploy.ts, one grouped query, no schema change). feature_id: '' (the feature-less convention); title carries counts only. SLA: fleetDeployApprovalSlaHours (env FLEET_DEPLOY_APPROVAL_SLA_HOURS, default 24). Never reads or emits recipe_snapshot, image_snapshot, output, error or triggered_by. Deliberately NOT breach-notified in this increment — runAttentionSweepOnce already skips a feature-less item, and that skip is unchanged here; project-level breach notification for feature-less items is a named follow-up. public/fleet.html renders a Decide link for security_disposition (the PR when set, else the project) and a Review runs link to /runs.html for approve_deploy, both built with DOM APIs (appendDispositionActions, the same pattern appendReauthActions established) so no escaping pin moves. AttentionSummary gains security_parked and deploy_pending_approval. Both sources honour approver scope in SQL.
Public surfaces:
GET /api/features/admin/attention#security_dispositionGET /api/features/admin/attention#approve_deploypublic/fleet.html#action:security_dispositionpublic/fleet.html#action:approve_deploy
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Review Disposition Attention
Status: 🟢 SHIPPED Audience: Operator
Spec 443 inc-83 (RM-280 addenda 1-2, part 2c-2) — a review round the operator's claim-check sent to a person becomes its own fleet-attention item, same seam as security_disposition/approve_deploy above. run-review.ts's escalate branch now spreads one additive key into the reviewer_final payload it already writes: routed_by: 'unchanged_at_base', recorded only when the unchanged-at-base rung decided THIS round (routedByUnchangedAtBase). Every other escalation and every approval is byte-identical — the key is absent, never null. reviewDispositionSource (src/services/attention-sources.ts): one item per FEATURE whose newest reviewer_final is outcome: 'escalated' with routed_by: 'unchanged_at_base' and has no later revise_started event. A coarse SQL pre-filter (getReviewDispositionAttentionCandidates, src/models/features.ts — implemented/not-archived/has-a-pr/has-a-reviewer_final) narrows candidates; the exact per-candidate ledger check (highest-seq reviewer_final, no later revise_started) decides qualification. An unparsable payload is a skip, never a throw. status: 'review_parked', action: 'review_disposition', feature_id set to the parked feature (breach key ${feature_id}:review_disposition, distinct from that feature's own merge item). SLA: config.fleetMergeSlaHours — the same class as the merge item it sits beside, no new setting. No finding text, check target, command or exit code ever reaches the item, the summary, or a notification — only the feature's own title, ids, timestamps and PR URL — and no string says the finding is wrong, disproved, dismissed or resolved. Clears when the feature leaves implemented, a revise_started event is recorded, or a later round records a newer reviewer_final. No new dismiss route. Rows recorded before this increment carry no routed_by and produce no item — not backfilled, not inferred from reason text. public/fleet.html renders a Decide link (the PR when set, else the project), built with DOM APIs in appendDispositionActions alongside security_disposition/approve_deploy, so no escaping pin moves. AttentionSummary gains review_parked.
Public surfaces:
GET /api/features/admin/attention#review_dispositionpublic/fleet.html#action:review_disposition
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.md
Engine Credential Registry
Status: 🟢 SHIPPED Audience: Admin
Spec 457 (RM-321) — an admin-managed registry of engine credentials (the operator's engine accounts) and, since inc-1b, selection from it. GET/POST/PATCH/DELETE /api/admin/engine-credentials (admin-only, 403 for a project key) let an admin register, list, edit, enable/disable and delete a credential declaring which engine/auth mode it serves — the claude-code engine, or (since inc-2d) a non-default engine declared in FA_ENGINE_PROFILES with at least one authEnv key. ref is an opaque handle validated against a closed set of shapes (an existing secret-material ref, host-oauth-dir:<absolute path> for oauth, or host-env:<NAME> matching ANTHROPIC_API_KEY(_[A-Z0-9]+)* for api) and never dereferenced at registration time — a pasted secret (secret/value/key/ token/credentials_json/api_key in the body) is refused outright, nothing written. Every mutation is recorded on the actor-events ledger with the ref's SHAPE only, never the string. inc-1b (src/services/engine-credential-selection.ts) wires the registry into implement.ts and implement-spec-kit.ts, which pick the least-recently-used ENABLED, health-clear, currently-resolvable account for the run's engine/auth mode immediately before createRuntime; every other run type and every tenant-configured run (resolveRunCredentialSource's non-operator-default sources) is unchanged and never reads this table. An empty registry or no eligible candidate falls back byte-identically to the pre-inc-1b behavior. One engine_credential_selected run event per run that had candidates records the outcome and the account's id only — never its ref, path, env-var name, or label. Since inc-1d (RM-321), a revise round (revise.ts) and a Security Fixer round (security-fixer.ts) select and record through the SAME selector, immediately before their own createRuntime, with an additive flow: 'revise' | 'security_fixer' field on their selection event (absent on every implement-flow event, which still means an implementation run). Since inc-1g (RM-321), the three review paths that run a model — the spec-conformance reviewer, the designer review, and the adversarial security reviewer (src/services/review/reviewer-engine-dep.ts, security-engine-dep.ts) — also select through the SAME selector via one shared helper (src/services/review/review-engine-credential.ts), immediately before their own createRuntime. A review round's pick is recorded on a DISTINCT run-event type, review_engine_credential_selected ({engine_id, auth_mode, outcome, engine_credential_id, applied, role, ineligible}, role one of conformance_reviewer / designer_reviewer / security_reviewer, fixed by the call site) — never the implementation-flow engine_credential_selected type, so a review round can never rebind the feature's own credit-pause/failover binding (the four readers that treat a feature's LATEST engine_credential_selected as the account it is bound to are unchanged and never see a review event). A review round's own exhausted/auth-failure signal only ever SUSPECTS the selected account (never a health write); there is no failover for a review round (least-recently-used ordering alone moves the next round to another account once the probe confirms a block). The repro-reproduction reviewer (repro-review-deps.ts, authMode: 'none', no engine) is unaffected — it never invokes an engine, so it never reads the registry. Since inc-1c (RM-321), a run's own credit/auth signal can only make the selected account SUSPECT (recordEngineCredentialSuspect) — nothing is written to health_status/health_until from a run. Weftra confirms for itself with a fixed, minimal probe call run in a fresh Weftra-owned container (RUNTIME=docker only, no tenant code/env/data/services), rate-limited to one probe per account per 15 minutes (the limit is read from the actor-events ledger, so it survives a restart). Only the probe's own classified outcome writes health; the write and its engine_credential_probe_completed ledger row commit atomically. Four actor-only actions record the lifecycle (engine_credential_suspected/_probe_started/_probe_completed/ _health_reset), none ever carrying a ref, path, env-var name/value, label, or the probe's response text — and none of it reaches any tenant's own run_events. An admin clears a block with PATCH .../:id {reset_health:true} (the literal true, the only field). Since inc-1e (RM-321), a project paused on an account Weftra's own probe has confirmed blocked fails over automatically: when every one of a project's paused features ran on a registered account that is CURRENTLY health-blocked, and another account for the same engine/auth mode is eligible right now, Weftra releases that project's credit pause and resumes its paused features through the SAME recovery path a normal credit-check recovery uses — never a run's own signal, only Weftra's own probe-confirmed health (src/services/engine-credential-failover.ts). Two triggers, both Weftra-observed: right after a probe confirms a block, and — for the rate-limited second hit — right after a pause lands on an account already confirmed blocked. Recorded as one engine_credential_failover actor-ledger row per evaluation that found a registered-account feature (including a skipped one, with why); nothing new ever reaches a tenant's own run_events. When every account for an engine/auth mode is blocked, one admin attention item and one Engine Accounts panel line name it (pool_status on GET .../engine-credentials); a push notification is deferred (no instance-level operator channel exists yet). Since inc-2d (RM-321), a non-default engine takes accounts through host-env-suffix:<SUFFIX> (api only): for each declared key K the account reads host variable K_<SUFFIX>, all keys or none, and the value reaches the container under the name K. Derived names Weftra reads as secrets are refused at registration, selection and run start. Staging lives only in DockerRuntime; selection normalises a non-default engine to its api rows. Health is NOT judged for these accounts yet: a run signal writes nothing, the probe refuses without a container, and no failover moves a run (follow-up inc-2d2). Since inc-2e1 (RM-321), an admin can dedicate an account to ONE project. POST /api/admin/engine-credentials takes an optional project_id: absent or '' registers a shared operator-pool account as before; a non-empty id must name an existing project, else 400 with nothing written. The row's tenant_id is stamped from that project's own row in the same transaction, never from the body, and tenant_id in a body is refused. project_id cannot be changed by PATCH (refused as an unknown field). Selection tries the run's own project's dedicated rows first (LRU), then the shared pool; a dedicated row is never a candidate for any other project. Every selection event carries scope (project, operator_pool, or null when nothing was selected) and each ineligible entry carries scope. The five selector call sites pass the run's feature.project_id. The pool-only readers (pool_status, the failover eligibility check, the attention source) still read the shared pool alone; inc-2e2 makes them project-aware. The usage rollup includes dedicated rows, each with its project_id. A dedicated account is an admin-registered preference with isolation, not a quota or spend boundary: when none of a project's dedicated accounts is usable, its runs fall through to the shared pool, and the ledger's scope says which pool was drawn. Dashboard panel: Engine Accounts tab, DOM-built (no innerHTML), states which run types draw from the pool, that secret-material accounts aren't selected yet, the suspect-then-probe-confirmed health flow with a Reset health button on blocking rows, and — since inc-1e — a "no usable account" line per engine/auth mode with zero eligible accounts. Since inc-2a (RM-321), an admin can see how each account has been USED, read-only from the same ledgers: GET /api/admin/engine-credentials/usage?from&to (admin-only, 403 for a project key, registered before /:id so it is never captured as one) returns, per registered account, its selected/applied run counts, distinct features, last-selected time, why it was passed over when a run fell back to the instance default (ineligible_by_reason), how often it was suspected, its probe outcomes, and how many failovers moved work off it — plus one instance-level fallback_runs figure. A deleted account keeps its history (registered: false). from is inclusive, to exclusive, both compared in UTC; default window 7 days, capped at 90. Its registered accounts are the shared operator pool (default tenant, project_id = '') plus, since inc-2e1, every project-dedicated account (a non-empty project_id), selected by explicit columns; each entry carries its project_id ('' for a shared account). A row with a non-default tenant and no project stays absent. The response carries ids, counts and timestamps only — never a ref, an oauth path, an env-var name, or a label. Adds no write, no actor-event action, and no schema change; touches no selection/runtime/ flow/ledger-writer code. Since inc-2b (RM-321), each of the five engine-call-boundary accumulators (implement, implement-spec-kit, revise's two sites, the Security Fixer) also writes a new engine_usage_recorded run event — {flow, engine_id, engine_credential_id, applied, cost_usd, cost_estimated, input_tokens, output_tokens} — immediately after its existing accumulateFeatureUsage call, and only when that run itself wrote an engine_credential_selected event (a tenant-BYO/brokered run never writes one). The usage rollup counts applied: true events naming an id into that account's engine_calls, cost_usd (summed over non-null values; null, never 0, when nothing measured a cost), cost_unmeasured_calls, cost_estimated, input_tokens and output_tokens; an applied: false or null-id event is never charged to an account — it folds into a new instance-level unattributed block with the same fields instead. A write failure is caught and logged (feature + flow only, never the account ref), non-fatal because the spend already happened; review-round cost stays out of scope for this slice (the panel and docs say so plainly) and there is no historical backfill. The usage route and its guard are unchanged; its OpenAPI response schema and the dashboard panel gain the new fields. Since inc-1g (RM-321), the same rollup additionally counts review rounds, separately from implementation/revise/fixer runs: per account, review_rounds_selected, review_rounds_applied, and review_rounds_by_role (applied-only, broken down by conformance_reviewer/designer_reviewer/security_reviewer); instance-level, fallback_review_rounds. ineligible_by_reason counts an account's appearances across BOTH engine_credential_selected and review_engine_credential_selected none-eligible events. runs_selected/runs_applied/features/fallback_runs keep their inc-2a meaning unchanged. Dashboard: the Engine Accounts panel gains a 7/30/90-day window selector, the usage columns per account row (including, since inc-1g, a review-rounds column with a per-role breakdown in its title), and the instance fallback line (including the review fallback count), all DOM-built. Since inc-2d2a (RM-321), an admin can ask Weftra to check an account: POST /api/admin/engine-credentials/:id/check (admin-only, 403 for a project key, no body, 202 / 409 / 429) runs Weftra's fixed check on the account's OWN engine, in Weftra's own container. For the default engine that is the existing probe. For another engine it runs only where the profile declares probeEgressHosts (a new optional FA_ENGINE_PROFILES field), and it is two-valued: a PONG is confirmed_ok (the account works); anything else is "could not confirm" and writes nothing. For such an account a check never marks it unhealthy, and a check cannot determine that it is blocked. Shares the 15-minute window with suspicion probes; recorded as engine_credential_probe_started (trigger admin, attributed to the requesting admin) and engine_credential_probe_completed. The list and single reads carry last_check. The dashboard gains a Check button and a plain-English last-check line per account, DOM-built. No schema change, no run event.
Public surfaces:
GET /api/admin/engine-credentialsGET /api/admin/engine-credentials/:idGET /api/admin/engine-credentials/usagePOST /api/admin/engine-credentialsPATCH /api/admin/engine-credentials/:idDELETE /api/admin/engine-credentials/:idGET /api/features/admin/attention#restore_engine_accountpublic/index.html#tab-engine-credentialspublic/index.html#data-tab:engine-credentialspublic/index.html#on:submitEngineCredentialpublic/index.html#on:resetEngineCredentialHealthpublic/index.html#on:loadEngineCredentialUsage
Docs:
docs/USER_GUIDE.mddocs/OPERATIONS.mddocs/design/agent-inputs.md
Github App Operator Credential
Status: 🟢 SHIPPED Audience: Operator
Spec 458 inc-1a (RM-323) — the operator-default github.com VCS credential can be a GitHub App instead of a single host-wide GITHUB_TOKEN PAT. Built for two measured problems: a fine-grained PAT cannot read/write GitHub's Checks API (App-only), so Weftra's own provenance check falls back to commit statuses; and the operator's per-user 5,000/hour quota is shared by every tool the operator runs, and ran out four times on 2026-09-26. Configured entirely through operator env — GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY_PATH (exactly one set is a misconfiguration, logged at startup as a named github_app_misconfigured line naming the missing variable, never a value) and an optional GITHUB_APP_INSTALLATION_ID to pin one installation. No route accepts any of the three — caller input is not configuration. A new module, src/services/vcs/github-app.ts, hand-signs the App's RS256 JWT via Node's built-in crypto (no new dependency), resolves the installation (pinned id, or GET /repos/{owner}/{repo}/installation, a 404 there is the named github_app_not_installed error), and mints via POST /app/installations/{id}/access_tokens with {"repositories":["<repo>"]} — the token is scoped to exactly that one repository. A module-level cache reuses a token while more than 5 minutes remain and single-flights concurrent mints for the same repo. resolveVcsCredential's operator-default branch (src/services/vcs/credential.ts) is the only resolution change: it applies when a project's repo_url host is exactly github.com and the App is configured, and every other case (a project's own credential, GitLab, Bitbucket, the unrouted-host fallback, the non-default-tenant isolation throw) is byte-identical to before. credentialKind ('pat' | 'github_app') and, for an App, github_app: {app_id, installation_id} (ids only, never material) are new fields on VcsCredentialResolution; a shared vcsCredentialEventFields helper adds them to every vcs_credential_resolved run event (implement, revise, gate-rerun, weftra-merge ×2, loop canary) — a source-derived test requires every such event to go through it. Every failure mode (misconfigured, not installed, an unreadable key file, a non-2xx mint) throws a named GithubAppError and never falls back to GITHUB_TOKEN. GET /api/admin/vcs-credential-health (admin-only, unchanged guard) gains operator_default_github: the App's status (configured, misconfigured, app_id, installation_pinned, last_mint_at, a redacted/bounded last_mint_error) plus kind: 'github_app'|'pat'|'none' — no field ever carries a token, a JWT, key bytes, or the key path. A minted token is ghs_…, already matched by the existing GitHub-token redaction pattern. Inc-2 (RM-323) makes the App credential the mechanism Weftra actually reads and writes check-runs with, and gives the operator a way to see it. GitHubProvider (src/ services/vcs/github.ts) gains an optional credentialKind: 'pat'|'github_app' (default 'pat', so every existing call site is byte-identical), plumbed through resolveCredentialedVcsProvider at both the github.com branch and the unrouted-host fallback branch. Only when it is 'github_app' do postCheck/fetchPrChecks record a Checks-API outcome via recordGithubAppChecksOutcome('ok'|'denied', 'postCheck'| 'fetchPrChecks') (src/services/vcs/github-app.ts) — a non-rate-limited 403 now means "the App is missing the Checks: Read & write permission" (actionable) rather than "this is a PAT" (expected); the existing commit-status fallback, credential_lacks_check_write classification, and the credential-health 403 amnesty are all unchanged for both kinds. GET /api/admin/vcs-credential-health's operator_default_github gains checks: { last_ok_at, last_denied_at, last_denied_operation }, all null until observed — no repository, project, tenant id, token, or error text, ever. An admin-only "GitHub credential" dashboard card (public/index.html, DOM-built, no innerHTML) shows the credential kind, App status, and the Checks line next to the proven-handle enforcement card. Inc-1b (RM-323) lets a PROJECT bring its OWN GitHub App instead of only the operator's — the spec-215 tenant-credential path was PAT-only until now, so a project in a different org/account than the operator's App could only get check-runs and its own rate limit by the operator widening its own App onto that customer's repositories, exactly the ambient authority spec 215 exists to remove. tenant_credentials gains credential_kind ('pat'|'github_app', default 'pat' — every pre-existing row reads back unchanged), github_app_id, github_installation_id (both ids, never secrets; github_app is valid only on a purpose='vcs' row). The ref of a github_app row dereferences through the SAME resolveSecretMaterial seam to api-key-shaped material whose value is the App's PEM private key — never stored, never in argv, never logged. src/services/vcs/github-app.ts gains a parameterised getProjectGithubAppInstallationToken({projectId, appId, privateKeyPem, installationId?}, owner, repo) that reuses 1a's JWT signing, installation lookup, and mint rather than forking them, on its OWN cache/single-flight Maps keyed projectId:appId:sha256(privateKeyPem) — never the operator's owner/repo-keyed Maps, so two projects on the same repository with different Apps never share a token and a rotated key always re-mints. Project mints never touch the operator-default lastMintAt/lastMintError/Checks scalars; a module-level projectGithubAppStatus Map (keyed by project id) holds each project's own last_mint_at, a redacted/bounded last_mint_error, and Checks outcome, evicted on PUT/DELETE of that project's credential. In resolveVcsCredential (src/services/vcs/credential.ts), a tenant-configured github_app row refuses unless the project's repo_url host is exactly github.com (named github_app_host_not_github), mints for the project's own owner/repo, and returns credentialKind: 'github_app' with a NEW github_app_scope: 'operator_default' | 'project' field on VcsCredentialResolution (kept separate from the pre-1b github_app: {app_id, installation_id} object so that object's shape is unchanged); vcsCredentialEventFields adds github_app_scope to the ledger only when present. It never falls back to the operator App or GITHUB_TOKEN — every failure (non-github.com host, an undereferenceable ref, non-PEM material, a 404 install lookup, a non-2xx mint) throws a named GithubAppError. GitHubProvider gains githubAppScope/githubAppProjectId constructor params (defaulting to 'operator_default'/null, so every pre-1b 3-arg call site is unaffected) so postCheck/fetchPrChecks route a project App's Checks outcome to that project's OWN status, never operator_default_github.checks. The admin route PUT /api/tenants/projects/:projectId/vcs-credentials (still admin-only, no project-key path) accepts an optional kind: 'pat'|'github_app' (default 'pat' — a body with no kind is byte-identical to before this increment): github_app requires a decimal-digit app_id, accepts an optional decimal-digit installation_id, requires the project's repo host to be exactly github.com, and preflights the dereferenced material with crypto.createPrivateKey (stores nothing on failure, mints nothing at configuration time); app_id/installation_id sent with kind absent or 'pat' is a 400. GET returns {provider, authMode, ref} for a plain PAT row (unchanged) and additionally kind, app_id, installation_id, and this project's mint/Checks status for a github_app row — never key bytes, a token, or a JWT. redactSecrets/the ledger floor gain a PEM private-key block pattern so a leaked key is masked the same way every other credential shape is. No dashboard UI is added — deferred to a follow-up; the surface is this admin API.
Public surfaces:
GET /api/admin/vcs-credential-health#operator_default_githubGET /api/admin/vcs-credential-health#operator_default_github.checkspublic/index.html#GitHub credential cardpublic/index.html#on:loadGithubCredentialStatusGET /api/tenants/projects/{projectId}/vcs-credentials#kindPUT /api/tenants/projects/{projectId}/vcs-credentials#kind
Docs:
docs/SETUP.mddocs/OPERATIONS.md
Openai Compatible Engine
Status: 🧪 BETA Audience: Operator
Spec 520 inc-1 (RM-435) — an engine for any OpenAI-compatible hosted model, with no new engine integration. An FA_ENGINE_PROFILES entry may declare openaiCompatible: { baseUrl, model, apiKeyEnv? } (host configuration only, never a request, project or repo field), validated at startup in the existing profile parser: baseUrl an absolute https:// DNS-host URL (http:// refused), model non-empty and at most 200 characters, apiKeyEnv a key of the profile's own authEnv; each failure names its field. Weftra drives one shipped, version-pinned ACP harness (OpenCode opencode acp, installed in the agent layer and the curated runtime images) and writes its provider configuration as a file outside the workspace; the key is referenced by env-var name only, so it is in neither argv, the file, the run log nor provenance. The baseUrl host (and no other) joins the run's egress allowlist and the profile's probeEgressHosts, and is never used to widen an operator bound. POST /api/admin/engines/:id/smoke-test and the engine account check do a sandboxed one-prompt round trip and report confirmed_ok | auth_failed | unreachable | inconclusive. Cost stays null unless the endpoint reports usage Weftra can price, and the token-budget cap still applies (an engine that cannot be capped refuses the task). The live hosted-endpoint acceptance is operator-run and recorded as pending in the increment's verification record. Spec 520 inc-2 (RM-435) lets the same block name a model on the operator's own machine: an http:// base URL is accepted only when its host is also listed in FA_EGRESS_PRIVATE_HOSTS (loopback, localhost, link-local, 0.0.0.0 and IPv6 are never accepted), the run's allowlist gets the exact host+port entry and the egress forwarder a bounded plain-HTTP forward for it alone, every decision is an audit line marked PLAIN, and the smoke test and account check report the same four outcomes. The live local-model acceptance is operator-run. Spec 520 inc-3 (RM-435) lets the SWE-bench harness run on any declared engine with --engine-profile <id>: an unknown id is refused before any work, every arm project gets that engine, the engine list carries the model of an OpenAI-compatible profile only, the report names engine_profile {id, model}, and an arm with unreported cost carries cost_unmeasured and renders ≥ $X or unknown. The paired Pro run is operator-run.
Public surfaces:
POST /api/admin/engines/{id}/smoke-test#outcomeGET /api/admin/engines
Docs:
docs/ENGINES.mddocs/RUNTIME_IMAGES.md
Stop Everything On This Host
Status: 🟡 PARTIAL Audience: Admin
Spec 543 inc-1 (RM-375) — one admin action that stops everything this instance is running. POST /api/admin/stop (admin-guarded; empty body, every body field ignored, no caller-supplied scope or command) records an instance.stop actor event first (a failed write returns 500 and does nothing), then starts a pure drain with no auto-restart, records the durable cancel intent and cancels every live job this instance owns whose status is cancellable (a job on any other status is torn down with its status kept and reported as killed_not_cancelled), and force-removes this instance's own sandbox containers — listed by both reaper label families and re-verified (managed, own instance, no family conflict) before removal; foreign-instance, missing-instance and conflicting containers are refused and reported, never removed, and the container step is reported as skipped under RUNTIME=local. It ends with an instance.stop.completed actor event carrying the report. The 200 report carries ids and fixed reason categories only (never raw stderr), isolates per-item failures, and names what it does not yet cover (worker episodes, run tokens, external harness containers). The instance stays draining until DELETE /api/admin/drain. The dashboard's Safe drain & restart card gains a confirmed "Stop everything on this host" danger button that renders the report.
Public surfaces:
POST /api/admin/stoppublic/index.html#stop-everything-btn
Docs:
docs/OPERATIONS.mddocs/USER_GUIDE.md
Limitations: Increment 1 of spec 543. Worker episodes, run tokens and external harness containers are not stopped yet (increment 2); multi-instance or org-wide stop is out of scope.