The Weftra Build Tracker
GENERATED from docs/features.yml + src/tools/north-star-pillars.ts — do not hand-edit; regenerate with npm run docs:build-tracker.
This page answers "how far along is Weftra, per north-star pillar?" — rendered directly from the capability traceability manifest so it cannot drift from the traced source of truth. It republishes automatically whenever a shipped feature updates docs/features.yml (see docs/design/target-architecture.md for the north star this tracks progress against).
Honest caveat: Shipped/Beta means built and self-dogfooded by FA's own self-build loop — not proven on a legacy production monolith at scale.
Like the Capabilities page, this page covers only manifest entries with a user, operator, or admin audience; internal-only and contributor-facing capabilities are omitted from the listing and from every figure below.
Header metrics
- Total capabilities (published audiences): 119
- Shipped: 91
- Beta: 1
- Partial: 23
- Planned: 4
- Superseded: 0
- Internal: 0
- Shipped-or-partial vs planned: 115 shipped-or-partial vs 4 planned (77% of the tracked north-star scope is shipped; 97% is shipped or underway)
North-star pillar coverage
Fleet / portfolio orchestration (the wedge)
A persistent cross-project queue, unified portfolio observability, and governed delivery across every enrolled repo at once — the durable wedge nobody else is building.
███████░░░ 70% (7 done / 10 on the path) — mostly done
- Notification Channels —
Shipped— The notification-dispatch service (with transition dedup) and its Telegram, Discord, Slack, WhatsApp, and Email channel adapters. - Dashboards —
Partial— 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. Outstanding: 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. Afailedrun 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 —
Partial— 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. Outstanding: The assistant's visible text is not yet kept after a workspace is cleaned up. - Cost Tracking —
Partial— 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 agoverned_runsmeter, and a token-derived ESTIMATED cost for engines (ACP — copilot and others) that report no dollar figure of their own, markedcost_estimatedon 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. Outstanding: Usage rollups and CSV exports still sum estimated and actual cost into one figure. - Mentions Collaboration —
Shipped— @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 mergewhose gates and re-review run against the MERGE RESULT, not the branch (spec 249), and@weftra revise, a second door onto the existingPOST /api/features/:id/reviseflow for a VCS-confirmed project approver acting from the pull request (spec 113 inc-2 / RM-047). - Fleet Fan Out —
Shipped— 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. - Build Tracker —
Shipped— 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. - Attention Source Seam —
Shipped— 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 thefeaturestable. This adds a small seam,src/services/attention-sources.ts, so a non-feature source registers instead of becoming another hand-wired block: anAttentionSourceinterface ({name, collect(scope, now)}), a frozenATTENTION_SOURCESarray (no module-level Map/Set, no state), andcollectFromAttentionSources— called by bothGET /api/features/admin/attentionand 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) sittingsubmittedhad no SLA, no attention item and no breach notification — only the write-side 500-row cap.GET /api/features/admin/attentionand 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" definitioncountOpenWorkEscalationsForProjectalready uses (status = 'submitted'AND the originating feature is not evaluation-triggered). The title carries counts only (open/blocking);state_entered_atis the oldest qualifying escalation'screated_at(aged withparseDbTimestampMs, nevernew Date(...)— see the host time-zone blindspot);feature_idis that oldest escalation'soriginating_feature_id, so the existing notifier can route through it. SLA is a new setting,fleetEscalationTriageSlaHours(envFLEET_ESCALATION_TRIAGE_SLA_HOURS, default 24, falling back to 24 on a non-positive/unparseable value — same pattern asfleetStuckSlaHours). No worker- authored text (the escalation'sproblem, 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 sameescAttr-escaped pattern as its sibling action links. - Security And Deploy Attention —
Shipped— 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): animplementedfeature carrying a still-LIVEsecurity_finalorsecurity_fixer_finalterminal — 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-securityitself uses (listLiveSecurityTerminals, a thin export overcollectBlockingTerminalsinsrc/services/review/security-fix-eligibility.ts— no new rule). A coarse SQL pre-filter (getSecurityDispositionAttentionCandidates,src/models/features.ts) narrows toimplemented/not-archived/has-a-terminal candidates; the exact per- candidate ledger check then drops anything superseded by name or cleared by a laterfindings_deferredevent. Two simultaneous live terminals still yield ONE item, aged from the OLDER. SLA:fleetSecurityDispositionSlaHours(envFLEET_SECURITY_DISPOSITION_SLA_HOURS, default 24). No finding text, terminalreason, or premise-rejection text ever reaches the item, the summary, or a notification.approve_deploy(one item per PROJECT): a project with at least onedeploy_runsrowpending_approval(getDeployApprovalAttentionRows,src/models/deploy.ts, one grouped query, no schema change).feature_id: ''(the feature-less convention); title carries counts only. SLA:fleetDeployApprovalSlaHours(envFLEET_DEPLOY_APPROVAL_SLA_HOURS, default 24). Never reads or emitsrecipe_snapshot,image_snapshot,output,errorortriggered_by. Deliberately NOT breach-notified in this increment —runAttentionSweepOncealready 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.htmlrenders a Decide link forsecurity_disposition(the PR when set, else the project) and a Review runs link to/runs.htmlforapprove_deploy, both built with DOM APIs (appendDispositionActions, the same patternappendReauthActionsestablished) so no escaping pin moves.AttentionSummarygainssecurity_parkedanddeploy_pending_approval. Both sources honour approver scope in SQL. - Review Disposition Attention —
Shipped— 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 assecurity_disposition/approve_deployabove.run-review.ts's escalate branch now spreads one additive key into thereviewer_finalpayload 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 newestreviewer_finalisoutcome: 'escalated'withrouted_by: 'unchanged_at_base'and has no laterrevise_startedevent. 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-seqreviewer_final, no laterrevise_started) decides qualification. An unparsable payload is a skip, never a throw.status: 'review_parked',action: 'review_disposition',feature_idset to the parked feature (breach key${feature_id}:review_disposition, distinct from that feature's ownmergeitem). SLA:config.fleetMergeSlaHours— the same class as themergeitem 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 leavesimplemented, arevise_startedevent is recorded, or a later round records a newerreviewer_final. No new dismiss route. Rows recorded before this increment carry norouted_byand produce no item — not backfilled, not inferred fromreasontext.public/fleet.htmlrenders a Decide link (the PR when set, else the project), built with DOM APIs inappendDispositionActionsalongsidesecurity_disposition/approve_deploy, so no escaping pin moves.AttentionSummarygainsreview_parked.
Everything as governed code (terminal thesis)
Any diff, from any engine, carrying a verifiable record of how it was produced. Governance only counts at a choke point with teeth — owning the merge/PR boundary and the deploy gate.
████████░░ 83% (10 done / 12 on the path) — mostly done
- Feature Lifecycle —
Shipped— 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 withresult subtype: "success". A pure classifier (src/services/agent/engine-auth-failure.ts) reads the envelope fields the pinned CLI writes on theassistantevent 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. Theawaiting_authrecovery 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, chargedrun_failurecauseengine_auth, which is never attributed to the operator and is excluded from Weftra's automatic failed-feature retry. A feature paused inawaiting_authnow 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 withPOST /api/features/:id/revert. Weftra builds the revert in the sandbox on a freshrevert/<slug>-<id8>branch, opens a draft revert PR with the project's own credential, and recordsrevert_requested, thenrevert_pr_openedorrevert_refused, on the original feature's ledger. Nothing is pushed to the base branch, the feature staysmerged, 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 recordsrevertedon the original when it merges (the original staysmerged). 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. - Empty Commit Guard Excludes Fa Own Artifacts —
Shipped— The empty-commit guard behind everyimplement/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 atdocPath, the verification artifact captured from a declaredverify_command, or any declaredgenerated_pathsthis 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/**anddocPathare 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-scopedfaWrittenPathsset, 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/**nordocPath, 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 inimplement.ts/implement-spec-kit.tsto 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. - Contribution Lineage —
Shipped— 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 refusalgate-rerunalready applies, spec 443 inc-29). Each observed commit is classifiedasserted(anexecution_checkpointsrow for this feature names this SHA as one Weftra produced) orexternal(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. Recordsartifact_lineage_observed(one marker per observation: head/from/relation/commit_count,unavailableon a provider failure,truncated+total_commitsbeyondCONTRIBUTION_LINEAGE_MAX_COMMITS_PER_OBSERVATION(50)),artifact_contribution_observed(one row per new commit, oldest first — SHA, attribution, a bounded[A-Za-z0-9._-]-onlyreported_author_handlewhen the provider supplies one,handle_verified: falsealways, never an email, never a raw commit message), andlineage_discontinuity(from_sha/to_sha/relation) when the PROVIDER REPORTS the head isbehindordivergedrelative 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 singlerecordRunEventscall — all-or-nothing. New provider seamVcsProvider.listCommitsBetween(GitHub viarepos.compareCommitsWithBasehead, the same callfetchChangedFilesBetweenalready 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 distinguishidenticalfrombehind— so they reportidenticalonly when the request itself proves it andunknownin every other case, which means a rewind on those two providers is recorded asunknownwith NOlineage_discontinuityrow rather than guessed), added toMETHOD_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). - Default Branch Landings —
Shipped— 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 oldestchecked_at, throttled byPR_CHECK_INTERVAL_MS), resolves each project's own default-branch head through its own per-project VCS credential (spec 215), and classifies every new commitgoverned_here(a merged, same-repo PR targeting the branch whose URL matches a feature'spr_urlof THIS SAME project,linked_feature_idset),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), orhistory_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 yieldgoverned_here. The first observation of a project baselines its cursor with zero backfill; anahead/unknowncompare is bounded to 20 commits per tick, oldest-first, advancing the cursor only to the last processed commit. Every row carriesfa_evidence: 'no_fa_evidence'— evengoverned_hererows, since the row records the landing, not Weftra's own gate. No commit message, diff or file list is ever stored;author_handleis capped at 255 chars. New provider seamVcsProvider.listPullRequestsForCommit(GitHub, GitLab, Bitbucket), added toMETHOD_RETRY_POLICY(compile-checked exhaustive). Kill switchFA_DEFAULT_BRANCH_OBSERVER(default on). Two new tables (default_branch_cursors,default_branch_landings) — no column added toprojectsorfeatures. 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-keyedVcsProvider.fetchCommitChecks(repoUrl, sha), sharingfetchPrChecks's provider mechanism, credential, throttle and "null, never throw" contract (never populatinglogExcerpt). This is an OBSERVED FACT WITH ITS SOURCE, never Weftra evidence:fa_evidencestaysno_fa_evidenceon every row. Stored append-only in a newdefault_branch_landing_checkstable (outcomeunreadable/none_reported/pending/all_passed/some_failed, derived by the puresummarizeLandingChecks, plus acheck_runs_readablepartial-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 additiveprovider_verificationfield 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-actionsruns, fetches or checks nothing, including the verification the actor claims it ran (an optionaldescription+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 sharedADMIN_API_KEYis recorded honestly as a role (admin_legacy), never a person. Appends a newoperator_action.declaredrow to the existing tamper-evidentactor_eventsledger (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 whosecommit_shamatches (via the samecommitShaMatchesprefix comparison PR adoption uses), in one bounded read per page;fa_evidenceand 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 adirect/governed_elsewherelanding 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 aforeign_landing_verification.setactor event), Weftra runs the project's own declaredverify_command(elsetest_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. Onlygoverned_elsewhere/directlandings are ever pending, never recorded before the policy's latestenabled_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.exactcoverage means the landing's own commit ran;at_later_headmeans only that the run'shead_shawas not the landing's commit — ancestry is not checked (a force-push can leave a head that does not contain the landing), and on anerrorrunhead_shamay be null with nothing verified, so readoutcomeandhead_sha— never claimed as the landing's own commit.passis not Weftra governance:fa_evidencestaysno_fa_evidenceregardless. RequiresRUNTIME=docker(409 to enable otherwise) and a declaredverify_command/test_command(400 otherwise); refuses to execute outside docker regardless of the policy. Never comments, blocks, reverts, re-opens, writesrun_events, or raises attention — afailraising 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 toprojectsorfeatures. Kill switchFA_FOREIGN_LANDING_VERIFIER(default on). The landings read API gains an additiveforeign_verificationfield (one bounded join); a newGET /api/projects/:id/foreign-landing-verifications?limit=returns this project's runs newest-first withcovered_landing_idsand the redacted, 4000-char-cappedoutput_tail. Inc-3b puts afailwhere a human acts on it. A project whose policy is enabled AND whose newest CONCLUSIVE (outcome IN ('pass','fail')) run is afailraises 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/errorruns never decide (anerrorafter afailleaves the item standing; anerroralone raises nothing), and the item clears the moment the newest conclusive run is apassor 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 isexact. No notification is sent — the item is feature-less, so the existing SLA sweep skips it exactly likerepair_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_reasonon 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. - Source Control Delivery —
Shipped— 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-defaultGITHUB_TOKEN— the fallback is recorded, never silent.npm run resolve-vcs-tokenis 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'sartifact_deliveredledger rows and theexecution_checkpointscommit 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). - Http Api —
Shipped— 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 undersrc/openapi/, with tripwires that refuse a document in which two modules (or a module and the base) declare the same path or the samecomponents.schemaskey, or in which a module contributes anything but schemas undercomponents.npm run openapi:hashprints 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. - Artifacts —
Shipped— Feature artifact upload, preview, and delete storage and API. - Provenance And Audit —
Partial— Evidence, tamper-evident run-event ledger, and decision-history provenance chain construction for every feature run. Since spec 443 inc-3 (RM-262 e), everytool_call/subagent_spawn/tool_call_outcomerun 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 onetool_call_outcomerow (deniedon a conservative permission-refusal match,errorotherwise — never a falsedenied); a successful call still records nothing (no ledger growth on the common case). Oneengine_pass_tool_summaryrow is recorded per pass on return. Coverage is the claude-code transport only — ACP/headless tool events reach the workspace log, notrun_events(EngineTask.onEventhas no consumer insrc/). Spec 443 inc-7 (RM-262 l) extends the ledger side of this: theprovenance_check_postedevent (posted by theprovenance-checkcapability) now recordsvia/failure_reason/envelope_comment_posted, and bothGET /api/features/:id/provenanceand its admin equivalent serve a derivedprovenance_checkblock over closed enums only — see theprovenance-checkcapability entry for the full posting-mechanism/fallback narrative. Spec 443 inc-25 (RM-282) extends the per-passengine_pass_tool_summaryrow withdeclared_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 aBashcall's parsedcommandagainst 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 12makeToolEventSink(...)call sites acrossflows/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. Outstanding: 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 withGET /api/audit/stream?cursor=&limit=(insertion order, operator-view redaction, storedrecord_hash, admin-only, no tenant view);source=actor_eventspages administrator actions the same way by their globalseq(restore-safe cursor, plus achain_headto keep outside the instance); push delivery and signed evidence are not built (RM-235).chain_verifiedforrun_eventsis 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 registerrun_eventsactually stored at write time (chain_verification: "stored_ledger", astored_chainobject 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_idscope is now answered from a module-level cache of the last COMPLETED background pass (runRunEventStoredChainPass, sliced and yielding viasetImmediate), 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 answersstored_chain: {status:"pending", verified_at:null}andchain_verification:"self_consistency"instead of blocking the request. Afeature_idscope 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/exportdownload (RM-414 inc-2 closes the sibling/health-blocking defect inbuildAuditExport'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 asactor_events(spec 537 inc-1) and administrator feature edits asadmin_field_setrun events (spec 537 inc-2). - Release Notes Verification —
Shipped— 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. - Model Rejection Auditable —
Shipped— A refusal by the model/provider is a first-class recorded outcome, not a silent failure — part of the provenance story. - Actor Identity Proven —
Shipped— 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). - Admin Action Audit —
Planned— A tamper-evident, hash-chained record of privileged ADMINISTRATOR actions (theactor_eventsledger, spec 261) — closing the gap CLAUDE.md's "Administration Is Legitimate" core law names: run_events is feature-keyed, with nowhere to put a non-feature admin action. Already covers identity management (user create/update/delete/role-change/key-regeneration, approver-project links) and a growing set of governance writes (runtime-image allowlist, runtime settings, separation policy, runtime egress). Spec 292 (RM-063) is extending it across every remaining privileged admin surface, increment by increment — this entry tracks it as a PLANNED gap until the whole spec's scope lands: increment 1 (shipped) covers the admin PROJECT routes —POST /api/projects,PATCH /api/projects/:id,DELETE /api/projects/:ideach now record one event on success, carrying field NAMES only, never values. Spec 443 inc-52 (RM-287 addendum 3 pt.3) closed one of the two other admin-reachable project-enrollment paths:POST /api/builder/new-projectnow calls the SAME create pipeline (createProjectPipeline,src/routes/projects.ts)POST /api/projectsdoes, so it records the identicalproject.createevent too. Still missing: the agent-factory compile path, which callscreateProjectdirectly under its own named-admin bar and recordsworker_deployments.compiled_byinstead — a deliberately separate ledger for a deliberately separate door, not scheduled to fold into this one; project-scoped VCS-credential configuration (inc-2); instance administration —POST /api/admin/rotate-admin-keyand the database-backup routes still record zero events today (inc-3) — and connector/integration administration (inc-4). Outstanding: Instance-wide, never tenant-reachable — a project key always receives an empty actor_events section (CLAUDE.md's Trust Boundary core law). The dashboard's Audit nav group (spec 528) shows these records under Administrator actions, an item shown only to an admin session and only while the records response carries the stored-ledger-verified actor_events section; Run ledger and Export are its sibling sub-views. The remaining spec-292 increments are not yet built; do not present the FULL "every privileged administrative mutation" claim as shipped product behavior until they land (CLAUDE.md "Important product-claim boundaries").
One core → N deployment modes
Self-hosted-per-org and zero-infra SaaS are the SAME core with multi-tenancy as a deployment mode, not a rewrite.
█████░░░░░ 50% (1 done / 2 on the path) — half-built
- Self Hosted Packaging —
Shipped— Self-hosted deployment packaging: container image + Docker Compose (with a proven one-command cold-start install rehearsal), and a Helm chart for Kubernetes. - Saas Control Plane —
Planned— The zero-infra SaaS deployment mode: tenant provisioning, routing, metering, and the orchestrated (k8s/Fargate) Runtime backend that turns the self-hosted-per-org core into a multi-tenant SaaS front door. Design-only — explicitly sequenced AFTER the self-hosted core earns adoption (target-architecture.md §2, §9); no control-plane code exists yet. Outstanding: Not generally available. Do not present as shipped product behavior (CLAUDE.md "Important product-claim boundaries"). Depends on the multi-tenant credential/trust model (enterprise-multi-tenancy) landing first.
The four SaaS-primitive seams
Tenant/org scope, the runtime interface, BYO/brokered credentials, and per-tenant budget/quota — built now so SaaS becomes additive later, not a retrofit.
░░░░░░░░░░ 0% (0 done / 2 on the path) — mostly outstanding
- Budget And Quota —
Partial— Project/tenant budget cap, duration cap, and quota configuration. Outstanding: No tenant-level budget or quota and no USD-denominated cap yet; cost authority is blocked on the credential broker (spec 011). - Enterprise Multi Tenancy —
Planned— Tenant isolation and per-tenant credential configuration seams toward full RBAC/orgs/ SSO/billing. The multi-tenant trust/credential model is implemented; RBAC/orgs/SSO/ billing as a complete enterprise product are north-star, not shipped. Spec 215 adds a project-scoped VCS credential (admin-only read/write,tenant_credentialspurpose'vcs') on the same table and route file as the tenant-wide model credential above — not a second credential model. Outstanding: RBAC/orgs, SSO as a general enterprise product, and billing are not all generally available today (CLAUDE.md "Important product-claim boundaries").
Rent-the-engine + transport
Never build our own agent loop; the engine (Claude Code, ACP adapters, headless) and the transport that drives it are both pluggable, swappable dimensions.
██████░░░░ 57% (4 done / 7 on the path) — half-built
- Engine Abstraction And Routing —
Partial— 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 — mirroringenforcesBudgetCap) is a REQUIRED per-engine capability signal:truefor claude-code (the only transport that readstask.allowedTools),falsefor ACP and headless.requireToolGrantEnforcementrefuses a task whoseallowedToolsis non-empty on a non-enforcing engine, applied both by the two non-enforcing adapters themselves and unskippably at theregisterEnginechoke point. The standard flow's fa_authored spec-authoring pass (implement.tsstep 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 theauthored-spec-approval-checkpointnote inprovenance-and-auditbelow 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'sdecided_by_class—user/admin_role/project_key/unknown, derived from the already-proven credential, never the request body), andpermission_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 distinguishablenot_recordedresult →500, never the404an unknown id gets; an expired-write failure still denies). A project-key ledger read withholdsactor/actor_verifiedonpermission_decided(redactPermissionDecisionPayload) while keepingdecision/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 carryraised_by_role, and a project key resolves only anauthorgate — a revise, security-fixer or helper gate answers403and needs an approver or admin on the admin route. Outstanding: 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 acomplexitymap consulted after thecategorymap (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 asapplied: false. The same response lists each live worker deployment's agent-run profile underagent_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 asapplied: 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 atGET /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 readoperator_set, and worker deployments are not shown; a sentinel test asserts no withheld value appears. - Helper Roles Mid Run Delegation —
Shipped— 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. - Agent Profiles —
Shipped— 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. - Engine Account Pool —
Partial— 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. Outstanding: 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 valueclaude setup-tokenprints) 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. - Eval Replay Profiles —
Partial— Shadow-replay already-shipped features to measure a config change's real effect — an experimentation substrate over the eval harness. Outstanding: inc-2 (persistent toggle, drain, schedule, autonomous selector, fleet-metrics exclusion) outstanding (RM-050). - Engine Credential Registry —
Shipped— 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 — theclaude-codeengine, or (since inc-2d) a non-default engine declared inFA_ENGINE_PROFILESwith at least oneauthEnvkey.refis an opaque handle validated against a closed set of shapes (an existing secret-material ref,host-oauth-dir:<absolute path>for oauth, orhost-env:<NAME>matchingANTHROPIC_API_KEY(_[A-Z0-9]+)*for api) and never dereferenced at registration time — a pasted secret (secret/value/key/token/credentials_json/api_keyin 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 intoimplement.tsandimplement-spec-kit.ts, which pick the least-recently-used ENABLED, health-clear, currently-resolvable account for the run's engine/auth mode immediately beforecreateRuntime; every other run type and every tenant-configured run (resolveRunCredentialSource's non-operator-defaultsources) is unchanged and never reads this table. An empty registry or no eligible candidate falls back byte-identically to the pre-inc-1b behavior. Oneengine_credential_selectedrun 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 owncreateRuntime, with an additiveflow: '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 owncreateRuntime. 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},roleone ofconformance_reviewer/designer_reviewer/security_reviewer, fixed by the call site) — never the implementation-flowengine_credential_selectedtype, so a review round can never rebind the feature's own credit-pause/failover binding (the four readers that treat a feature's LATESTengine_credential_selectedas 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 tohealth_status/health_untilfrom a run. Weftra confirms for itself with a fixed, minimal probe call run in a fresh Weftra-owned container (RUNTIME=dockeronly, 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 itsengine_credential_probe_completedledger 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 ownrun_events. An admin clears a block withPATCH .../:id {reset_health:true}(the literaltrue, 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 oneengine_credential_failoveractor-ledger row per evaluation that found a registered-account feature (including a skipped one, with why); nothing new ever reaches a tenant's ownrun_events. When every account for an engine/auth mode is blocked, one admin attention item and one Engine Accounts panel line name it (pool_statusonGET .../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 throughhost-env-suffix:<SUFFIX>(api only): for each declared key K the account reads host variableK_<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 inDockerRuntime; 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-credentialstakes an optionalproject_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'stenant_idis stamped from that project's own row in the same transaction, never from the body, andtenant_idin a body is refused.project_idcannot 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 carriesscope(project,operator_pool, or null when nothing was selected) and each ineligible entry carriesscope. The five selector call sites pass the run'sfeature.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 itsproject_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'sscopesays 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/:idso 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-levelfallback_runsfigure. A deleted account keeps its history (registered: false).fromis inclusive,toexclusive, 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-emptyproject_id), selected by explicit columns; each entry carries itsproject_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 newengine_usage_recordedrun event —{flow, engine_id, engine_credential_id, applied, cost_usd, cost_estimated, input_tokens, output_tokens}— immediately after its existingaccumulateFeatureUsagecall, and only when that run itself wrote anengine_credential_selectedevent (a tenant-BYO/brokered run never writes one). The usage rollup countsapplied: trueevents naming an id into that account'sengine_calls,cost_usd(summed over non-null values;null, never0, when nothing measured a cost),cost_unmeasured_calls,cost_estimated,input_tokensandoutput_tokens; anapplied: falseor null-id event is never charged to an account — it folds into a new instance-levelunattributedblock 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, andreview_rounds_by_role(applied-only, broken down byconformance_reviewer/designer_reviewer/security_reviewer); instance-level,fallback_review_rounds.ineligible_by_reasoncounts an account's appearances across BOTHengine_credential_selectedandreview_engine_credential_selectednone-eligible events.runs_selected/runs_applied/features/fallback_runskeep 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 declaresprobeEgressHosts(a new optionalFA_ENGINE_PROFILESfield), and it is two-valued: a PONG isconfirmed_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 asengine_credential_probe_started(triggeradmin, attributed to the requesting admin) andengine_credential_probe_completed. The list and single reads carrylast_check. The dashboard gains a Check button and a plain-English last-check line per account, DOM-built. No schema change, no run event. - Openai Compatible Engine —
Beta— Spec 520 inc-1 (RM-435) — an engine for any OpenAI-compatible hosted model, with no new engine integration. AnFA_ENGINE_PROFILESentry may declareopenaiCompatible: { baseUrl, model, apiKeyEnv? }(host configuration only, never a request, project or repo field), validated at startup in the existing profile parser:baseUrlan absolutehttps://DNS-host URL (http://refused),modelnon-empty and at most 200 characters,apiKeyEnva key of the profile's ownauthEnv; each failure names its field. Weftra drives one shipped, version-pinned ACP harness (OpenCodeopencode 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'sprobeEgressHosts, and is never used to widen an operator bound.POST /api/admin/engines/:id/smoke-testand the engine account check do a sandboxed one-prompt round trip and reportconfirmed_ok | auth_failed | unreachable | inconclusive. Cost staysnullunless 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: anhttp://base URL is accepted only when its host is also listed inFA_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 markedPLAIN, 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 themodelof an OpenAI-compatible profile only, the report namesengine_profile {id, model}, and an arm with unreported cost carriescost_unmeasuredand renders≥ $Xorunknown. The paired Pro run is operator-run.
Every agent sandboxed / trust boundary
Everything untrusted runs in the Runtime, never on the host — the trust boundary every agent run, credential, and secret must respect.
████████░░ 81% (13 done / 16 on the path) — mostly done
- Web Reading —
Shipped— 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. - Sandboxed Runtime —
Partial— 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 sharedsandboxHardeningArgs()ceiling, strict by default with a recordedcompatopt-out (spec 405 inc-1). Declaredservices/envare 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 aservices_startedrun 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/npmwith 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 ownnpm_cache_postureevent (spec 475 inc-3). The default and browser images carry a native-build toolchain (python3/make/g++) andnpm_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 aruntime_base_image(project or feature; admin-only, allowlist-bound, mutually exclusive withruntime_image) and Weftra builds the sandbox image from it by copying a constant agent layer onto it with noRUNinside the base, cached by base digest and layer version (spec 514). An admin can also declareruntime_writable_paths(project or feature; admin-only, at most 8 validated absolute paths) and Weftra builds a further image whose only content is aCOPY --chownof those directories to the sandbox user, with noRUN, 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). Outstanding: Spec-kit enrollment runsspecify initand the constitution agent in one flow-owned sandbox under RUNTIME=docker, not on the Weftra host; the curated fa-runtime image provides the pinnedspecifyCLI (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 —
Shipped— Attribute every workspace write during an implement or/reviserun to the phase that made it — the agent's own turn, or Weftra itself running a declaredsetup_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 viagenerated_deliverables. On/revise, an excluded set is additionally recorded as a boundedtest_side_effectsrun event and named (by count only, never by path) in the round's PR comment. - Seccomp Profile Name Support Check —
Shipped— Startup + on-demand validation that the Docker host's ownrunc/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 ordocker 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 reportsvalidated|degraded|not-checked(never claiming a validation that did not run), andnpm run diagnose:seccompprints the full report on demand. Reporting only — no run behaviour, argv, or tenant-visible surface changes. - Agent Runtime Egress Allowlist —
Shipped— 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),allowlistis the DEFAULT posture: when nothing more specific resolves the policy,createRuntimecomputes 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 toallowlistmode (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 ofFA_RUNTIME_EGRESS_ALLOWLIST. - Environment Provisioning —
Partial—.fa/environment.ymlmanifest, 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 declaredsetup_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 declaredtest_commandtoo (RM-267), bounded byTEST_GATE_TIMEOUT_MS— the same command Weftra blocks every real feature run on, so a green check predicts a gate that can run. ATEST_GATE_TIMEOUT_MSbreach is only reported when the deadline verifiably fired; any other failure to finish is stillfailedbut names no bound. Captured step output is redacted on BOTH streams (stdout as well as stderr) against this run's own injectedenv/service values, not just Weftra's configured secrets. Whenruntime_egressis inallowlistmode, a failedrepo/setup/test/verifystep whose captured tail reports a name-resolution failure for a host that IS on this run's own allowlist gets a dedicatedMISSING -> HOW TO FIXentry naming the host, the fact it is allowed, and the proxy env vars Weftra set (RM-291) — a toolchain that ignoresHTTPS_PROXY/https_proxy/NO_PROXY/no_proxysees 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 ownrepostep exists:git ls-remote --headsand a non-destructivegit push --dry-runagainst the repository's existing default branch, both through the same sandboxedrunSandboxGitseam and the same resolved credential therepostep'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 additivecapability_probeblock (never changesok) 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 eachmissing[]entry, and from the failing outcome's own step kind — never by matching Weftra's ownwhat/remediationprose. Everymissing[]entry gainsfailure_class; the report gainsprimary_failure_class(present only whenok: false, the first class present in the vocabulary's own precedence order).humangains aFAILURE CLASS:section rendered only when a class applies, so a passing report'shumanstays byte-identical.registry_accessis produced by the declared-host probe (spec 443 inc-75, RM-287 remainder);egress_proxy_dnsis 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 fixedenvcheck-<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 asrun_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 honestinterruptedreport —ok: false, every stepnot-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-checkedtearDownJobResourcesprimitive (spec 434).GET .../env/check/logskeeps 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 anenv_check.cancelactor event fail-closed, then kills the run's registered containers; the persisted report isok: false,cancelled: true,primary_failure_class: cancelled, completed steps keep their real outcomes and the rest arenot-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 onruntime_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 aregistry_accessentry 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 additivedeclared_host_probeblock that never changesok. Under open egress nothing is probed. Outstanding: 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. - Admin Runtime Settings —
Shipped— 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). - Operator Policy Floor —
Shipped— 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. - Local Instance Reachable From Sandbox —
Shipped— 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. - Mcp Gateway Allowlist And Audit —
Shipped— 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 declaredmcp.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. - Sandboxed Browser Tool —
Shipped— 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). - Capability Token Substrate —
Shipped— 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'sidentity-self-service-scoped-tokensentry below; root-key RETIRE remainstsx-only, with no HTTP route or UI. - Trust Page —
Shipped—npm run trust:exportrendersdocs/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. - Vcs Credential Health —
Shipped— 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 alwaysrejected; 403 isrejectedUNLESS it carries a rate-limit marker (x-ratelimit-remaining: '0'orretry-after), which isother; everything else isotherand 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 asother, no rejection at all:ENDPOINT_AUTHORIZATION_403_OPERATIONSlistspostCheck, 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 classifiescredential_lacks_check_writeand 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 newvcs_credential_healthtable (src/models/vcs-credential-health.ts) carriessource_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_rejectionsandrejected_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 Octokithook.after/hook.errorpair installed at every one of its 20new 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-fetchresponse site, inside their sharedglFetch/bbFetchhelpers and at every standalonefetchcall.hostis 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 intoconsecutive_rejections. An admin reads the table viaGET /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(envVCS_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, sinceconsecutive_rejectionsalready 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.recordVcsCredentialRejectednow returns the row's post-writeconsecutive_rejectionsatomically (aRETURNINGclause on the same upsert, no second read), andrecordVcsCredentialObservationdispatches 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 (anokafter a streak that had reached threshold). A purecomputeVcsCredentialAttentionItems(src/services/fleet-attention.ts) derives an admin-onlyvcs_credential_invalidattention 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/attentionappends 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, whichsendDigestNotificationnow 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 — neversource_kind, an operation name, a URL or provider text — and every dispatch also writes one token-freeconsole.warnline. 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 viawithVcsProvider(..., { background: true }); every other caller (admin reconcile, create-pr, revise, the review/security stages, every git transport path) is unaffected —optsomitted is byte-identical to before. The window is derived entirely from existingvcs_credential_healthcolumns, no new timer or Map:attempt = consecutive_rejections - threshold,window = clamp(base * 2^attempt * (1 + jitter), base, cap)wherebaseisprCheckIntervalMs,capis a new runtime settingvcs_credential_backoff_max_ms(default 1h, bounds 60s..24h), andjitteris DETERMINISTIC per (tenant, project, host, attempt) — the first 4 bytes of asha256of that key read as a uint32, scaled to [0, 10%), so noMath.randomand a test can predict the exact window. A rotated credential is tried at once: a new nullable column,rejected_credential_fingerprint, stores a Weftra-computedsha256fingerprint 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 onevcs_credential_backoffactor-ledger row (metadata{tenant_id, project_id, host, attempt, until}only — no fingerprint, operation, URL or token) under a dedicated system principal, and exactly oneconsole.warnper streak (only atattempt === 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 agithub-authentication-token-expirationresponse 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 Octokithook.after/hook.errorpair already saw it and threw it away.GitHubProvider.newObservedOctokitnow reads it and hands it toVcsObserver'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. Agithub_appcredential is CLEAR-ONLY —nullon success,undefinedon 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 withDate.UTC, nevernew Date(rawString), re-deriving the UTC calendar fields to catch a dayDate.UTCwould otherwise silently roll over; anything else returnsnull, treated as "unknown" and never written as a clear. A new nullabletoken_expires_atcolumn holds the normalised ISO value, written by a plainUPDATE(recordVcsCredentialExpiry) only when it differs — never an upsert, so a row that was never observed stays absent; for the sharedoperator-defaultcredential that one statement writes everyoperator-defaultrow 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;vcsCredentialExpiryWarningWindowis the ONE pure predicate shared by the notifier, the attention item and the admin read'sexpiring_soonfield, so the three cannot disagree. The warning is claimed with ONE atomicUPDATEon a second new column,expiry_warned_for(claimVcsCredentialExpiryWarning) — the dedup group is everyoperator-defaultrow 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 purecomputeVcsCredentialExpiryAttentionItems(src/services/fleet-attention.ts) derives an admin-onlyvcs_credential_expiringattention 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'svcs_credential_invalidowns that row instead — never both).GET /api/admin/vcs-credential-healthgains three per-row fields:token_expires_at,expiring_soon, andbacking_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 withcreateElement/textContent, neverinnerHTML. 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. - Github App Operator Credential —
Shipped— Spec 458 inc-1a (RM-323) — the operator-default github.com VCS credential can be a GitHub App instead of a single host-wideGITHUB_TOKENPAT. 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 namedgithub_app_misconfiguredline naming the missing variable, never a value) and an optionalGITHUB_APP_INSTALLATION_IDto 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-incrypto(no new dependency), resolves the installation (pinned id, orGET /repos/{owner}/{repo}/installation, a 404 there is the namedgithub_app_not_installederror), and mints viaPOST /app/installations/{id}/access_tokenswith{"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'srepo_urlhost is exactlygithub.comand 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 onVcsCredentialResolution; a sharedvcsCredentialEventFieldshelper adds them to everyvcs_credential_resolvedrun 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 namedGithubAppErrorand never falls back toGITHUB_TOKEN.GET /api/admin/vcs-credential-health(admin-only, unchanged guard) gainsoperator_default_github: the App's status (configured,misconfigured,app_id,installation_pinned,last_mint_at, a redacted/boundedlast_mint_error) pluskind: 'github_app'|'pat'|'none'— no field ever carries a token, a JWT, key bytes, or the key path. A minted token isghs_…, 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 optionalcredentialKind: 'pat'|'github_app'(default'pat', so every existing call site is byte-identical), plumbed throughresolveCredentialedVcsProviderat both the github.com branch and the unrouted-host fallback branch. Only when it is'github_app'dopostCheck/fetchPrChecksrecord a Checks-API outcome viarecordGithubAppChecksOutcome('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_writeclassification, and the credential-health 403 amnesty are all unchanged for both kinds.GET /api/admin/vcs-credential-health'soperator_default_githubgainschecks: { last_ok_at, last_denied_at, last_denied_operation }, allnulluntil observed — no repository, project, tenant id, token, or error text, ever. An admin-only "GitHub credential" dashboard card (public/index.html, DOM-built, noinnerHTML) 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_credentialsgainscredential_kind('pat'|'github_app', default'pat'— every pre-existing row reads back unchanged),github_app_id,github_installation_id(both ids, never secrets;github_appis valid only on apurpose='vcs'row). The ref of agithub_approw dereferences through the SAMEresolveSecretMaterialseam 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.tsgains a parameterisedgetProjectGithubAppInstallationToken({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 keyedprojectId:appId:sha256(privateKeyPem)— never the operator'sowner/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-defaultlastMintAt/lastMintError/Checks scalars; a module-levelprojectGithubAppStatusMap (keyed by project id) holds each project's ownlast_mint_at, a redacted/boundedlast_mint_error, and Checks outcome, evicted onPUT/DELETEof that project's credential. InresolveVcsCredential(src/services/vcs/credential.ts), a tenant-configuredgithub_approw refuses unless the project'srepo_urlhost is exactlygithub.com(namedgithub_app_host_not_github), mints for the project's ownowner/repo, and returnscredentialKind: 'github_app'with a NEWgithub_app_scope: 'operator_default'|'project'field onVcsCredentialResolution(kept separate from the pre-1bgithub_app: {app_id, installation_id}object so that object's shape is unchanged);vcsCredentialEventFieldsaddsgithub_app_scopeto the ledger only when present. It never falls back to the operator App orGITHUB_TOKEN— every failure (non-github.com host, an undereferenceable ref, non-PEM material, a 404 install lookup, a non-2xx mint) throws a namedGithubAppError.GitHubProvidergainsgithubAppScope/githubAppProjectIdconstructor params (defaulting to'operator_default'/null, so every pre-1b 3-arg call site is unaffected) sopostCheck/fetchPrChecksroute a project App's Checks outcome to that project's OWN status, neveroperator_default_github.checks. The admin routePUT /api/tenants/projects/:projectId/vcs-credentials(still admin-only, no project-key path) accepts an optionalkind: 'pat'|'github_app'(default'pat'— a body with nokindis byte-identical to before this increment):github_apprequires a decimal-digitapp_id, accepts an optional decimal-digitinstallation_id, requires the project's repo host to be exactlygithub.com, and preflights the dereferenced material withcrypto.createPrivateKey(stores nothing on failure, mints nothing at configuration time);app_id/installation_idsent withkindabsent or'pat'is a 400.GETreturns{provider, authMode, ref}for a plain PAT row (unchanged) and additionallykind,app_id,installation_id, and this project's mint/Checks status for agithub_approw — 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. - Stop Everything On This Host —
Partial— 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 aninstance.stopactor 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 askilled_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 underRUNTIME=local. It ends with aninstance.stop.completedactor 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 untilDELETE /api/admin/drain. The dashboard's Safe drain & restart card gains a confirmed "Stop everything on this host" danger button that renders the report. Outstanding: 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.
The Builder → governed deploy
The AI-assisted spec-authoring front door, finishing at a declarative, approval-gated, project-agnostic deploy — the Lovable-for-specs pitch with governance nobody else has.
█████░░░░░ 50% (2 done / 4 on the path) — half-built
- Builder Spec Drafting —
Shipped— Conversational, AI-assisted drafting and refinement of a feature spec from plain English, including clarifying questions, cost/scope estimate, and submission. - Governed App Deployment —
Partial— 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. Outstanding: 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). - Governed Deploy Bounded Execution —
Planned— The bounded-execution increment of the governed-app-deployment rebuild (spec 106 inc-2): running a project's own declared deploy recipe to completion under Weftra's permission gate. Blocked on the spec-011 credential broker (a tenant's cloud creds, safely isolated) per specs/106-governed-app-deployment-rebuild/spec.md — see CLAUDE.md's Trust Boundary incident note (2026-07-17, PR #173). Outstanding: Not generally available. Do not present as shipped product behavior (CLAUDE.md "Important product-claim boundaries"). - Agent Solution Builder —
Shipped— TheAgentSolutionSpecBuilder (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 governedagent_solutiondraft 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 (roleagent_builder, host spawn,--tools '', same footingdefinition-review/analyzeuse):.../interviewparses the workflow into a closed, nonce-bound schema (steps + questions + prohibitions/remembers/measures harvested from the text) and persists it as its ownagent_interviewrow — 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 toretain-humanwith aninstruction_shapedrisk, enforced by the parser, never trusted to the model..../refinefolds 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..../draftruns NO model —deriveAgentSolutionis a pure, deterministic mapping from the classified steps onto the existing (spec 372) closedagent_solutionschema:automatebecomes anact/observestep,assist/require-approvalbecome arecommendstep,retain-humanbecomes a documentaryworkflow.human_steps[]handoff entry (a new optional field, never a step an agent performs);authority.capabilitiesis the MINIMAL vocabulary subset the derived steps need, never guessed past what a step's owntools_impliedmaps ontoREVERSIBLE_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'snot_yet_expressible[]— never fabricated, never silently dropped. The draft is created through the ONE door (createProductArtifact, kindagent_solution, DRAFT only,authored_via: 'builder'hardcoded — never from the body, neverresolveAuthoredVia's caller-declared header), and the response carries the Definition-of-Ready gate's own review of the fresh draft —propose-readyon 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.../refinewithreclassify, 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 derivedagent_solutionJSON beside the SAME Definition-of-Ready review cardpropose-readyrenders elsewhere on this canvas, withnot_yet_expressible[]and the route's per-field provenancenotes(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) — noinnerHTMLtemplate string, so the page's escaper-site count stays at 0.
The governance funnel
Autonomy modes, PO approval, clarify→spec, and pluggable review/security gates — the upsell for the agency and regulated buyer, riding on top of the fleet wedge.
████████░░ 83% (45 done / 54 on the path) — mostly done
- Intake And Clarification —
Shipped— 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 afeature_submittedrun 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 latestclarification_answeredevent); an admin/approver may amend any 'agent' answer, but amending one alreadyreviewer_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 anauthor_clarifications_givenevent 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 additiveclarification_answer_revisionstable (text lives there; the ledger carries only hashes) and writes aclarification_amendedevent plus a freshclarification_answeredevent in the existing shape, soresolveRubricClarificationsgrades the amended text with no change tosrc/services/review/.GET /api/features/admin/:id/clarifications/:clarificationId/ historyreads 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). - Approval And Autonomy Policy —
Partial— 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, separaterequire_spec_approvalcheckpoint (spec 299): Weftra pauses inawaiting_spec_approvalafter it authors a spec and before any branch/commit work, so an approver reviews the rubric Weftra itself wrote (or edits it) viaGET /: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/retrythat wiped the workspace — reinstates the approved bytes atspec_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. Changingspec_pathafter 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 evidencedeclaration (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 viaGET /:id/pending-specbefore the hash is bound. A missing or unparseable declaration is recorded honestly and never fails the run or blocks the pause. Outstanding: A spec-kit run records aspec_approval_skippedevent (flag_offorspec_kit_flow) and continues the pipeline; under an operatorrequire_spec_approval: requiredfloor it is rerouted through the standard flow, so the checkpoint applies (RM-236). Spec 472 inc-2 (RM-348 b): at the project's declaredseparation_policy.submitter_approverlevelrequire/require_proven(require is the default),POST /:id/approveandPOST /:id/approve-specadditionally refuse any approval whose credential does not resolve to a verified NAMED person —403 approver_unverified,reason: named_approver_required, noremediation— closing the gap where the sharedADMIN_API_KEYcould approve a project-key submission under the role-credential carve-out incheckSeparationOfDuties.off/recordare unchanged, and every other caller of the underlyingevaluateApprovalSeparation(@weftra merge'ssubmitter_mergerpair,/:id/amend-spec, the clarification-answer route) is unaffected — only the two approve routes layer the newevaluateApprovalActSeparation. Spec 494 (RM-390): an approval is bound to the text that was approved.approvestoresapproved_content_hash; a project-key edit of an approved title/description is refused (409 approved_text_locked) except onfailed/cancelled, where it re-opens the approval (approval_reopened) so/retryreturns toawaiting_approval; an admin edit rebinds the hash and recordsapproved_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. - Implementation Review —
Partial— 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-setfixer_model, and the review after it is terminal. Spec 443 inc-10 (RM-262 o): areviewer_verdict/APPROVEreviewer_finalpayload 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 —getImplementedForReviewre-selects it once the live head has moved past the graded commit (one cheap live-head read before anything else runs,sha_unchangedskip otherwise), bounded by a newREVIEWER_MAX_REGRADES_PER_FEATURElifetime cap (default 5,0restores 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 onPOST /:id/run-review, gated by a verifiable admin credential — never the dev-mode open-admin pass — which records ONEreviewer_reopenedevent 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-boundbuild_verdictfor 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-boundbuild_verdictfor the exact graded commit, it is recordedcontradicted_by_evidence(naming the matched phrase and thebuild_verdictevent) onreviewer_verdict/conformance_verdictand 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 intoverdict, and the parsedverdictobject is handed tosubmit()unchanged (src/services/review/run-review.ts:731computes them,:763posts 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: abuild_verdictPASS is the exit code of the project's OWN declaredtest_command(src/services/agent/gates.ts:951,const pass = exitCode === 0), andtest_commandis settable with a project API key (SELF_CONFIGURABLE_PROJECT_FIELDS,src/models/projects.ts:54) — a project that declarestruegets 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.tsgrows 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/structuralhave no answering class in this increment and are recorded on a newexecution_claimspayload array instead, never contradicted). Averify-class claim now IS contradicted, but ONLY by averify_gaterecord that itself carries the exact graded head — the gate-rerun stage's own write (§24); an implement//revise/security-fixer-writtenverify_gaterecord carries no head at all and can never contradict anything.EvidenceClassRecord(evidence-classes.ts) gainsmethod(declared_command|sandbox_execution|static_check|policy_eval|reviewer_judgment),producer({role, engine, model}) andrecord_event_id— populated only from what the underlying row states, null on everynot_runrecord — socontradicted_findingscites 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 renderbuild — not run (no_record_for_head)on a head-bound round where it previously renderedbuild ✅ passfor 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 pureblocking-resolution.tsfrom the SAMEdecideAutoReviseresult 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 ofcapped/no_progress/loop_disabledapplies 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 rendersblocked_by_clarificationinstead:getRevisingFeatures, the revise flow's entry guard andPOST /:id/reviseall refuse a conflicted feature, so a queued-round promise (or a/revisestep) would name work Weftra will not start until a human answers the standing clarification. Additive onconformance_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 asalready_satisfiedby code already in the BASE tree instead of inventing a change to "fix" something already true — the agent ends its output with one nonce-boundfinding_dispositionsblock (already_satisfied|changed|disputed, each withpath/line/quoteevidence foralready_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 EVERYalready_satisfiedpointer — 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 onerevise_dispositionsrun 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-signedfa:revision-dispositionsmarker under its own domain-separated subkey, and the same composedexcludeFaAuthoredCommentsfilter 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 arevise_startedorsecurity_fixer_startedledger event ran strictly after it was captured (parseDbTimestampMs, nevernew 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 changedsrc/. When in scope,buildReviewerPromptadds afenceFor-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.ReviewVerdictgainsspecStale({section, claim, shipped}[]), normalized exactly likeinlineComments, capped at 20 entries/500 chars each; an APPROVE with non-emptyspec_stalestill parses as APPROVE — the parser never changes the decision.decideAutoRevisegains a new rung 0, evaluated before EVEN APPROVE:specOfRecordStale→escalate, neverrevise— 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.tsstep 5g WITHHOLDS the provider-side approving review (submitis not called; thereviewer_submittedmarker recordssubmitted: 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 handssubmit()on thereviewer_submittedmarker (withattempt_id,pr_url,head_sha,lineage_id) and writesreviewer_postedonce the post completes, andresolveStandingApprovingReviewREADS 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 intonone); the comment, the Resolution sentence and thestanding_approving_reviewfield 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.resolveConformanceResolutiongains aspec_of_record_stalebranch, labelled ahead of every other escalate branch, so the comment no longer announces an unreached round cap on a stale round.run-review.tshonoursspec_staleONLY when the round was in scope (an out-of-scope round drops the entries and recordsspec_stale_unsolicited: trueonreviewer_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 newspec_of_record_stalerun event is recorded once per round, only when the array is non-empty, naminground/spec_record_id/spec_content_hash/head_sha/head_bound/triggers/reason/entries/blocked_approve. It is deliberately NOT added toPROJECT_KEY_WITHHELD_EVENT_TYPES— it is a fact about the tenant's own spec and its own diff, the same class asno-changes/spec-artifact-drift, and carries no operator control value.buildConformanceCommentBodyrenders 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 — aNot 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-speclets a named admin/approver (never the implementer, reviser or security fixer) amend a VERBATIM (spec_content) spec of record — guarded exactly like/:id/approve-specin both halves (requireAdminOrUserAuth+requireApproverScopeForFeature, and the same separation-of-duties check so the feature's own submitter cannot amend its own rubric), bodyspec_content/reasononly (every other field, including every reviewer-arming field, refused by name with a 400 before any write; there is noamended_by— the actor is always the authenticated credential). One transaction updatesfeatures.spec_content, appends a spec record under the EXISTINGapprover_editedprovenance, and writes aspec_amendedrun 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 nextPOST /:id/revisere-assertsfeature.spec_contentunmodified (writing the amended bytes to the branch), andresolveSecurityReviewSpecSourcereads the same field, so the amendment is automatically a new security rubric with no carried verdict.specOfRecordAmendmentStepand 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 anapprover_editedrecord and repointsspec_record_idat it, and never writesspec_content.reviseand 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 owndocs:commit with no agent turn, recording aspec_amendment_pushedrun 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 optionalspec_amendmentskey (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) andauthor_clarifications_given(implement.tsstep 3, exactly the rowsbuildClarificationContextgave 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, isexcludedwith a closed reason, never silently used.buildReviewerPromptrenders 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_finalgainrubric_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 sameamend-specroute 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). Outstanding: 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 newdecideAutoReviserung 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_checkedwithrecorded) 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/structuralclaims 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 perclaim_check_resultrow, oldest first, omitted entirely when a feature has none) and the dashboard project form gained a control forclaim_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 sameunchanged_at_base/isUnchangedAtBaseprojection the routing rung reads — never the command text or its output, and never a claim that the entry'sunchanged_at_baseby itself routed the round. Spec 443 inc-83 (RM-280 addenda 1–2 part 2c-2): the escalatedreviewer_finalfor a round the unchanged-at-base rung decided now carries one additiverouted_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 themergeitem it sits beside, clearing when the feature leavesimplemented, 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); atest/structuralclaim 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-projectreview_context_pathsnames 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. Arun_mode: patchfeature an administrator has armed withpatch_fix_rounds(andreview_on_patchon) 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 itsstop_reason(spec 544 inc-8f, RM-446); unarmed behaviour is unchanged. - Security Review And Fix —
Shipped— The dedicated adversarial security-reviewer engine role, the on-demand security-fixer trigger/recovery loop (the Security Fixer runs on its ownsecurity_fixerrole with admin-onlysecurity_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_pathsexclusion, droppedindexlines, 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 assecurity_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 thefingerprint_context_ignored: truemarker the carry now requires on the candidate. Inc-2 changes what the ESCALATE round-cap terminal STOPS: exhaustingSECURITY_REVIEWER_MAX_ROUNDSnow recordssecurity_fixer_final(escalated_cap)(a Fixer terminal) instead ofsecurity_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 adminsecurity_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 arate_limitedprovider 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 barenull.GithubProvider.resolveHeadShaclassifies the failure into a CLOSEDHeadShaFailureReasonenum (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 recordssecurity_head_sha_retried; an unresolved head still records the existingsecurity_sha_unavailableevent (now carryingreason/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 adiffBaseRefis 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) onbuild_repair_escalatedasunpushed_diffrather than lost with the workspace.build_repair_triggeredgainsprompt_bytes/output_truncated/diff_included/prompt_ceiling_applied, andsecurity_fixer_completedgainsbuild_repair_prompt_byteswhenever 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 pureblocking-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, namingPOST /fix-securityand 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 ofautonomous_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, andPOST /fix-securityanswers 409 — so the block names answering the standing clarification instead of any route the conflict would refuse. Computed via the SAMEresolveFixSecurityEligibilitythe autonomous dispatcher uses, evaluated against the round's own just-computed verdict. Additive onsecurity_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'slineis 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 acarried: truerecord makes — and the narrower things it does not — are written out indocs/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. Thefloorband always blocks regardless; a cited re-rating (severity_change_reason, parsed fail-closed exactly likeseverity_justification) always stands, and its text is rendered on the PR comment in the finding's own fenced block as well as on thesecurity_verdictrun-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 (acarried_anchor_sha256marker 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 aseverity_driftrun 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 writesseverity_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 — seedocs/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 optionalsame_defect: true(meaningful only alongsidestatus: "restates", parsed fail-closed — kept only for the literal booleantrue), and the reviewer prompt now says explicitly thatrestatesmeans the SAME defect re-observed, never a merely-neighbouring one, and that a verified-fixed prior id belongs inresolved_finding_idseven alongside a new, neighbouring finding.computeSecurityFindingSetschanges in exactly one case: a prior finding BOTH named inresolved_finding_idsAND restated by a current finding. If any restater affirmssame_defect: true, the existing spec 207 contradiction guard stands (carried, unchanged). If none does, the prior finding isclosedand each restater is REATTRIBUTED — scoredintroducedand recorded on a newreattributedlist ({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 staycarriedand scored the roundno_progress, parking a real fix uncredited. Closure still requires the reviewer's ownresolved_finding_idsentry — nothing closes from absence or inference.decideSecurityFixRound'sreasonnames every reattribution, and thesecurity_verdictnet-progress payload carries afixer_findings_reattributedarray alongside the existingfixer_findings_*_countfields. No new project/feature field, no route, no dashboard surface — seedocs/OPERATIONS.md§4d. - Verification Gates —
Partial— Pluggable gate providers, the verification-integrity gate, custom per-project verification commands, browser-based verification, and (spec 401 §1) a project-declaredpinned_test_commandpre-push check run before the test gate — a failure gets one bounded, targeted fix turn (the failing test BLOCK, not just the tail, viaextractFailureEvidence) before falling through to the ordinary gate unchanged. (Spec 443 inc-14, RM-275) a/reviseround now resolves and runs the declaredverify_commandtoo, in the sandbox, leaving the same ledger/anchor/provenance evidence an initial implementation run does — ablock-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 defaultfixer_build_before_pushorder 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//revisealready decide it, rather than clearing unconditionally regardless of what the round's gates actually found. (Spec 443 inc-25, RM-282) the implement and/reviseprompts' "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). EveryBashcall 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 asdeclared_check_runson that pass'sengine_pass_tool_summaryledger row — see theprovenance-and-auditandevidence-packcapabilities 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 declaredtest_command/verify_commandagainst 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 "nobuild_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 recordsbuild_verdict{source:'gate_rerun'}and ablocking_gate_result{gate:'verify_gate'}that, uniquely among this event's writers, carrieshead_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) SKIPSsource:'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 (asetup_commanddaemon and anything it forks are always spared), a live Weftradocker 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 declarestool_proxy_env: inherit(default) |strip_for_testsso 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 finalexec— whoever set them (Weftra's runtime-egress grant sets four; an image, a login profile or the project's ownenv: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 ownrun_config_snapshotrecords 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 ingates:at the commit a feature branch forks from still runs, blocking, in its baseline form, even when the branch's own manifest deletes it, setsblock: falseon it, or replaces its command — pinned on the fresh clone before the agent runs (agate_baseline_pinnedledger 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 showsgate_memo_hit/gate_memo_miss. Host switchGATE_MEMO(default on); entries are bounded to 30 days and 200 rows per project. Each removed or weakened baseline gate is recorded as agate_removedrun 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_resultgains asourceofbaselineorbranchso 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 thegates:key only. (Spec 486 inc-2, RM-371) an admin can set the project fieldgate_removal_policy: require_signoff: FA's own merges (@weftra mergeand 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 withgate_removal_uncheckedwhen the comparison cannot be made. Defaultrecordchanges nothing. Recorded asgate_removal_signed_off/gate_removal_merge_checkrun events, surfaced in the Evidence Packsignoffsection and the provenance check; holds only merges FA performs. Outstanding: 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 fieldtest_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. - Repro Check —
Partial— 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. Outstanding: The repro verdict now downgrades merge-readiness: with the gate armed and a repro captured, a head whose repro iscannot_verify,failor never graded isunverified— anauto_low_riskloop 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 mergemention path has no readiness layer, and the feature-detail dashboard shows no readiness panel. - Principal Identity And Project Roles —
Shipped— 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 reservedlegacymirror tag) and fails closed when one handle is linked to two principals. - External Pr Review —
Shipped— Reviews a PR Weftra did NOT author, on an enrolled project's own repository, viapr_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). - Whole Repo Security Sweep —
Partial— 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 optionalscopeof 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 withruntime_unavailableunlessRUNTIME=docker). One sweep per project at a time; the start is recorded as arepo_review.startedactor event before anything runs (fail closed). Results land inrepo_review_runsas observations, never a verdict — nothing gates on them, and an unparseable or wrong-nonce output reads asinconclusive, never "no findings" (spec 550 inc-1, inc-2, inc-3a, inc-3b, RM-470). A project whose newest completed sweep has afloor/highobservation gets one admin-onlyreview_repo_sweepfleet-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 withtrigger: 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. - Ci Check Remediation —
Shipped— Reads failing CI checks off a Weftra-authored PR (GitHub check-runs + statuses, GitLab pipeline jobs, Bitbucket commit statuses; aci_failurewebhook for a standalone CI system) and, for a check the project has declared actionable viaci_actionable_checks, answers it with a gatedrevisinground — the same governed pathPOST /:id/reviseuses, never a status change on its own. Apo_approvalproject gets aci_remediation_pendingfleet-attention item and waits for an approver;auto_safe/full_autostart the round automatically. Bounded byci_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). - Prior Decisions Conflict Gate —
Shipped— 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. - Merge Conflict Clarification —
Shipped— 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 — andbothis 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 offeredabandononly), 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. - Generated Path Merge Resolution —
Shipped— A base-merge conflict confined entirely to project-declaredgenerated_pathsresolves 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;regenerateis deliberately not a fifth option, because a generated path is never shown to a human at all. - Generated Paths Normalized After Agent Edits —
Shipped— A project's declaredgenerated_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 agit diff-treebetween 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. - Advisory Designer Review —
Shipped— 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. - Execution Checkpoints —
Shipped— 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 recordstree_identity_captured=0with 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. - Governed Work Escalation Record —
Shipped— 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, andproblem/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. - Work Dependency Graph —
Shipped— Work Dependency Graph, Increment 1 (spec 411, RM-170, draft 386 §11/§19/§21 Increment 4 first half): a durable, tenant-scopedwork_relationshipsedge table recording that one work item (a feature or agoverned-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 undersrc/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), andrelated_to/duplicates/discovered_during(excluded from the walk, may legitimately be mutual). The creating route'sfromendpoint is always the path feature — never body-supplied; the body carries onlyrelationship/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 differenttenant_id, including for an admin caller, because increment 2 turnsblocked_byinto 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 byMAX_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. - Work Dependency Graph Blocked Lifecycle —
Shipped— Work Dependency Graph, Increment 2 (spec 411, RM-170, draft 386 §11/§19/§21 Increment 4 second half): theblocked -> re-queuedlifecycle increment 1 deferred. Aqueuedfeature with one or more unsatisfied dependencies (the SAME §5 direction normalization increment 1 defined for cycle detection —blocked_by/depends_onname the dependency as thetonode,unlocksreverses it) moves toblockedinstead 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_countuntouched,resume_flowuntouched. Satisfaction is a closed three-way classification: a feature dependency is satisfied only atmerged(implementedis deliberately NOT satisfied), unsatisfiable atwont_merge/cancelled; a work-escalation dependency is satisfied at triageacknowledged/duplicate, unsatisfiable atrejected/invalid; a dangling id is unsatisfiable. An unsatisfiable dependency never auto-unblocks — a humanwont_merge/cancelled/rejected/invaliddecision 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 ingetQueuedFeatures), 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, ledgeringwork_dependency_blocked/work_dependency_unblocked/work_dependency_unsatisfiable(the last at most once per feature/dependency pair) fail-soft.blockedraises fleet- attention actionunblock_dependencyat the same SLA asawaiting_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. - Federated Login Github Gitlab —
Shipped— 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 asprovider_subject— never the mutable login/username. GitLab maps an address only whenGET /userreportsconfirmed_at; the accountstatefield 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 insideexchange*Code→fetch*Profile— written to no store, redirect, or log — andGET /auth/statusexposes only boolean flags, never a base URL or secret. - Identity Link Provider To Account —
Shipped— 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>/callbackroutes (one redirect URI per provider app) and branches on a signedfa_oauth_statecookie field (mode:'link',linkUserId) minted only by the authenticatedPOST /api/users/me/identities/link/:provider— never derivable or editable client-side. The callback's link branch requires a live session whosereq.user.idequals the cookie'slinkUserId, 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/:identityIdis scoped by a(id, user_id)SQL predicate (self-only, no enumeration) and refuses to remove thelocal(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/closeModalinpublic/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/httpsPostin 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)stateis 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. - Identity Oauth Proven Vcs Handles —
Shipped— Spec 363 (Weftra Identity & Access, RM-127), increment 3: a VCS handle inprincipal_vcs_identitieswas admin-asserted free text — the ONLY authorization check on the@<bot> mergepath (getProjectApproverByVcsHandle), the mention dispatch, and merger attribution. Inc-3 records the provider's own authoritative account name (profile.login) as anoauth_provenhandle, 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, andcompleteIdentityLink'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:proveVcsHandlewrites nothing when the user holds no row for that (forge, host, handle) and recordsprove_refused(reason: no_declaration) instead, so completing an OAuth sign-in can never mint a principal their own entry in the tablegetProjectApproverByVcsHandletreats 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 insrc/models/principal-vcs-identities.ts(proveVcsHandle,demoteProofsForIdentity); the admin declare route (POST /api/users/:id/vcs-identities) createdpendingat inc-3 (inc-4, below, changes that tostagedon a provable scope) and has no request field for it. Proof is EXCLUSIVE — a handle alreadyoauth_provenfor a different user refuses the second claim before any write, recordsprincipal_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 topending(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 withproofin 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:proofis 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 sameextractGitHostparser 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 (sharedinitiateProviderLink, samemode:'link'state cookie, same live-session requirement), andGET /api/users/me/vcs-identitieslists 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 isresolveRequestActor(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 fixedstate_reusedmessage. Login-mode state is unchanged (a documented follow-up). - Identity Admin Prestage User Proves —
Shipped— Spec 363 (Weftra Identity & Access, RM-127), increment 4: after inc-3, an admin-declaredpendingrow 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 —proofis an unconstrained TEXT column,src/models/database.ts:1029): every NEW admin declaration (linkPrincipalVcsIdentity) on a scope the named user could actually prove now insertsstagedexplicitly, 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.pendingsurvives on every row that predates this increment (inc-3's declared rows) and is STILL written by thelegacyusers.vcs_loginmirror on every vcs_login write (an admin PATCH included; thatlegacy/*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 (abitbuckethandle; agithub/gitlabhost 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. Excludingstagedalso takes the handle-changed-hands case out of inc-3's CONTEST check for every new declaration, so the.addevent recordsresolves_to_user_idwhen 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 onstaged, notpending— an unevidenced handle must re-earn authority through proof rather than falling back to a state that still authorizes (ProveVcsHandleResult.upgradedFrom: 'pending' | 'staged' | null).proveVcsHandletreats a staged row as an existing declaration exactly likepending— proving upgrades it in place;recordProvenVcsHandleremains the only writer ofoauth_proven. Adds the prove-it challenge: an admin-issued, stateless, HMAC-signed (samesignValue/verifyValueprimitive 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 notstagedor not provable — and recordschallenge_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), checkspayload.userId === req.user.id(403 otherwise) BEFORE consuming it, consumes it single-use viaconsumeSingleUseToken(src/models/oauth-consumed-states.ts— a sibling ofconsumeOAuthState, same table, domain-separated by hashingkind + ':' + tokenso a challenge token and an OAuth state cookie can never cross-consume; 409 on replay) in the SAME transaction as thechallenge_redeemedit records, and only after every check that can still refuse — row stillstagedand 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-flowmode:'link'state cookie with an addedchallenge: {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_completedand the ordinary?linked=redirect are recorded only when THIS round-trip is the evidence — the proof call targeted that row, the row readsoauth_proven, and itsproven_viais the identity this callback verified; otherwise the staged row is left untouched and it recordschallenge_refused(reason: 'handle_mismatch',expected_handle/presented_handle, ledger-only), and redirects with a new fixedIDENTITY_LINK_ERROR_MESSAGES.challenge_mismatchmessage (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 rowstaged · 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 insessionStorageunder one key until sign-in completes. Tenant reachability: none — both new routes 401 a project key. - Identity Admin Password Invite —
Shipped— 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 rawpassword_hashcolumn, and starting the reset flow for someone else required SMTP: with no SMTP configured,POST /auth/local/reset-requestsilentlyconsole.warns and the admin had no supported fallback.redactUser(src/utils/redaction.ts) now deriveshas_password: booleanand OMITSpassword_hashentirely (by absence, notnull); every user read also carriesidentity_providers: string[](sorted/deduped,[]when none), resolved for a whole page in ONE batched query (listIdentityProvidersByUserIds,src/models/identities.ts, thegetProjectIdsByUserIdspattern).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 EXISTINGgenerateResetToken/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 (sendResetEmailnow returns{sent, reason}instead ofvoid), returns a one-timeset_password_urlto the admin. Refuses 404 unknown user, 503 whenisSessionConfigured()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.htmlgains 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/loginreturns the identical 401 for "no password", "wrong password", and "no such account";reset-requestalways returns 200) is unchanged. The dashboard Users table gains Password (set/not set) and Identities columns and a per-row "Invite / reset password" action; adelivery: "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. - Identity One Time Email Sign In Link —
Shipped— 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 viamintSignInLink(src/models/sign-in-links.ts, a self-contained model that creates its ownsign_in_linkstable lazily, the same patternlocal-secret-material.tsuses —database.tsis not touched), storing only a sha256 hash with a 15-minute expiry, and emails it withsendSignInLinkEmail(shares transport construction withsendResetEmail). The response is200with 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 atomicUPDATE(consumeSignInLink, success only whenchanges === 1, so two concurrent redemptions can never both succeed), refusing with one uniform401for 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 recordsuser.sign_in_link.redeemon 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 anemail_linkidentity 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 whenemailDeliveryConfiguredis false, viaapplyEmailDeliveryStatus) and a confirm panel for?signin-token=, which strips the token from the address bar withhistory.replaceStatebefore 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 touchespassword_hash, role, or API key. No new account, project link, or environment variable is ever created by either route. - Identity Federated Sign In By Subject —
Shipped— 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 refusedidentity_owner_missingand 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_linkedotherwise) and the account does not hold the admin role (admin_requires_explicit_linkotherwise; an administrator links a provider from the Account panel while signed in, the existing link flow, unchanged).upsertIdentitynow throwsIdentityOwnershipErrorbefore 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 anidentity.linkactor event (via: first_sign_in, no email) in one transaction; the session still goes throughissueSession(user.session_issued). Every refusal is recorded asidentity.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_SIGNUPprovisioning, 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). - Identity Multi Role Named Submission —
Shipped— Spec 363 (Weftra Identity & Access, RM-127), increment 5: role becomes a SET (UserRole = 'admin' | 'approver' | 'submitter',users.rolesJSON array, sorted/deduped/non-empty), andusers.rolestays the DERIVED PRIMARY (admin > approver > submitter) — written ONLY bymodels/users.ts(deriveLegacyRole), so every existinguser.role === 'admin' | 'approver'read keeps meaning exactly what it always did. Theusers.roleCHECK constraint is widened via a table-rebuild migration (migrateUsersRoleConstraint, a faithful sibling ofmigrateAutonomyModeConstraint) — string-edits the livesqlite_masterDDL, refuses to guess on unrecognized DDL,PRAGMA foreign_keys = OFFaround the rebuild,foreign_key_checkbefore commit — because four child tables cascade onusers(id)(project_approvers,identities,principal_vcs_identities,project_roles).POST/PATCH /api/usersacceptrolesand/or the legacyroleshorthand (disagreeing values 400);redactUseraddsroles;user.role_changefires on a SET change withfrom_roles/to_rolesalongside the primaryfrom/to. The spec-273submitterproject HAT becomes authorizing for EXACTLY ONE act: a new route,POST /api/features/user, guarded by a newrequireSubmitterAuth(session or user API key holdingsubmitteroradmin; a project key or the sharedADMIN_API_KEYget 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_emailis that person,submitter_verifiedis always true,submitter_claimed_emailis 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/featuresitself is unchanged and stays the machine/tenant submission path, gap and all, by design. Body validation is shared withPOST /api/featuresthrough one extracted helper (validateTenantSubmissionBody) — a submitter arms exactly what a project key can arm and the two paths cannot drift apart.requireAdminOrUserAuthgains 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 gainsubmitter. Self-service:GET /api/users/meaddsrolesandsubmitter_projects;GET /api/users/me/featuresadditionally 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 thefaCLI 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). - Identity Self Service Scoped Tokens —
Shipped— 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/tokensmints — grantable capabilities are the closedSELF_SERVICE_GRANTABLE_CAPABILITIESset (features:submit,features:approve; neveradmin:*ortokens:mint), each one checked at mint time against the SAME per-project linkrequireCapabilitydemands at use (isApproverForProject/hasProjectHat), 403 naming any excess rather than silently narrowing; TTL is clamped byclampTtlSeconds, never a tenant-lengthenable value; the token value is returned exactly once.GET /api/users/me/tokenslists only the caller's own rows (nocnf_pub/root_key_id/token bytes);DELETE /api/users/me/tokens/:jtirevokes one instantly and idempotently. All three arerequireUserAuth-only — a project key, the sharedADMIN_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 bytests/authz-surface.test.ts's function-name walk, unlike mountingrequireCapability's anonymous closure directly):requireSubmitterAuthOrScopedTokenreplacesrequireSubmitterAuthonPOST /api/features/user;requireAdminOrUserAuthOrScopedTokenreplacesrequireAdminOrUserAuthonPOST /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 runsrequireCapability'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 settingreq.userto 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 acapability_token.mint/capability_token.revokeactor event withactor_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 (thecapability_tokensschema change was blocked on a concurrently-open PR holdingsrc/models/database.tsopen). - Product Definition Plane Artifact Store —
Partial— 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-configurableproduct_path(default .fa/product)/definition.json on a freshproduct/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-hashedproduct_artifactstable (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: adefinition-reviewrole (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_raisedrather 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 existingproduct_artifact.reviewledger event — never a new action, never finding prose, never the artifact body.POST .../artifacts/:lineageId/reviewruns it on demand;POST .../artifacts/:lineageId/propose-readyruns it and, on areadyverdict with zero blocking findings, setsstatus='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-indefinition_gate('off' default | 'warn' | 'block', self-configurable like verify_gate) runs a virtual spec_link-kind review over a submitted spec + a declaredstory_lineage_id: 'block' refuses a not_ready submission with 422 and queues nothing; 'warn' queues normally and records the findings on adefinition_reviewrun event; no story_lineage_id short-circuits to a singlespec.tracesfinding 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_viais 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,faclient 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_flipson the ledger) and, at or overDEFINITION_REVIEW_MAX_CRITERION_FLIPS(default 2), records the finding advisory/drift_cappedand excludes it from the verdict — never for Weftra's own store-derived or synthesized findings. Once a lineage has hadDEFINITION_REVIEW_MAX_ROUNDS(default 6) GRADING rounds (a parse-error round excluded), every later review carriesround_cap_reached: true.POST .../propose-readygains 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 withround_cap_reached: true, refuses when Weftra's own store-derived readiness preconditions are unmet, and — only then — marks the artifact ready and recordsproduct_artifact.ready_override(round, carried finding ids, the attributingnote,decided_by) alongside the usualproduct_artifact.readyevent now carryingready_via: 'gate' | 'override'. The override's own Express guard is widened fromrequireProjectOrAdminAuthtorequireProjectOrAdminOrUserAuthSOLELY 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 criterionstill_openand 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'swhat_is_missingto a deterministic, bounded, one-way 16-hex fingerprint (never the prose) — case/punctuation/whitespace/word-order-insensitive,nullfor prose that normalizes to nothing.computeCriterionFlipsgains a SECOND counted reason on the SAMEcriterion_flipscounter 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 forsubstituted_objectionis recorded in full and returnsverdict: 'ready', butpropose-ready's gate branch will NOT writestatus='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. Everyproduct_artifact.reviewevent that graded now also carriesfinding_fingerprints(per-criterion, capped at 8, unioned across the lineage) anddrift_capped_reasons(which of the two reasons fired) besidedrift_capped_ids— a parse-error round records neither, and a lineage's first-ever review remains byte-identical (nofinding_fingerprints,drift_capped_reasons, orcriterion_flipsat all). Every response/event carryingroundnow also carriesmax_roundsso 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-specificdrift_cappedbadge — DOM APIs only, no newesc()site.DEFINITION_REVIEW_MAX_ROUNDS's default is lowered 6 -> 4 (bounds unchanged;DEFINITION_REVIEW_MAX_ROUNDS=6restores 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. Outstanding: Export of the definition tree to a project-declaredproduct_pathin the repo is not built yet. - Product Definition Plane External Artifact Ingestion —
Partial— Product Definition Plane, Increment RM-149 (spec 374), wiki first: a project declaresconnectors: [{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) —kindnames 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), stripsscript/style/svgelements 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 persistsproduct_sourcessnapshots — 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 closedderived_from: [{source_id, version}]field on every kind; a re-ingest that changes a page makes every artifact naming an older version readsource_drifted: trueon 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 oneproduct_source.ingestactor-ledger summary and oneproduct_source.driftevent per (artifact, new version) left stale. Read-only: no write-back to any external tool, ever; no model call anywhere in this increment. Outstanding: 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 —
Partial— 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 adiscovery_docproduct_artifactsrow (versioned, hashed, provenancefa_authored/authored_via: 'discovery', set by Weftra, never a caller). The run clonesbase_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 aDockerRuntimewith 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'sspecAuthorAllowedTools): 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.restoreSincerestores 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/hotspotsmetrics (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'sderived_fromnames amedia: 'repo'product_sourcessnapshot (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 thandiscovery_stale_commits(project-settable, default 50) commits behind the default branch readssource_drifted: trueon 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_TURNS150,FA_DISCOVERY_TIMEOUT_MS30 min, a repo-size guard past whichfocuspath 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 existingGET .../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: withdiscovery_docs_pathset on the project, an admin/approver'sPOST /api/projects/:id/discovery/publishwrites the stored documents verbatim plus amanifest.jsonon a freshdiscovery/publish-<hash12>branch through the sandbox and opens a draft PR a person merges (never the default branch, no agent, stale files kept). Outstanding: 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 advisorystory.known_modulesfinding when a story names a module the repository does not have (FA_DEFINITION_REVIEW_MAX_KNOWN_MODULES). Publish-to-repo is built (opt-in viadiscovery_docs_path). The brief is not yet fed into the agent prompts (spec 380 inc-2b2). - Product Definition Plane Mcp Door —
Shipped— Product Definition Plane doors, Increment 1 (spec 373 inc-1, RM-146) — server side, no UI.authored_viabecomes a closed enum (api,builder,mcp,fa-client,openapi-action, plusmaintainer/importreserved) resolved server-side by ONE function,resolveAuthoredVia(req)(src/routes/product-artifacts.ts): a bodyauthored_viastays a 400; anX-Weftra-Door: mcp|openapi-action|fa-clientrequest 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 rawcurlrecords'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. Sixproduct_*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, andproduct_review— each a thincallFAwrapper over an EXISTING 369/370 project-key route withX-Weftra-Door: mcpset 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);:idis resolved per call from the calling key's own project (GET /api/project), the same waylist_projectsalready does.ALLOWED_PATHSgains 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: nosrc/models/import, noconfig.ts, noADMIN_API_KEYread.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 onlykind/authored_via/verdict/COUNT(*), so a sentinel planted in a title/body can never surface in the response — alongside a staticattributiondisclosure 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 andfaclient CLI verb are the product-definition-plane-openapi-and-client-doors entry below (spec 373 inc-2). - Product Definition Plane Openapi And Client Doors —
Shipped— Product Definition Plane doors, Increment 2 (spec 373 inc-2, RM-146) — the two remaining door CALLERS; inc-1'sresolveAuthoredViamechanism is unchanged.GET /api/product/openapi.jsonis 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 acceptsAdminAuth/UserAuth. Unauthenticated, like the full/api-docsSwagger 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 carryingX-Weftra-Door: openapi-actionrecords that door value inauthored_viafor 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 sendingX-Weftra-Door: fa-client— set in code no flag maps to, mirroring the MCP door'scallFAAsMcpDoor. 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 otherfaverb uses (src/cli/lib/config.ts) — no new credential handling. - Product Definition Plane Canvas —
Shipped— 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, alongsideworkers.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, noinnerHTML, 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 SAMEreview-then-propose-readypath 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 areadypill once a lineage passes. The Builder-only wrapper prefix/api/builder/product/*(src/routes/builder.ts) is every write the canvas makes: it mountscreateArtifactHandler/versionArtifactHandler, the SAME factory functionssrc/routes/product-artifacts.ts's own create/version routes use (extracted there for this reuse — no reimplemented validation/cap/actor-event logic), with anauthoredViaresolver that is a plain closure always returningBUILDER_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 viaX-Weftra-Dooror any other header/body field sent to the raw routes (inc-1'sresolveAuthoredViaand itsDOOR_HEADER_VALUESset, which excludes'builder', are unchanged). Guard isrequireProjectOrAdminAuth— 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 inGET /api/projects/:id/product/stats'sattribution.self_declared_doors(DOOR_ATTRIBUTION_DISCLOSURE), whose note names both mechanisms and says abuildercount is not evidence the canvas wrote the row. The Builder's existingdraft/clarify/refine(src/services/builder.ts) now accept an optionalstory_lineage_id, resolved against the project the same wayPOST /api/featuresresolves it (resolveStoryLineageLink, spec 370) — a cross-project or non-storyreference is a 400 before any model call — and when present,fetchStorySeedreads that story'sstatement+acceptance_criteriaand 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 intests/builder.test.tspasses unmodified). "Submit as feature" carries the resolvedstory_lineage_idonto the created feature — spec 370's intake field — via the samecreateFeatureThroughPriorDecisionGatechokepoint 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 ofGET /api/projects' newest-firstlist[0], which used to land on a fresh pilot and render a blank tree. A new admin-onlyGET /api/product/artifact-counts(src/routes/product-artifact-counts.ts, mounted at/api/productalongside the existing/openapi.jsonrouter — disjoint paths, no shadowing) returns, for every project holding at least one LIVE lineage (noretiredrow),{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 withcreateElement/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. - Product Definition Plane Traceability And Outcomes —
Shipped— 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 withstory_lineage_idresolvesuse_case_lineage_id/outcome_lineage_idONCE, 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 intofeature_spec_records(the three ids plus the story's content_hash at record time), aproduct_chain_resolvedrun 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 reachingqueuedmoves itready -> in_delivery; every remaining linked feature (excludingwont_merge/cancelled) reachingmergedmoves it todelivered.advanceStoryToInDelivery/advanceStoryToDeliveredare 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 afull_autofeature'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 —valueis 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-trackerrendersdocs/current/outcome-tracker.mdfor 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 likedocs/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-projectstory_lineage_idis still the 404-shaped 400; observations and the tracker are project-scoped in SQL. - Agent Factory —
Partial— Agent Factory, Increment 1 (spec 372, RM-152): declare a digital worker as a governedagent_solutionartifact (a 369 product-artifact kind) — its purpose (outcome/story lineages it traces to), workflow (triggers plusobserve/recommend/actsteps, 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.actis accepted only undershadowautonomy — neveradvisory— because it is declared but NOT executable in this increment. Gated exactly like every other product artifact: the spec-370definition-reviewrole 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 isready, and every success measure names a purpose outcome — are decided from Weftra's own store (unmetReadinessPreconditions) and forcenot_readywhen 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 actPOST /api/projectsgates behind an admin) turns areadyversion into an enrolled worker PROJECT in ONE SQLite transaction: config derived ONLY from the declared authority (egress allowlist equalsauthority.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 immutableworker_deploymentsidentity 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 astep_idthat exists in the compiled solution's workflow (missing or unknown is 400), a step whose action_class isactis 403, and a solution body Weftra cannot parse is 503 with nothing created. Arecommendstep's output is arecommendationartifact (a second 369 kind this increment adds: step_id, summary, rationale,proposed_action_class: 'act', evidence refs) — underadvisoryautonomy, creating one notifies the source project's approvers through its existing notification channels; undershadow, nobody is notified.observesteps 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 everyrecommendationSCORABLE. Three trigger kinds all produce the same Weftra-setepisode_ofrecord (episode_deployment_id/episode_trigger_kind/episode_trigger_refon 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|24horat: HH:MM— registered viaPOST .../deployments/:deploymentId/triggersand fired by a bounded host-side scheduler in thereport-scheduler.tsshape, at most one in-flight episode per deployment and a daily cap default 24/hard-96), andwebhook(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 withDELETE .../deployments/:deploymentId/triggers/:triggerId(listed by the siblingGET), 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 sharedADMIN_API_KEYis 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-onlyworker_outcome_events, one writer, a transition that skips a state answers 409) — opened automatically when arecommendstep's episode produces itsrecommendation, advanced only byPOST .../recommendations/:lineageId/respondfrom an approver on the SOURCE project (a worker's own key can never reach this route — same guard as compile).GET .../deployments/:deploymentId/evaluationreturns 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 orhuman_action/notetext. 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, noact, 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.autonomygrows to four closed, ORDERED values —shadow/advisory/draft/act_reversible— andagent_solutiongains an optionalpromotion_policy(per-levelmin_samples/min_agreement, plusmax_refused_actfixed at0andmin_effect_observed/max_open_escalationsat level 3), Weftra-floored at spec write (min_samples >= 10/>= 30for level 3,min_agreement >= 0.8) — a policy below floor is400, not silently accepted.POST .../deployments/:deploymentId/promote {to_level}(admin/approver on the SOURCE project, NAMED — the sharedADMIN_API_KEYis 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 currentworker_deploymentsrow 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-eligibilitypreviews 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"). Anact_reversibledeployment whose evaluation showsrefused_act > 0is AUTO-demoted todraft— recorded, notified, never silent; there is no code path for auto-PROMOTION. Level 2 (draft): adraftstep produces a closeddraft_actionartifact (a THIRD 369 kind:step_id/action_kind/payload_hash/payload/evidence_refs) instead of acting —POST .../drafts/:lineageId/executeruns it under the CLICKING APPROVER's own identity (executed_from_draft: true, never the worker's),POST .../discardretires 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 containadmin:*/features:approve/tokens:mintor anything targeting the source project)∩the operator's ownWORKER_REVERSIBLE_CAPABILITIESallowlist (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. Anactstep executes ONLY as an MCP-gateway tool call under that token —requireCapability's ownverifyCapabilityToken/decide()primitives decide admission server-side; a call outside scope is refused (never silently dropped) and counted asrefused_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 mandatorymin_effect_observed >= 10floor could only be satisfied by a deployment that was ALREADYact_reversible— the level-2 Execute path ran the SAMErunReversibleActiondispatch a gateway call runs and got a realeffectRef, but recorded only anactor_event, never aworker_act_outcomesrow, so the evidence level 3 requires was generated and discarded. NowPOST .../drafts/:lineageId/executewrites the SAMEattempted -> accepted -> effect_observedchain the gateway writes, taggedsource: 'draft_execution'(gateway calls staysource: 'episode', the column's default, so every pre-384 row and reading is unchanged) — a dispatch that THREW writes none of it.GET .../evaluationreports 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;checkPolicySatisfiedstill reads only the total, so both count identically toward the floor — no floor moved. What the total counts is DISTINCTeffect_refs, not rows:write_evidencereturns 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'sepisode_feature_idis the deployment'sgenesis_feature_id— the same value the dispatch itself is passed, so both halves of one execution name one feature; adraft_actionartifact records no originating episode, so Weftra does not guess one.countActOutcomesForEpisode(the per-episodeFA_WORKER_ACT_MAX_CALLS_PER_EPISODEcap) is narrowed tosource = '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 (noinnerHTML): 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 andescalations.openas 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 displayedcontent_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 ashadow/advisory/draftepisode had no way to read Weftra's own records or record anything at all.provisionWorkerActGatewaynow wires the SAMEfa-actMCP server for EVERY episode whose deployment row is the live one, at EVERY autonomy level; the per-episode 362 token's scope becomesdeclared ∩ 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, plusREVERSIBLE_CAPABILITIESat 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 intotools/listby whether the solution's declaredauthority.data_scopescontains a matchingfa:…prefix.write_evidence/file_escalationjoin this observe set while staying members ofREVERSIBLE_CAPABILITIES;operatorPermitschecks observe-set membership FIRST, so on the MINT path they are not gated by the operator'sWORKER_REVERSIBLE_CAPABILITIESallowlist (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. Arecommendstep additionally getspropose_recommendation— the SAMEcreateProductArtifactwriter the tenant route uses, opening THIS episode'sworker_outcomesrow atattemptedand running the SAME rate-bounded advisory notification (now shared viasrc/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 bothtools/listandtools/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 renamedepisode_step_not_executablefor 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 —initializeandtools/listeach write aworker_act.meteredactor 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 aworker 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 immutableworker_packagesrow (insert-only exceptsuperseded_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), andevaluation/*.json.shape: 'hermes'is a RENDERING of that same tree (renderHermes) — it addsSYSTEM.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 disabledschedule/*.jsonstubs; the renderer module imports nothing frommiddleware/capability-tokens/runtime/modelsand branches on nothing but the manifest (§VII).authority.capabilitiesisdeclared ∩the SAME REVERSIBLE_CAPABILITIES/operator-allowlist intersectionmintEpisodeAuthorityTokenapplies 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 forfa_-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 — ashadowworker 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'sactstep 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 onlytools/call:observeiff the agent_solution's newauthority.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; otherwiseact. Anobservecall is admitted at every level (unless the operator has PAUSED the deployment, which silences every method); anactcall is admitted only when the live deployment isact_reversible, the episode's own step is itself anactstep, the newworker:external_toolcapability (a sixthREVERSIBLE_CAPABILITIESverb,WORKER_REVERSIBLE_CAPABILITIES-gated, fail-closed, NOT self-configurable) is in the deployment's live scope — the SAME intersectionmintEpisodeAuthorityTokencomputes, 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 admittedactcall is metered EXACTLY like an internal one: aworker_act_outcomesrow (capability: 'worker:external_tool',source: 'episode', and atoolidentifier built only from operator- or spec-controlled strings — never the agent's own) opens BEFORE the upstream call and reacheseffect_observedon a non-error result; an admittedobservecall leaves oneworker_act.meteredrow. Every admitted call and every refusal counts against the per-episodeFA_WORKER_ACT_MAX_CALLS_PER_EPISODEallowance the internalfa-actpath already enforces — ONE budget per episode across both paths. A refusal is recorded through the SAME lean, boundedmcp_gateway_calldeny path a non-allowlisted server gets, and anact-classified refusal additionally counts towardworker_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'sfile_escalationcall now resolves its own lineage from the episode's live run —originating_run_id/engine_id/model/transport— the SAME fields the humanPOST /:id/escalationsroute resolves server-side, and acceptsblockingas the same non-binding advisory it is for any author, recordingblocking_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: anobserve/recommend/draftstep whose whole output is ledger rows written through the channel (increment 4 part 1 above) used to fail on the ordinary empty-commit guard withcause: 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);implementFeaturenow branches onfeature.episode_deployment_idat the empty-commit guard, so a clean-tree episode judgedcompletedendsimplementedwithpr_url: nulland a fail-closedepisode_completedrun event (no commit, no push, no PR), and one judgedno_evidenceendsfailedwith the new closed causeno_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 — anactepisode 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'scompletedcount is widened tostatus = 'merged'OR (status = 'implemented'ANDpr_url IS NULL), so an evidence-completed episode is counted rather than sittingimplementedforever with no PR to merge; the Workers panel (public/workers.html) renders the deployment's summedevidence_notesbeside its episode counts. Spec 389 (RM-162) gives a worker its own WORKING MEMORY — deliberately never a skill: an optionalmemoryfield (retention_days1-365, up to 20 named privacy classesinternal/personal/sensitive,subject_opt_out), closed and content-hashed like every other part of the spec, refused 422 if declared withoutworker:stateinauthority.capabilities. Compiling copies it into the compiled-config hash and the constitution's own## Memorysection. 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 onmemorybeing declared at all (never on capabilities alone).state_putis the ONE writer (src/models/worker-state.ts) and checks a durable opt-out table on every call, so a subjectstate_forgetmarked opted-out can never be written again; asensitive-class value is instance-encrypted (the same at-rest key Weftra already uses for its own secrets),internal/personalare 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 declaredretention_daysand 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 reachesmcp-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 —compileAgentSolutionalways 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 optionalauthority.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 matchingequals/prefix/a length- and construct-boundedregexadmitted 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 acapability/tool-prohibited name fromtools/listAND from what atools/callcan 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 anargument-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 declaredobserve, and acapability-form prohibition onworker:external_toolcovers every external call whatever class it was given. The draft-execute route re-checks the same list before running an approver-clickeddraft_action, so the other door onto the same dispatch is not a way around it. A denied call is refusedprohibited_act_refusedwith the ledger'sargsfield replaced by{prohibition_index, form, arg?}: the argument NAME the offending rule matched, never its value. A refusal the prohibition itself caused is recorded asworker_act.metered— a stopped prohibited attempt is a control working, not a demotion signal — while a call that was unauthorized anyway keeps theworker_act.refusedaccounting it had before this spec, so a prohibition never buys immunity from auto-demotion.computeDeploymentEvaluationreports the count asprohibited_refusals(Workers panel, one moreevalCell) — visible to an operator, never scored against a promotion floor.compile.tscopies the list into the compiled-config hash and renders a## Prohibitedsection in the worker's constitution; the exported package's manifest carriesauthority.prohibitedverbatim and thehermesshape expands it intotoolset.json'sdeny(forms 1-2, capability entries expanded to the concrete tool ids that capability would otherwise grant) andargument_denies(form 3), withSYSTEM.mdrendering each entry's reason. Spec 407 (RM-134/RM-178), origin Pilot 0 episode472274f2: anobservestep had run as an ordinaryimplementFeaturejob — the CLI's own tool grant and prompt were never keyed to the step'saction_classat all, only the gateway's owntools/listwas, so nothing stopped an observe worker from runninggit pushdirectly and bypassing the gateway entirely (it did — seedocs/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'saction_class, before the agent's first turn, REPLACINGfeature.allowed_tools(never merged with it, same "replace never merge" rule spec 368 established):observe/recommendgetRead/Glob/Grep/LSplus the step's ownfa-actgateway tools asmcp__fa-act__<tool>rules (derived fromtoolNamesForStep, 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 noBash, noWeb*, noTask, no write of any kind;draftaddsWrite/Edit/MultiEditconfined to the step's declaredoutput_paths(an inexpressible entry — sameisRuleExpressiblePathcheck spec 368'sdocPathuses — fails the run before the agent starts, never falls back to a wider grant);actgets 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) replacesbuildImplementationPromptfor 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.restoreSinceruns unconditionally and fail-closed forobserve/recommend/draft(protecting only adraftstep's declaredoutput_paths), so adraftstep'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 —implementedwith a pushed branch is unreachable forobserve/recommend. The test/ verify/declared gates are skipped entirely forobserve/recommend(nothing to test — Pilot 0's own postmortem showed an exit-127test_command"passing" proves nothing either way);draft/actrun them exactly as before. Recorded on the ledger asepisode_posture(before the first turn) andepisode_tree_restored(after).FA_EPISODE_POSTURE_MODE(defaultenforced) can be set tolegacyas 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-timePOST .../packages/:packageId/enrolltoken (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 atomicUPDATE … WHERE revoked_at IS NULLevery 362 revocation uses (a losing concurrent redeem sees zero rows changed and is refusedenrollment_consumed, recorded), re-resolves the LIVE deployment the package was cut from (retired →deployment_superseded, recorded), and creates the episode feature through the SAMEfireEpisodeevery trigger kind uses — but forced straight toin_progresswith a newepisode_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 signedjtimust be theepisode_authority_jtithe redeem stamped on exactly onein_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 inmodels/features.ts(getQueuedFeatures/getRevisingFeatures/getRetryableFeatures/getAutonomousSecurityFixCandidates/getFeaturesWithOpenSecurityFixerRound) and the stuck-in_progresscrash-recovery sweep inagent-engine.tsnow addepisode_runtime IS NULL;tests/prior-decision-relaunch-guard.test.tspins the rule.POST /api/worker/episodes/:id/complete {summary, claimed?}writes each item as aepisode_claimed_evidencerun event (fidelity: 'claimed', bounded, redacted) — a channel entirely separate from the OBSERVED evidence the gateway itself writes (worker_act_evidence_writtennow carriesevidence_fidelity: 'observed', channel: 'gateway') — then closes the episode with the SAMEdecideEpisodeCompletion(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_evidenceotherwise; 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, markedepisode_expired.computeDeploymentEvaluationreportsclaimed_itemsas its own counter, read by nothing that feeds a promotion decision — a claimed item was never written toworker_act_outcomes/worker_outcomesin the first place, so promotion eligibility is unchanged by any number of them. Cancelling an external episode revokes its token and landscancelledwith 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 newGET .../agents/:lineageId/deployments/:deploymentIdboth resolvesolution(title,purpose.outcomes/purpose.storieswith resolved titles,steps[]withescalate_when,human_steps) from the compiledagent_solutionartifact 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 — plustriggers[]withlast_fired_at(both kinds) and, forscheduleonly,next_due_atcomputed from cadence andlast_fired_at(computeScheduleNextDueAt, episode-scheduler.ts), and agenesis_feature_id. A newGET .../deployments/:deploymentId/episodesroute is spec 409's paged list contract ({items,total,limit,offset}, default limit 20) over one deployment's episode features, newest first, each row'soutcome({kind:'completed'|'failed', cause?, summary?}) resolved from the lastepisode_completed/run_failurerun event (resolveEpisodeOutcome, episodes.ts, vialistRunEventsTail). Inc-2 (the page):public/workers.html(DOM APIs only) now renders all of it — the card title issolution.titlewith 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 bareraised/resolvedcount. NewGET/PATCH .../deployments/:deploymentId/escalations[/:escalationId](src/routes/agent-factory-episodes.ts), guarded by the samerequireAdminOrApproverForProjecton the SOURCE project as every other route in that file.listWorkEscalationsForDeployment/getWorkEscalationForDeployment(src/models/work-escalations.ts) joinwork_escalationstofeaturesonfeatures.episode_deployment_id, never onwork_escalations.project_idalone — 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.PATCHreusessetWorkEscalationTriageStatusunchanged (the same four statuses, the sameresolveRequestActorattribution asPATCH /api/escalations/:id) and additionally records an actor event (work_escalation.triaged, added toACTOR_EVENT_ACTIONS' closed enum) with{escalation_id, deployment_id, from_status, to_status}— the older route is deliberately not backfilled.public/workers.htmlrenders an Escalations section on every deployment card (DOM APIs only): class/status/blocking chips, the fullproblemtext, 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 ownEscalationscell 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, optionalhermespreamble); a Packages list (shape, shortpackage_hash, created by/at,superseded_by, a Download link built from the route template plusencodeURIComponent(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 fromGET .../packages's own response, whichserializePackagenow widens withlevel/tool_allowlistparsed 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 — neverlocalStorage,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'sfile_escalationverb 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 alreadysubmitted(findOpenWorkEscalationByTarget,src/models/work-escalations.ts— every scope dimension is in the SQL;classis deliberately NOT part of this key): Weftra writes no second row, and compares the repeat'sclassto the open row's current class. Same class: byte-identical to before — nothing written,file_escalation_deduplicatedrecorded. A STRICTLY MORE severe class (by Weftra's own fixedWORK_ESCALATION_CLASS_PRECEDENCE,securitystrictest throughopportunityweakest): the open row'sclassis RAISED (reclassifyWorkEscalationClass),class_originalis set once to the class it was filed under,reclassified_countincrements, andescalation_reclassified(applied: true) is recorded instead of the dedup event. A LESS severe class: the row'sclassand every other triage-visible column are untouched, onlyreclassified_count/last_reclassified_atmove, andescalation_reclassified(applied: false) is recorded — asecurityrow re-reported asdocumentationstayssecurity. 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 atreclassified_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: theworker_act_outcomesrow stays atacceptedwith a NULLeffect_refrather than counting towardmin_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 wayproblemalready is before either the lookup or the write.class_original/reclassified_count/last_reclassified_atare surfaced on bothGET .../deployments/ :deploymentId/escalationsandGET /api/escalations/GET /api/features/:id/escalations, andpublic/workers.htmlrenders areclassified ×Nchip 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 redactedproblem, deployment and reporting principal (underactor, the operator-identity key family a project-key ledger read strips) ride on theescalation_reclassifiedevent (bothappliedvalues) and the row itself says where to look; a same-class repeat recordsfile_escalation_deduplicatedwith spec 420's byte-identical payload and no text anywhere. The raise applies only to rows an automated caller filed:reclassifyWorkEscalationClass's own WHERE requiresoriginating_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 ownaction_classexists to call — the gap Pilot 0 v5 hit (arecommendstep declared withworker:propose_artifactand notworker: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, beforecreateProjectmints anything: arecommendstep that cannot reachworker:propose_recommendation, or anactstep that cannot reach any ofworker: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 onetools/list/tools/callare built from), called with and without the declaredprohibited[]to tell the two causes apart, never a second capability map. Anactstep is checked at the ladder's CEILING (act_reversible), never at the autonomy the solution actually compiles at, since a compile always enrolls atshadow/advisorywhere act tools are absent by LEVEL — the check is about whether the capability was ever declared, not about the level.observeanddraftsteps are unaffected, and an already-compiled deployment is never revisited.steps[]on both deployment read routes (GET .../deployments,GET .../deployments/:deploymentId) gainstools: string[]— the tool namestoolNamesForStepwould actually offer that step at the deployment's LIVE autonomy, deliberately NOT folding in the operator'spaused_at(the page's existing paused indicator already covers that) — plustools_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 thepropose_recommendationfiling boundary, so a recommend worker that never consults its own prior decisions (spec 429'slist_recommendations) still cannot re-propose a class an operator already decided.propose_recommendationgains 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 decidedaccepted/superseded/effect_observed/outcome_linked(refused for good) orrejectedwithin an operator cooldown window (FA_WORKER_REJECTED_CLASS_COOLDOWN_HOURS, default 168h,0disables);attemptednever 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 reasonclass_already_decidedand gateway code -32036 naming the class/state/lineage id/decided_at (never a deciding principal), and records onepropose_recommendation_class_refusedrun 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 staysaccepted, but it is never aworker_act.refusedrow, so it cannot feed auto-demotion — a worker re-proposing a decided class needs itsagent_solutionfixed, 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, beforecheckEpisodeBoundand beforefireEpisodeare ever reached. A deferral creates no feature row, so it costs no retry and nomax_episodes_per_dayslot; it is recorded once per (trigger, reason) streak as aworker_episode_deferredrun event (never aworker_episode_skippedrow — a deferral is not a skip), and re-fires on the next tick once the pool recovers. Separately,episodesTodaynow excludes afailedepisode whose latestrun_failurecause never got to do model work (credit_exhausted,credit_balance,oauth_expiry,infra) —no_evidencestill 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 }onGET .../deployments), never from a request body. Spec 443 inc-32 (RM-259): the scheduler's ownworker_episode_skippedstreak guard (bound tripped —in_flight/daily_cap) is now keyed per TRIGGER, the samegetLatestRunEventOfTypeForTriggerread 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 }onGET .../deployments). Spec 439 (RM-255): a declared external tool (authority.tools[], spec 388) now carries a required, closed, boundedinput_schema—{type: 'object', properties?, required?}, one level deep, mirroringToolInputSchema(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".compileAgentSolutionrefuses (400) a stored artifact whose declared tool lacks it — the defense-in-depth half, since schema.ts never re-validates a row already inproduct_artifacts.WorkerPackageManifest.tools.external_toolscarries every declared tool verbatim, sorted by server then tool; the hermes shape'stoolset.jsoncarries the same array andSYSTEM.mdgains 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 rawfa_…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-accessmints/lists/revokes it, guarded byte-for-byte like the spec-381 inc-2 enrollment mint (admin/approver on the SOURCE project, NAMED — the sharedADMIN_API_KEYis refused).src/services/agent-factory/worker-vocabulary-mcp.tsbuilds the five tools as THIN WRAPPERS over the EXISTINGepisode-reads.tsfunctions — the same read surface thefa-actgateway 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 declaredauthority.data_scopes∩capabilities∩prohibited— the three filtersact-gateway.ts'stoolNamesForStepalready applies — soworker:readalone never widens what a deployment exposes, and the mint response'stoolsarray names the real envelope at the moment of granting it.src/routes/mcp-http.tsresolves 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 ownlist_featurestool, so that one name exists on both).read_evidencehas no calling episode to default to at this door, so itsepisode_feature_idargument 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 onactor_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 writeworker_tool_access.mint/.revokeactor 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 namespacedtool_caller+<label>@…principal Weftra derives from the request'scallerlabel, 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 grantedworker:trigger_episodegets one more tool,trigger_episode { step_id }— the one verb with nofa-actgateway counterpart (an episode cannot fire an episode) — that starts exactly ONE governed episode through the samefireEpisodebounds 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-freepublic/project-selection.jspicker inc-70 gave the Product canvas. A new admin-onlyGET /api/product/worker-deployment-counts(src/routes/product-artifact-counts.ts, alongside the existing/artifact-countsroute — 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 declaringworker_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 withcreateElement/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 asworker_deployment.submit_target_bound/.submit_target_cleared); a worker'ssubmit_featureact may then opt in withdestination: "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. Outstanding: Increment 3 (spec 376, RM-154) is built: the four-rungautonomyladder is closed, evidence-gated+1-only promotion and free/instant demotion (with 362 revocation) are live,draft_actionis a human-executed artifact kind, and a level-3 (act_reversible) episode'sactsteps 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_reversiblecan now be reached through the ordinary named-approver route (a human-executed draft's evidence counts, tagged and split bysource, without touching themin_effect_observedfloor 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 — ashadow/advisoryworker 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 — anobserveepisode that correctly changes nothing still fails the empty-commit guard); levels beyondact_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 beyondact_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-freegeneric-jsonorhermespackage. 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_refusalsin the evaluation, and the package'sdeny/argument_denies. Outstanding: nothing declared out of scope by spec 390 itself; the agent-solution-builder's own interviewprohibitions[](free text) is not yet mapped onto this closed field — seesrc/services/agent-builder/derive.ts's ownnot_yet_expressiblenote, 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'saction_class(fixed, non-widenable, replacingfeature.allowed_toolsrather 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 anobserve/recommendepisode can ever reach a commit/push/PR path.episode_posture_mode: legacyis the explicit, ledger-recorded opt-out. deterministic, credential-freegeneric-jsonorhermespackage. 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 (observedfrom the gateway,claimedonly 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 kindevaluation,base_branch = given.fixture_ref,given.inputsquoted DATA in a new## Inputsspec-text section,given.stateseeded into a run-scopedeval:<runId>:memory namespace wiped when the run ends) through the SAMEfireEpisodeseam and daily allowance every other trigger kind uses.POST .../evaluateanswers 202 with aworker_evaluation_runsrow that captures every version binding (spec/constitution hash, model, engine profile, skills, knowledge) BEFORE the first case fires; a tick driver (mirrorsepisode-scheduler.ts) advances one run one step per tick, deciding each terminal case with a PUREdecideCase(expectation, ledgerFacts)— no model grades a case. I1: a case episode's tool set is pinned to the observe set pluspropose_recommendationregardless of the deployment's live autonomy, via a Weftra-set marker on the feature's ownepisode_trigger_kindcolumn — anact_reversibleworker's case episode still lists noact-only tool and adraft_prcall is refused.GET .../evaluations/GET .../evaluations/:runIdreturn 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-inpromotion_policy.require_evaluation_passboolean (defaultfalse, part of the content hash) thatcheckEvaluationPassRequirement(src/services/agent-factory/promotion.ts) enforces as a SECOND, additional gate applied to a promotion to any rung — the LATEST completeworker_evaluation_runsrow 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 viasrc/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-eligibilitysurfaces the identical check's answer asevaluation_requirement, so the preview can never disagree with the real promote call.adversarialis an ordinaryclassvalue decided by the samedecideCasetable as every other case (I3); the runner marks its verdictred_team: truepurely 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, noinnerHTML. Increment 5 part 1 (spec 418 inc-1, RM-214) is built:examples/agent-factory/hermes/— adocker-compose.yml(this repo's Weftra image + the official pinned Hermes Agent image) anddeploy.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.mjsis 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, unmodifiednousresearch/hermes-agent:v2026.8.31image (2026-09-09, episode35e09221, 50gateway_observedevidence 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.tsnow rendersconfig.yaml(a FRAGMENT: onemcp_serversentry running the committed stdio bridgeexamples/agent-factory/hermes/fa-mcp-shim.pyagainst${FA_GATEWAY_URL}with${FA_WORKER_EPISODE_TOKEN}, and atoolsetsrestriction to that one MCP server) in place of anmcp.jsonHermes never read;manifest.jsongainedworkflow.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.mdnames thefa-gatewayMCP server as the tool surface instead of a URL the runtime could never reach;manifest.tools.mcp_gateway_urlnow names the worker gateway's own invoke route (/api/internal/mcp-gateway/invoke) instead of the unrelated remote-MCP front door (/api/mcp); anddocker-compose.yml'shermesservice runs as the mounting uid (HERMES_UID/HERMES_GID) withHOME=/opt/dataand 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.mdgained 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 nowrun-hermes.mjs— a dependency-free Node script, so it can be driven by the test suite with a fakedocker— which readsFA_EPISODE_STEP_IDfrom the SAME runtime-env filedeploy.mjsalready wrote (no silent fallback step), merges the exported package's ownconfig.yamlfragment viahermes-home.mjs'scomposeHermesConfig(thetoolsetsrestriction Weftra exported, never widened — it changes only the two placeholders and the shim's path), and starts Hermes with no--yolo, no-t, nofeatureagent.instancelabel.deploy.mjsnow defaults its start command torun-hermes.mjs(neverdocker 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 composehermesservice and its Dockerfile are removed — a long-runninggateway runservice 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 optionalworkspace: 'repo' | 'none'onagent_solution(default'repo', byte-identical), refused at validation time when combined with anactstep orworker:draft_pr/worker:submit_feature(the two capabilities whose whole effect is a repository). Compiling a'none'solution needs noworker_repo_url; the worker project is created withrepo_url: ''and the newworkspace_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 is409. 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.implementFeatureresolvesrepoLessonce (resolveWorkspaceMode) and provisions an empty, disposable workspace (provisionRepolessWorkspace, no clone/.git/remote/git lfs pull;data_dirs/setup_commandstill 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 (onlyobserve/recommendcan 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 declaredoutput_paths— the repo-less equivalent of spec 407's tree restore, stronger because the declared baseline is EMPTY — and recordsepisode_tree_restoredwithmode: 'workspace_none_empty_baseline'; a walk/delete failure fails the run. Completion is decided solely bydecideEpisodeCompletion, unchanged; the commit/push/PR path is structurally unreachable (only observe/recommend can run here, and both always resolve through the evidence-completion branch orEpisodeNoEvidenceError) and carries arepoLess &&throw at its top as a defense-in-depth assertion.branch_nameis omitted (never a name for a branch that will never exist) andpr_urlstaysundefined(nevernull).POST /:id/create-prandPOST /:id/revise(and their/admin/twins) now refuse409for a repo-less project's feature, before any VCS call. An ordinary (non-episode) feature submitted to a repo-less project is still refused409, now with the permanent message ("accepts worker episodes only").buildEpisodePrompttakesopts.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_EPISODEis now the operator CEILING over a per-episode budget COMPUTED from a step's optional declaredbudget: {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'sresolveEpisodeBudget), clamped to the ceiling and recorded once per episode asepisode_budget_setbefore 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 forwrite_evidence/file_escalation/propose_recommendationalone, 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 fromepisodes.refused_act/auto- demotion the same way itsepisode_call_cap_exceededsibling is; either budget refusal carries a JSON-RPCerror.datanaming 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) addslist_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 thefa:product_sourcesdata scope on top ofworker: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 bydeployment_id— no caller-supplied project/episode argument) so it never re-proposes a class a human already decided. Gated by the SAMEworker:readcapability every read uses, plus a new machine-checkedfa:recommendationsdata-scope prefix; on arecommendstep only, that scope is IMPLIED by a declaredworker:propose_recommendation(toolNamesForStep's recommend branch extends the data scopes passed toobserveSetToolNamesfor that branch alone — the capability intersection itself,onlyDeclared, is unchanged, and every other class still needs the scope declared explicitly). Each item nameslineage_id/version/title/summary(read off the recommendation artifact; a missing artifact yields nulls, never a throw),state,episode_feature_id,proposed_at, anddecided_at(the outcome row'supdated_atonce 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 SAMEstep_id(a non-actstep) andcadence(the closed subsetvalidateCadencealready enforces)POST .../triggersaccepts as a body — validated by REUSING that same function, never re-expressed, and refusing exactly the same shapes the route refuses (an unknown oractstep, 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 astep_idre-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 SAMEcreateWorkerTriggerthe route calls, once per declared trigger, withcreatedBya new reserved, non-human principal (WORKER_TRIGGER_COMPILE_PRINCIPAL = compile@featureagent.local, classifiedsystemand 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; anactstep stays unreachable by a trigger in both paths. Oneworker_trigger_registeredrun event is recorded per registered row.GET .../triggersnow reportsarmed_by: 'compile' | 'operator'per trigger (derived by exact comparison against the reserved principal;created_by_kindis unchanged). The Workers page marks each bound trigger with itsarmed by compile/armed by operatorchip 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'srecommendstep 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_CAPABILITIESwidens by exactly one member,worker:file_escalation— Weftra's own canonical name for RM-173'sescalate_work/report_blocker; no alias tool name exists anywhere insrc/. Omittingcapabilitieson mint now yieldsworker:readALONE (a deliberate change from inc-1: a write grant is explicit or it does not exist, never inherited silently on upgrade), and a requestedworker:file_escalationis refused400when the DEPLOYMENT's own compiledagent_solutiondoes 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-timeisToolListedDespiteProhibitionscheck the fa-act gateway and inc-1's read path already run, both imported rather than restated. The door'scanEscalategate 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, refusingprohibited_act_refused— the sameevaluateProhibitionscall 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 ownWRITE_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_bystaysnulleven when the payload setsblocking: true(only an episode's own call ever claims that), the principal is the token's namespacedtool_caller+<label>@…subject, andoriginating_run_id/originating_actor_idstaynull— there is no run. Most load-bearing: it is NEVER PROMOTION EVIDENCE.worker_act_outcomes.sourcegains'tool_caller'(a TypeScript union member only — no migration, the column has noCHECK), andactEvidencePredicateflips 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 clearedmin_effect_observedand 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: atool_callerrepeat 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-jticall budget gains a stricter WRITE sub-budget in the SAME window (10/minute, tracked by awriteCountfield on the existingcallBudgetByJtientry — still the only module-levelMapin the file). Every call, allowed or refused, records the existingworker_tool_access.callactor event; the escalation'sproblemtext 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 zeroinnerHTML. 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, whoseepisode_deployment_idisNULL, 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_CAPABILITIESwidens by one more member,worker:trigger_episode— the ONE verb this door admits with NOfa-actgateway counterpart at all (an episode cannot fire an episode); it is deliberately outside every episode-authority ladder (in noREVERSIBLE_CAPABILITIES/WORKER_OBSERVE_CAPABILITIES/level set), so no worker episode token can ever carry it. A requested grant is refused400when the deployment's compiledagent_solutiondeclares no workflow step at all — the only deployment-side fact available for this verb, since (unlikefile_escalation) it is never a member of a solution's declaredauthority.capabilities. The door'scanTriggerEpisodegate re-derives the token's grant AND the deployment's live declared prohibitions (form 1/2) on EVERY request, same discipline ascanEscalate, 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 SAMEfireEpisodefunction (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 carriesfireEpisode's own status and message verbatim.episode_trigger_kindis recorded'manual'(the closed column's spec-381-inc-2 precedent — no schema change) andepisode_trigger_refistool_caller:<jti>, Weftra-derived from the token, never a person's name or email. Aworker_episode.firedactor event is recorded with the token's namespaced principal andorigin: 'tool_caller'. Because the trigger-kind column alone would read as a human action,GET .../deployments/:deploymentId/episodesadditionally returnsfired_by('tool_caller'/'schedule'/'webhook'/'evaluation'/'other', POSITIVELY classified from that episode's ownworker_episode.firedactor event, one BATCHED query per page — never one per row) andfired_by_caller;public/workers.htmlrenders a tool-caller-fired row asexternal tool caller · <label>in the episodes table's trigger cell instead of the plainmanualan 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 becauseshapes/hermes.tswrote a top-leveltoolsetskey, which nothing in a Hermes CLI run reads — fixed by renderingplatform_toolsets.cli: ["fa-gateway"]instead. The key and the baremcp_serversname were NOT checked against upstream source in this increment (hermes_cli/tools_config.pyat the pinned tag could not be fetched from the build sandbox) — they stay unverified until the live run.hermes-home.mjs'scomposeHermesConfignow requiresplatform_toolsets:in the fragment. The reference harness (run-hermes.mjs) now starts the Hermes container on a per-run--internalDocker 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:portonly, one log line per allow/deny decision, saved toegress.login 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:443foranthropic; any other provider needsHERMES_EGRESS_ALLOW, or the run fails at a named step before any container starts) and anyHERMES_EGRESS_ALLOWentry the operator adds.--internaldoes 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.mjsalso now exits non-zero whenever the completion response'soutcomeis notcompleted(including a missing field), sodeploy.mjs's watch loop fails within one poll interval instead of waiting out its full timeout on ano_evidencerun — 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 indocs/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 sametoolset.json/memory-policy.json/schedule/*thehermesshape emits for the same manifest (pinned byte-identical).buildWorkerPackage's dispatch is now exhaustive (generic-json/hermes/openclaw, anever-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 ownOPENCLAW-README.md, and indocs/USER_GUIDE.md. Spec 477 inc-2 (RM-351 b) is built: a reference harness,examples/agent-factory/openclaw/, runs an exportedopenclawpackage in a container through one tested start path (run-openclaw.mjs, with a purecomposeOpenClawConfig), 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 ONEepisode_runtime_usage_reportedrun event,fidelity: 'claimed', the same claimed- evidence disciplineepisode_claimed_evidencealready carries: never checked against anything, never changingoutcome, never added tototal_cost_usd/a token column/the usage rollup/any promotion input. An unknown key, out-of-range value, non-object, or emptyusageis a400naming the field, nothing recorded. The episodes list route (GET .../deployments/:deploymentId/episodes) gainsruntime('external'/'internal', fromfeatures.episode_runtime) andruntime_reported_usage(that event's validated keys plusreported_at, ornull) per row —cost_usdkeeps 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 neverargs),worker_act_evidence_written(reusingread_evidence's own note mapping),episode_claimed_evidence,episode_runtime_usage_reported,episode_completed, and the run-failure event — each item carryingseq/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, aruntime-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 visibleclaimedmark. - Agent Factory Runnable Walkthroughs —
Shipped— 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(anoutcome, ause_case, astory, and anagent_solutionwith anobservestep and arecommendstep, oneauthority.prohibited[]entry, oneevaluation.success_measures[]entry, and amemorypolicy — all dummy data, no real customer or repository named anywhere in the directory) andrun-governance-pilot.mjs(dependency-free Node,fetchonly; readsFA_BASE_URL/FA_ADMIN_KEY/FA_WORKER_REPO_URLfrom the environment ONLY, never argv; exits2with a usage line and never echoes the key whenFA_ADMIN_KEYis 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 ashadowdeployment, arms an hourly schedule trigger, fires one episode viaPOST …/episodes, polls until it ends, prints its evidence (worker_episode_started, thenepisode_completedor arun_failurecause), 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.tsruns the identical step sequence in-process (supertestagainstcreateApp(), an in-memory database, the engine mocked exactly astests/agent-factory-episodes.test.tsmocks 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 rowsimplementFeatureitself writes (recordRunEvent(..., 'episode_completed', ...),updateFeatureStatus(..., 'implemented')) directly, standing in for the async agent-engine run a plaincreateApp()test harness never starts — full sandboxed-episode-execution coverage stays intests/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 fordocs/design/agent-factory-path-to-pilot.md§8 G0 — Weftra's own Retrospective worker, a realagent_solutionon Weftra's own project (not dummy data), already authored inpilots/pilot-0-retrospective/. It probesFA_HELPER_CALLBACK_BASE_URL/healthfirst (the sandbox-facing listener RM-176/spec 413 added) and names theFA_CONTAINER_BINDfix when unreachable, resolves Weftra's own project fromFA_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 hourlywatch_verdictsschedule trigger, fires that step's episode immediately instead of waiting an hour, waits for it to end, and assertsimplementedwith anepisode_completedevent, 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). Arun_failurewhose 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;--liveis what an operator runs to produce RM-134's evidence, attached todocs/design/fa-roadmap.mdby the operator, never generated by the script itself.tests/examples-agent-factory-walkthrough.test.tsgained a matching in-process case that loads the realpilots/pilot-0-retrospective/agent-solution.jsonartifact (its actual four-step shape:watch_verdicts/grade_learningsobserve,propose_candidatesrecommend,propose_improvementact) and drives it through the same declare → ready → compile → arm → fire → evidence → Workers-tab-listing sequence.docs/WALKTHROUGH.mdgained an "Agent Factory" chapter linking both scripts. Still no new route, field or capability. - Identity Require Proven Vcs Handle —
Shipped— Spec 363 (Weftra Identity & Access, RM-127), increment 6: after inc-3/inc-4, apending/staged-turned-legacyorusers.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 addsREQUIRE_PROVEN_VCS_HANDLE, an operator-only, env-only, default-OFF switch read once at startup (config.requireProvenVcsHandle,src/config.ts— onlytrue/1, trimmed and case-insensitive, arm it; anything else, including unset, is off, fail-safe). Not a runtime setting (absent fromPUT /api/admin/settings/runtime's editable allowlist) and not a project/feature field (absent fromSELF_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 VALUEresolveProvenWinsreturns in bothgetUserByVcsIdentityandgetUserByHandleForScope(andfindUsersByHandleForScopefilters its whole result list the same way): when the flag is on and the winning principal's grouped candidate is notproven(any matching rowoauth_proven), the answer becomesnull. 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 alwaysresult(OFF)ornull, never a different non-null answer (stricter-only). Because alegacyrow (users.vcs_loginmirror) can never beoauth_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> mergeinsrc/services/flows/weftra-merge.ts, mention dispatch insrc/services/mentions.ts, and inbound PR-review-trigger dispatch insrc/routes/triggers.ts) andresolveMergerActor(src/services/feature-actors.ts, spec 274 merger attribution — an unproven merger resolvesunmapped, composing withseparation_policy'srequire_provenSoD level with zero changes toseparation-policy.ts). The two INFORMATIONAL readers of the same table —resolves_to_user_idon the admin declare response and onlistPrincipalVcsIdentitiesView— 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. Thatunmappedhas a cost the runbook states plainly: it also makes spec 274's merger/submitter comparisonindeterminate, so nomerge_actor_collisionrow 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_resolvedcarriesmerger_proof_withheld: trueexactly when the handle DID link to a principal and the proof requirement is what emptied it (resolveVcsIdentityForScope; the key is absent, notfalse, 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 existingmention_unauthorized/mention_authorizedrun event, theweftra_merge_refused(unauthorized) ledger row, and thepr_review_runsunauthorized refusal each gainproof_required: config.requireProvenVcsHandle(folded into theerrortext forpr_review_runs, which has no metadata column). No new writer ofproof, 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 inproject_approvers, matched forge+host+legacy likeloadScopedCandidates) andattribution/would_lose_attribution(every principal holding a row matching a project's own forge+host, approver or not, becauseresolveMergerActorresolves role-independently — without it an operator could readwould_lose_authority: 0and still lose attribution on arming).readyis computed per HANDLE STRING (the unitapplyProofRequirementrefuses) 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. - Evidence Pack —
Shipped— 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 isEd25519(root_key, sha256(canonical_json(statement))), withkey_id = sha256(public_key)[:16]; the public key is published (unauthenticated, rate-limited) atGET /api/instance/verification-keysince a verifier may hold no Weftra credential at all.GET /api/features/:id/evidence-packreturns{statement, signature, key_id}(?format=markdownfor 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/verifycryptographically 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. Onmerged, the pack is captured as a feature artifact (evidence-pack.json, sourcefa_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 theprovenance-checkcapability below. Spec 443 inc-10 (RM-262 o):verdicts.code_review[]gainsstale: boolean|null— a pure projection of that entry's ownhead_sha(non-null only when its round proved the binding) against the pack's ownhead_sha, never a fresh read;nullwhen the entry carries nohead_shaat all (unknowable, never reportedfalse). Spec 443 inc-25 (RM-282): the statement gains an OPTIONALdeclared_check_runsarray — one entry perengine_pass_tool_summaryrow 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 theevent_idit was read from — never the command text. Omitted entirely when every count is zero (thediff_fingerprint?precedent), so a feature with nothing counted produces a statement, digest, and signature byte-identical to before this increment;vstays1. 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 newestpr_checks_observedledger event (the spec-402 push poll) that carries a stringhead_sha— a signed, event-id-bearing, ZERO-EGRESS fact Weftra already records, never a live provider read (no VCS/provider import inevidence-pack.ts;buildEvidencePackStatementstays synchronous). Says whether the last observation Weftra WROTE DOWN names a commit other thanstatement.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 thebranchBLOCK 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'sverdicts.security[*].staledoes change the statement bytes — and therefore the digest and signature — of any feature carrying asecurity_verdictrow.vstays1either way (both additions are additive). (2)stalenow exists on every verdict class, via one shared helper (resolveStaleAgainstStatementHead):verdicts.security[*].stalefollows the exact same rulecode_reviewhas since inc-10, and a new OPTIONALverdicts.repro[](one entry perrepro_review_verdictrow,{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'sconformance_verdictledger event now carries the SAME top-levelhead_sha/head_boundpair its siblingreviewer_verdictalready reported (one resolution per round, no new fetch) — distinct from, and coexisting with, the evidence-CLASS block's OWN always-unboundevidence.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 thecurlcommands 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. - Provenance Check —
Shipped— 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 whoseoutput.summarycarries the pack's full human page andoutput.texta 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 thestatement.json/GET /api/verifypath instead). Conclusion issuccessorneutral— NEVERfailure(I5):successrequires ALL THREE — the pack's ownhead_shamatches the checked head, every verdict recorded against that exact head is a pass, AND (spec 443 inc-5, RM-262 k) Weftra holds anestablishedhost-anchored verification anchor for that exact commit (src/services/verification-anchor.ts'sresolveVerificationAnchorState) — 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 tosuccessexactly like a security PASS; a non-APPROVE at the checked head yieldsneutral/verdict_not_passinstead 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 areason(verified|pack_head_mismatch|verdict_not_pass|verification_anchor_cleared|verification_anchor_absent), fixed precedence, first match wins — visible in the check's owntitle, 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 theprovenance_check_postedrun event alongsideverification_anchor. This closes the QMD pilot regression where a commit whose anchor was CLEARED (evidence artifact excluded from the commit) still readsuccess/"verified" — a false safety claim. Dedup is now the quadruple(head_sha, conclusion, content_fingerprint, reason); a legacy event with noreasonre-posts once rather than matching silently. Thecontent_fingerprintcomponent 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 aspolicy(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 astatement=…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/verifyreturnscurrent_last_seq.npx weftra verify <pr_url|statement.json>(src/tools/verify.ts, host-side, full local DB access likediagnose.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 ownhead_shamust equal the head the PR is on now, every check carrying the name is considered rather than the first, and it reports anattestationblock (attested head, PR head, ledger positions, and a re-derive of the current pack) so avalid: truecannot 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 ownkey_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.PostCheckResultnow carriesvia(check_run|commit_status|build_status|none) and, on failure, a closedPostCheckFailureReason(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: thecredential_lacks_check_writedenial is remembered per credential (vcs_check_write_capability, re-probed on an interval or immediately on rotation) sochecks.createis 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's422(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.classifyPostCheckFailurereads 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 asuccessconclusion: GitHub has no truthful non-affirming status state, so aneutralconclusion 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,postProvenanceChecknow also posts the signed envelope as a single PR comment whenevervia !== '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. Theprovenance_check_postedledger event gainsvia/failure_reason/envelope_comment_posted; the dedup key grows to five fields (addingvia) so a credential that later gains check-write is retried instead of suppressed forever. Both provenance routes (GET /api/features/:id/provenanceand the admin equivalent) serve aprovenance_checkblock built entirely from closed enums/SHAs/booleans/timestamps — tenant-safe by construction, since aPostCheckFailureReasoncannot 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-signedfa:provenance-envelopemarker, 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/revisewith 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):detailsUrlnow opens the dashboard's own Evidence section for the feature (/?feature=<id>§ion=evidence) instead of the authenticated?format=markdownAPI 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. - Base Refresh Overlap Is Not A Park —
Shipped— 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 declaredgenerated_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 asgenerated_paths_excludedon the existingbase_identity_checkrun-event, naming exactly which declared paths were actually present in either raw set. Second, an implement run under the defaultrevise_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/revisewithrevise_base_strategy: 'merge', which gets spec 385's regeneration and spec 255's per-hunk conflict chooser). Recorded asbase_advanced_overlap { authorized_base_sha, remote_base_sha, overlap_paths, main_changed_paths_count }— nobase_refresh_escalationfailure event, no status transition, no retry consumed.refreshStaleBase()itself is unchanged in what it returns forbranch(stilloutcome: '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:/reviseunderbranchwas 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.tsnow applies the identical two-arm reinterpretationimplement.tsalready had —escalatewithstrategy === 'branch'proceeds (test gate, commit, push, PR updated,base_advanced_overlaprecorded, no workspace wipe);escalatefor any other reason (amerge-strategy cap exhaustion or a real conflict) still escalates-and-parks exactly as before, reverting toimplementedwith the branch/PR untouched and nothing pushed. The same increment also closed a revise-side evidence gap: a revise run'sbase_identity_checknow carriesgenerated_paths_excludedtoo, matching implement's since spec 412 inc-1.mergestrategy'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. - Worker Base Branch Provisioning —
Shipped— 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: whengit clone -b <baseBranch>fails (the compiledworker/<lineage>-vNbranch 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 atclone … 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), viagit fetch, falling through to the repository's ownHEADwhen no prior version exists or its fetch fails) and recordsbase_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 asworker_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 namedrun_failure { cause: 'base_branch_unavailable', branch, repo }instead of the old opaque code-128. Every git operation isrunSandboxGiton 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. - Upstream Clean Candidate Branches —
Shipped— 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-declaredupstream_excludeglob 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 declaresupstream_excludecovering 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-branchreads 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). - Repository Enrolment Survey —
Shipped— The enrolment survey (RM-287 parts 2-3, spec 443 inc-38 + inc-39): an admin-only,POST /api/projects/proposeclones a repository nobody has enrolled yet — shallow, into a throwaway workspace — and reports what Weftra can actually see in it, before anyone types aPOST /api/projectsbody. 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, plusO_NOFOLLOWon the open itself) AND a per-segmentlstatwalk 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 mostmax_bytes_per_fileof any one file is ever materialized, so an attacker-shipped multi-gigabyteREADME.mdis not a memory lever on the Weftra host); the clone itself is bounded byFA_PROPOSE_CLONE_DEADLINE_MSand not by any disk quota, which the docs state rather than imply. Every dropped/refused path is named inomitted[]with its reason, never silently shortened. Proposes two fields with no model —default_branch/head_sha(from the clone's own HEAD) andruntime_image(the samedetectRuntimeImageauto-detector the agent runtime uses — anullvalue cites the three cases that detector cannot tell apart, rather than claiming nothing matched) — each carryingaccepted_by_create_routecomputed by feeding the value toPOST /api/projects's own validators. Since inc-39, the survey ALSO runs one bounded, analysis-mode model call (--tools '', nocwd,--max-turns 1,FA_PROPOSE_MODEL_PASS/FA_PROPOSE_MODEL_DEADLINE_MS) over the evidence it already collected and proposessetup_command,test_command,verify_command,runtime_egress_allowlistandspec_path— each proposal citing an evidence{path, line, excerpt}that Weftra independently re-verifies (path inevidence[], line within the INCLUDED bytes, excerpt a byte-exact substring) before it is ever trusted; a citation that does not verify demotes the field tonot_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 advisoryautomated_prs_policyblock (with up to 10 cited notes) is reported alongside, gating nothing.proposal_provenancestates, 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=offskips the pass entirely and says so (model_pass.status: "skipped"); every other failure mode (engine error, timeout, unparseable reply, nonce mismatch) ends the surveystatus: "done"with the deterministic proposal untouched andmodel_pass.status: "failed"carrying a bounded, redacted reason — the model pass can never fail the survey itself. Executes nothing FROM the repository — nosetup_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 (nocreateProjectcall, no schema change) — the report is a JSON file underworkspaces/logs/, exactly like the env-check report. Since inc-40 (RM-287 pt.4), every report reachingstatus: "done"also carries areadinessblock: 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'sseverity(blocker/warning/advisory) andremedyfrom 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)detailfor context — and a finding whose citation does not verify is discarded, never shown uncited;citation_verified: trueclaims only that the cited line really is inevidence[]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 notest_command/runtime_egress_allowlist; theautomated_prs_policyreading). On the three contribution classesderivation: "deterministic"names who owns the RUBRIC — class, severity and remedy are Weftra's tables — while the policy reading itself is the model's: aforbidden/restrictedreading becomes a finding only when a note's citation verified, an uncited one is discarded and named inunevaluated[], and thecontribution_policy_absentadvisory is computed from the evidence (no CONTRIBUTING/CLAUDE.md/AGENTS.md collected) so no reply — including apermittedone — can suppress it.readiness.summaryis one ofready/ready_with_findings/incomplete/not_ready, computed in that priority order and never inferred from the model: any blocker forcesnot_ready; withFA_PROPOSE_MODEL_PASS=off(or any failed pass) every model-derived class lands inreadiness.unevaluated[]with the reason, so the summary isincompleteby construction and never a falseready. 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 withpnpm-workspace.yaml,.yarnrc.ymland a head-only-cappedpnpm-lock.yaml(<=1024 bytes, applied at the read), and added a deterministicpackage_managerfact ({manager, marker: {path, line}, declared} or null) computed from that evidence alone, with no execution: apackageManagerfield in a non-truncatedpackage.jsonfirst, elsepnpm-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) andbuild_state_committed(warning; fires whenever a bounded, read-nothing directory walk for the closed.tsbuildinfosuffix — depth <= 8, at most 20,000 entries examined, at most 20 paths retained — finds at least one match; ascan: "truncated"result with zero matches produces neither a finding nor anunevaluated[]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 samePATHexport, since each runs in its own shell) and a fixed lockfileVersion-to-major-version table, with table-derived proposals capped atmediumconfidence. The New Project modal renders both new classes through the existing generic finding path, with one addition: underbuild_state_committedit lists the matched paths (capped at 20, "… and N more" beyond that) and whether the scan was bounded — DOM APIs only, noinnerHTML. Lifecycle: a second survey for the same repository while one is in flight refuses 409; more thanFA_PROPOSE_MAX_CONCURRENTin flight refuses 429; a report leftrunningwhose survey_id didn't survive a process restart readsabortedon the next GET, never a stale success. A clone that outrunsFA_PROPOSE_CLONE_DEADLINE_MSis cancelled and killed (markCancelled+killJobon 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_idis arandomUUIDand 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 aSurvey repositorytrigger (create mode only — Re-detect on an already-enrolled project is inc-42), a bounded poll of the report with aCheck againfallback 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.jsis 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_routeis deliberately NOT that discriminator, since it isnullboth forspec_path(no such project field exists) and forsetup_command/test_command/verify_command(accepted as plain string columns with no dedicated validator) — readingnullas "do not pre-fill" would drop three fields the create route actually accepts.spec_pathis always informational, with the reason thatPOST /api/projectshas 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 withcreateElement/textContentonly — noinnerHTML, 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 mirroringproject.runtime_egress) andRuntime 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 dropbinarieson 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 whenSurvey repositoryis CLICKED, not when the survey returns, so thePOSTround 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 — the202included — 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-textdetailis 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", sincePOST /api/projects/:id/env/checkneeds a project id and so cannot run any earlier than that. This surface adds no endpoint and nosrc/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 asrunningcarries an optionalphase(cloning,reading_evidence,proposing) written at the three points the job log already names;phaseis absent from every terminal report and neverproposingwhenFA_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. - Worker Marketplace Template Export Install —
Shipped— 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-templatewalks the chain, replaces every internal id with a symbolic ref (outcome/roadmap_item/use_case/story_N/agent_solution), and returns the result with acontent_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/installwrites 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 artifactauthored_via: 'template'andstatus: 'draft'— neverready, so the installedagent_solutioncannot 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 arerequireAdminOrApproverForProject— a project key, including the worker's own, is refused 403 before either route reads or writes anything. Install creates noworker_deploymentsrow, enrolls no project, and mints nofa_key — installing grants nothing and runs nothing. - Worker Marketplace Catalogue Store —
Shipped— 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/entriespublishes 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 noagent_solutionis refused (a catalogue entry is a worker, and its display title has nothing else to come from). Validated with the SAME structural checksPOST .../templates/installuses (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/entriesandGET /api/catalogue/entries/:hashlet 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 inunverified_countonly, never named) or refused (409) on direct read. A project key never receivespublisher_principalin either response, onlypublisher_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. Declaredcapabilitiesandegress_hostsare 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-catalogueinstalls a published entry by hash into a project — samerequireAdminOrApproverForProjectposture 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 artifactstatus: 'draft', noworker_deploymentsrow, no worker project, nofa_key. - Worker Marketplace Install Time Bound And Provenance —
Shipped— 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. IncompileAgentSolution, before any write, a chain whoseagent_solutionartifact carries a non-nulltemplate_install_id(stamped only by a marketplace install) has its declaredauthority.egress_hostschecked against the INSTALLING project's own resolved runtime-egress policy (resolveRuntimeEgressPolicy) — modeallowlist: every declared host must already be in it, or the compile is refused (422) naming every offending host and writing nothing (no worker project, nofa_key, no deployment row, no egress config); modeopen(the default): nothing to bound against, so this permits the compile exactly as before. A chain the project authored itself (template_install_idnull) is completely unaffected. The refusal writes oneagent_solution.compile_refusedactor 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 landsdraft.authority.capabilitiesis 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_deploymentsgained two nullable columns,template_hashandtemplate_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) returninstalled_from: {template_hash, template_install_id} | nullwith 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. - Worker Marketplace Catalogue Evidence —
Shipped— 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, composescomputeDeploymentEvaluation(never re-implementing any counting) over everyworker_deploymentsrow (live or retired) carrying one template's content hash, plus a count ofworker_template_installsrows for that hash, over the ENTIRE lifetime of the instance.GET /api/catalogue/entriesandGET /api/catalogue/entries/:hashattach the result asevidence— 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 noreq.project, the exact test that already withholdspublisher_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/entriesgains?sort=, exactlypublished_at(default, unchanged order) oreffect(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 orsort=effectfrom a project key is refused (400). A row failing its tamper-evidence check is still omitted and counted inunverified_countonly, 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. - Product Definition Plane Import Door —
Shipped— 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 intobody.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 inbody_json, validated and stripped once invalidateArtifactBodyand 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_refresolves 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_viais always'import', never a caller-supplied value.roadmap_itemalone gains two optional OPAQUE fields,labels/references(string arrays, bounded, redacted) — the same byte-for-byte, interpret-nothing disciplineserialization_lanealready documents: no trim, no case fold, no split, no URL validation, no parsing anywhere in this door (§VII load-bearing). Exactly oneproduct_artifact.importrow 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 OUTSIDEsrc/by construction:npm run roadmap:export(scripts/roadmap-to-definition-tree.ts) parses Weftra's roadmap markdown into oneroadmap_itemenvelope per[RM-nnn]token —external_refverbatim, 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--postmode, 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 itsproduct_artifact.importactor 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). - Roadmap Inverse Renderer —
Shipped— The INVERSE ofnpm 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 fordocs/design/fa-roadmap.md— whether the store can actually reproduce the document. Default mode reads envelopes (stdin, or--in <dir>readingroadmap-import-*.jsonin sorted filename order) and renders each artifact'sbody.summaryto stdout, in envelope order.--checkruns 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.unclassifiedis the one class that must never appear — a single one fails--check(exit 3) and the test suite;--checkexiting 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 thatunclassifiedstays zero and the entry count stays at or above a recorded floor, both stable under routine edits. Lives entirely inscripts/, neversrc/— 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 inpackage.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. - Roadmap Document Partition —
Shipped— 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) walksdocs/design/fa-roadmap.mdonce and assigns every byte to exactly one of FRAME or ENTRY, reusing the same boundary ruleparseRoadmapalready 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 blockbuildEnvelopesactually 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:--checkreports to stderr (or stdout as--json) and exits 0 iff identical else 3; bare--documentprints the rebuilt document to stdout only, for an operator to diff — there is no write mode. Because--documentderives its envelopes from the--filedocument 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 overMAX_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) andunrebuildable(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_ENTRIESare 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 --documentexits 3 today by design — that is the measurement this increment exists to make provable, not a defect. Lives entirely inscripts/, neversrc/(constitution §VII) — this diff touches nosrc/file at all — and emits bytes only: no socket, no database handle, no subprocess, no credential, and nothing inpackage.json, CI or a hook runs--documentautomatically. Never opens the database and never posts to the import door. - Roadmap Guarded In Place Write —
Shipped— The GUARDED IN-PLACE WRITE (spec 449 inc-2c, RM-310 job 2's third half): applies the store's own entries back intodocs/design/fa-roadmap.mdin place, or writes nothing at all.scripts/roadmap-from-definition-tree.tsgains--store <dir>, a NEW flag distinct from--in(§32's entry-only render path), reading a directory ofroadmap-import-*.jsonenvelopes and using them, rather than envelopes derived from the document itself, as each entry span's replacement block (stillbody.summaryverbatim, inc-2a's render rule).--document --store <dir>prints the store-sourced rebuild for an operator to diff; adding--write(or its by-hand aliasnpm 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'scomputeDocumentReportself round-trip, or anoversize/unrebuildableentry) 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 neitherMAX_STRING_CHARS/MAX_TITLE_CHARS/MAX_ARRAY_ENTRIESis widened nor is a word of the document's own prose rewritten to make it pass. Waiving isscripts/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:applyspells--document --writeas literal argv inpackage.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 (noartifactsarray, akindother thanroadmap_item, anexternal_refthat is not^RM-\d+$, or a non-stringbody.summaryis 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 inscripts/, neversrc/(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. - Worker Marketplace Catalogue Forks —
Shipped— The worker marketplace, increment 4 (spec 445 inc-4, RM-175): a catalogue entry is IMMUTABLE but FORKABLE.catalogue_entries.forked_fromhas 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/entriesaccepts an optionalforked_from(64-character lowercase hex) — checked BEFORE the cap check, the credential scan, and signing: absent stays byte-identical to before this increment (including the201body, which carries noforked_fromkey 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 withcatalogue.entry_verification_failedfor the PARENT. Lineage is write-once: re-publishing an existing hash's bytes with the SAME declared lineage is the existing idempotent200; the SAME bytes with a DIFFERENT declared lineage is a new refusal (409,catalogue.entry_publish_refusedreasonlineage_is_immutable) — rewriting lineage is exactly the in-place edit immutability forbids, so it is refused even though the bytes match.forked_fromis 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) gainfork_counton 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 gainsforks(direct children, newest first, bounded byMAX_FORKS_LISTED, a failing child omitted-but-counted-and-recorded) andfork_ancestry(the parent chain root-ward, bounded byMAX_FORK_ANCESTRY_DEPTH, stopped on a repeated hash, withfork_ancestry_truncated).GET /api/catalogue/entriesgains?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. - Product Definition Plane Roadmap Candidates —
Shipped— Product Definition Plane, RM-311 (spec 451): aroadmap_candidateartifact 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/declinedare 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 — includingdeclined, 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_FEATURESdistinct features (default 3) proposes it, up toFA_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; aFA_ROADMAP_CANDIDATE_MAX_PER_PASSof 0 recordsproposer_offrather 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 everyevidence_refsentry (never truncated; aurlkind 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 callsGET /api/users/meonce 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_candidatestill does not appear in the canvas's definition-tree kind lists (itsparent_lineage_idis always null).
floop + Governance SDK
The self-build loop generalized into declarative, project-agnostic loop config, and the SDK that lets any external agent be governed by FA, not just FA-initiated runs.
████████░░ 83% (10 done / 12 on the path) — mostly done
- Integrations Mcp And Webhooks —
Partial— 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. Outstanding: 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_keyandlist_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 viaPOST /api/projects/:id/regenerate-key(spec 199 inc-2b2a), theissue_project_keyMCP 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_featureprogress notifications per phase change when the client sends a progress token, and afailureblock (stage, cause, redacted evidence) on a failed feature. Spec 467 inc-3 (RM-339 f) shipped:submit_feature,get_feature_status,await_featureandget_feature_progressall returndashboard_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 ownFA_BASE_URL), and the dashboard opens one feature from a/?feature=<id>deep link. Spec 467 inc-4 (RM-339 g) shippedproduct_propose_ready, a 1:1 MCP door onto the existing project-key routePOST .../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_configandcreate_pr. - Finding Continuity Study Harness —
Shipped— 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.exportreads 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.scoreputs 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.envthat 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. - Recurrence Classification —
Shipped— 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.htmlmoved onto the shared, DOM-freepublic/project-selection.jspicker (chooseProject) — it honours a listed?project=and remembers the last choice (localStorage['fa_recurrence_project']), instead of always takingGET /api/projects' newest-firstlist[0]. Deliberately carries no per-project count: recurrence classes are computed ON DEMAND byPOST .../recurrence/classifyagainst 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 withcreateElement/.value/.textContentinstead of aninnerHTMLtemplate. - Per Project Autonomous Loop —
Partial— 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. Underbacklog_source: "definition_store"(RM-147 inc-1) Weftra itself — never the model — deterministically selects the next unblockedreadystory 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. Underdefinition_store(RM-147 inc-2) selection is now a POLICY over fields the store already declares: a story's roadmap-item ancestor's declaredorderis honoured (ascending, declared-before-undeclared, otherwise inc-1's creation order), an ancestor's declareddepends_onblocks selection until every story serving each dependency reachesdelivered(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'sselection_policyblock, plus newmaintainer_draft_outcome,maintainer_story_passed_over, andmaintainer_no_selectable_storyevents), 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, nobacklog_pathconfigured underrepo_document) halts on its first occurrence, and a TRANSIENT one (an unreadablebacklog_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 underdefinition_storeis a genuine idle, never a fault, exactly like an emptyrepo_documentbacklog. Underdefinition_store(RM-147 inc-3) aroadmap_itemmay additionally declare an opaqueserialization_lanestring (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 reachesmerged/wont_merge/cancelled— a transient mutual exclusion, distinct fromdepends_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. Underrepo_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. Therepo_documenthalf now also closes the asymmetry withnext-pick: the maintainer's OPTIONAL, boundedselection_rationale(chosen_reason + passed_over[]) is sanitized and recorded on its ownmaintainer_selection_rationaleevent (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 asloop_status.last_selection, read by bothGET /api/projects/:idandGET /api/project. (RM-310 inc-3a) The three fields this policy reads off aroadmap_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/referenceson aroadmap_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) Aroadmap_itemmay additionally declare an opaqueclusterstring — a fourth operator-set selection input, subject to the same write-side lock and carry-forward asorder/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/activationwith{cluster, active}(404 activating a cluster nothing declares; deactivating is always allowed; idempotent re-setting writes neither a row nor an actor event), andGET /api/projects/:id/product/clusterslists 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-pickcarriesactive_clustersandselected_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 governingroadmap_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 omitsuse_case_lineage_idcarries the current link forward instead of silently dropping it (recorded asbinding_carried_forward); sending it explicitly, includingnull, is judged as a move. A project-key CREATE of astory/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 existingproduct_artifact .selection_input_refusedledger action and its one-row-per-project-per-hour bound; an admin user or the instance admin key is unaffected by either rule. Outstanding: 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-keyloop: nullnow 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-leasepush, 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/resumeis 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 unblockedreadystory (excluding one that already has a non-terminal linked feature, so apo_approvalproject's still-awaiting_approvalsubmission 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 carriesstory_lineage_id, so the EXISTING story->feature delivery hook advances that storyready -> in_deliverywhen the feature reachesqueued— inside the cycle on afull_autoproject, at approval time onpo_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 stayspartial: inc-4 (dogfood — retire the bash Governor) is still outstanding. Spec 460 inc-1a (RM-324) gives aparked_humanitem (ANY merge_policy, not only auto_low_risk) the same merge-readiness machinery auto_low_risk already has: a NEWloop_merge_readinesstable binds readiness to one exact head SHA (reviewer, security and build all passing there), recorded/cleared vialoop_merge_ready/loop_merge_ready_clearedrun events and surfaced asloop_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 — samerequireNamedAdmin/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 IDENTICALrunLoopAutoMergesequence 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 stalledrevisingitem, 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 asapprove_loop_merge, never the genericmerge, so the board cannot invite a provider-UI merge that skips Weftra's own merge-result verification; a merge-time verification failure shows asloop_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 asloop_status.infra_resume(identical on the admin and tenant payloads — it carries no principal) plus a channel notification on each resume and on exhaustion. - Learning Plane Repository Knowledge —
Shipped— 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. - Learning Plane Hybrid Semantic Retrieval —
Shipped— 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. - Learning Plane Governed Skills —
Shipped— Learning Plane — governed reusable skills + effectiveness (spec 359, RM-130, promoted from draft 311): a per-project, versionedskillsstore (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. - Learning Plane Pm Readable Candidate Approval —
Shipped— PM-readable candidate approval surface (spec 365, RM-133): the human half of the Learning Plane's candidate -> approval trust model. Addspublic/knowledge.html, a knowledge candidate-approval UI mirroringpublic/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 BOTHpublic/knowledge.htmlandpublic/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-identicalsource_refsare only the lookup key —source_refsis caller-supplied on the tenant-facing submit routes, so pairing also requires Weftra provenance on both rows (knowledgereviewer_finding/ skilllearned, each withcreated_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 withcreateElement/textContent, never an HTML template string, so no repository-supplied text on that path is ever interpolated into HTML.public/skills.htmlis 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-onlyGET /api/learning/candidate-counts(src/routes/learning-candidates.ts, mounted at a fresh top-level/api/learningsegment) 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 alongsideGET /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 onknowledge.html, skills onskills.html) when any project has one or more — ties broken by the other class's count, then by project name compared by CODE POINT (neverlocaleCompare, 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 bothlocalStorageand the URL (history.replaceState), so the view is linkable and survives a reload. The dropdown itself is rebuilt with DOM APIs (createElement('option')/textContent, noinnerHTMLtemplate string), grouped into "Has open candidates" / "No open candidates" optgroups when counts are available. Both pages'loadProjectsnon-OK branch, andskills.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/analysisandGET /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 ownr<N>-f<M>finding-id format, never guessed) — the signal that a cluster citingr6-f2/r7-f5/r9-f4inside 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 ownnormalizeClaimTokens/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'slocateQuotedBytes— 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_statusispresent/not_returned/not_readwith 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 — sonot_returnedclaims 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 missingrepo_url/credential, an unresolvable branch, a provider error or an exhausted outbound budget yieldsresolution.attempted: falsewith every citationnot_read, HTTP 200. A fixednot_claimedarray on every response (and restated in the UI) says what this is NOT: a verdict on the candidate, a claim thatline_hint_in_rangeis identity (quote_statusis), 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 anot_returnedfile was deleted. Bounded by three Weftra-owned constants per call — 50 cited refs, 20 distinct repository paths, 300 cohesion pairs, withtruncated_reasonsnaming 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 norepo_urlis charged nothing, since its analysis makes no provider call. Bothpublic/knowledge.htmlandpublic/skills.htmlrender the result beside (never in place of) the existing verbatim evidence, under "Computed facts (deterministic — no model)", built entirely with DOM APIs (createElement/textContent) — noinnerHTMLtemplate string. Spec 443 inc-66 (RM-305 part 2): layer 2 — a "Read it for me (model)" control beside the computed-facts panel that callsPOST /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 400unknown_fieldsbefore 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 withretry_after_s, an overrun answers 504 — and cached in-process, keyed on the candidate'scontent_hash(at most 500 entries; a cache hit spends nothing). Every dispatched call, success or unparseable, appends onecandidate_analysis.readingactor event carrying ids/content_hash/model/cost/tokens/outcome only — never the finding text, an excerpt, or the reading itself. - Governed Delegation Provenance —
Shipped— 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 ADDITIVEhelper_delegationrun 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 asunknown. 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. - Learning Plane Auto Fire Knowledge Candidates —
Shipped— 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'sclusterFindingsByClass, 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 addedrecurrence_countand?sort=recurrenceto 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. - Learning Plane Auto Fire Skill Candidates —
Shipped— 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'sclusterFindingsByClass, threshold 0.5), so this source'ssourceRefsfor a cluster are byte-identical to the knowledge candidate's for the same evidence — the keypublic/knowledge.htmllooks 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 addedrecurrence_countand?sort=recurrenceto 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. - Learning Plane Retrospective Evidence And Grades —
Shipped— 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_runevents gain adropped[]list ({id, reason: 'bytes'|'count', fit}) for every item that matched but was capped out, and every included item gains its ownfit(path_fit|broad|misfit), computed at retrieval time from the same applicability/changed-paths context the retriever already reads. A closed, optionaltargetsfield ({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,0disables;FA_LEARNING_RETRO_MAX_FEATURESper 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) intofit/truncation/effect(applied_but_failed|flipped|no_signal) per item plus a per-windowrecurrencetable (finding classes on 2+ distinct features) — writing onelearning_retrospectivesrow per pass (model: 'off'), alearning.retrospectiveactor event, and a per-featurelearning.run_gradedrun 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, nohuman_actionfree 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=onto 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', actorfa:candidate-generation) from its own per-classrecurrenceview. 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?) orlearning.candidates_skippedwith areasonofminer_off(per-file miner only),no_recurrence,already_covered, orgeneration_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 staysstatus: '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 declaredtargets.finding_classesvs. a run's actual security findings, for theeffectgrade) no longer re-implements the class-match ratio — it now delegates toisSameClass(src/services/finding-class-clustering.ts), the SAME predicate the candidate miners use, so the two decisions cannot drift.
Gaps — what the north star calls for, not yet built
- Enterprise Multi Tenancy (The four SaaS-primitive seams) — Tenant isolation and per-tenant credential configuration seams toward full RBAC/orgs/ SSO/billing. The multi-tenant trust/credential model is implemented; RBAC/orgs/SSO/ billing as a complete enterprise product are north-star, not shipped. Spec 215 adds a project-scoped VCS credential (admin-only read/write,
tenant_credentialspurpose'vcs') on the same table and route file as the tenant-wide model credential above — not a second credential model. —specs/011-multi-tenant-trust-credential-model/increment-1b.md,specs/011-multi-tenant-trust-credential-model/increment-2a-credential-resolution-core.md,specs/215-project-level-vcs-credentials.md - Saas Control Plane (One core → N deployment modes) — The zero-infra SaaS deployment mode: tenant provisioning, routing, metering, and the orchestrated (k8s/Fargate) Runtime backend that turns the self-hosted-per-org core into a multi-tenant SaaS front door. Design-only — explicitly sequenced AFTER the self-hosted core earns adoption (target-architecture.md §2, §9); no control-plane code exists yet. — no spec on file yet
- Governed Deploy Bounded Execution (The Builder → governed deploy) — The bounded-execution increment of the governed-app-deployment rebuild (spec 106 inc-2): running a project's own declared deploy recipe to completion under Weftra's permission gate. Blocked on the spec-011 credential broker (a tenant's cloud creds, safely isolated) per specs/106-governed-app-deployment-rebuild/spec.md — see CLAUDE.md's Trust Boundary incident note (2026-07-17, PR #173). —
specs/106-governed-app-deployment-rebuild/spec.md,specs/106-governed-app-deployment-rebuild/increment-2-bounded-execution.md - Admin Action Audit (Everything as governed code (terminal thesis)) — A tamper-evident, hash-chained record of privileged ADMINISTRATOR actions (the
actor_eventsledger, spec 261) — closing the gap CLAUDE.md's "Administration Is Legitimate" core law names: run_events is feature-keyed, with nowhere to put a non-feature admin action. Already covers identity management (user create/update/delete/role-change/key-regeneration, approver-project links) and a growing set of governance writes (runtime-image allowlist, runtime settings, separation policy, runtime egress). Spec 292 (RM-063) is extending it across every remaining privileged admin surface, increment by increment — this entry tracks it as a PLANNED gap until the whole spec's scope lands: increment 1 (shipped) covers the admin PROJECT routes —POST /api/projects,PATCH /api/projects/:id,DELETE /api/projects/:ideach now record one event on success, carrying field NAMES only, never values. Spec 443 inc-52 (RM-287 addendum 3 pt.3) closed one of the two other admin-reachable project-enrollment paths:POST /api/builder/new-projectnow calls the SAME create pipeline (createProjectPipeline,src/routes/projects.ts)POST /api/projectsdoes, so it records the identicalproject.createevent too. Still missing: the agent-factory compile path, which callscreateProjectdirectly under its own named-admin bar and recordsworker_deployments.compiled_byinstead — a deliberately separate ledger for a deliberately separate door, not scheduled to fold into this one; project-scoped VCS-credential configuration (inc-2); instance administration —POST /api/admin/rotate-admin-keyand the database-backup routes still record zero events today (inc-3) — and connector/integration administration (inc-4). —specs/261-audit-administrator-actions.md,specs/292-audit-all-privileged-administrative-mutations.md,specs/292-audit-all-privileged-administrative-mutations-inc-1.md,specs/443-model-endpoint-egress-and-honest-spec-pause/increment-52-one-enrolment-create-semantics.md,specs/528-audit-sub-menu/spec.md
Generated from docs/features.yml (content hash e8765d63b325). 14 further manifest entries are internal- or contributor-facing and not listed on this page.