Skip to content

feat(identity): pluggable user identity foundation — actor model, deny-by-default authorization, plugin gate inversion - #3428

Open
gsxdsm wants to merge 43 commits into
mainfrom
feature/user-accounts
Open

feat(identity): pluggable user identity foundation — actor model, deny-by-default authorization, plugin gate inversion#3428
gsxdsm wants to merge 43 commits into
mainfrom
feature/user-accounts

Conversation

@gsxdsm

@gsxdsm gsxdsm commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Foundation slice of the pluggable user identity plan (docs/plans/2026-08-07-001-feat-pluggable-user-identity-plan.md) — U1–U5, U18, U22.

Two real vulnerabilities closed

Any installed plugin could grant itself admin. project.actor_role_grants is the only table that says who holds authority, and every plugin could write it. Each link already existed: a plugin receives the real TaskStore, the gate deliberately does not deny getAsyncLayer() (four in-repo plugins need it), that handle is the same pooled fusion_runtime connection core uses, and migration 0006's ALTER DEFAULT PRIVILEGES granted full DML on the whole project schema. RLS did not close it — fusion_project_isolation filters on project_id, never on caller — so a plugin writing a grant for its own project passed the policy cleanly, and every can() check downstream would then honestly return allow. 0059 revokes write from fusion_runtime and keeps SELECT (authorization reads grants on that connection every mutation).

A no-token launch served the entire API unauthenticated. Auth mounted only if (daemonToken), so whether /api/* was protected depended on how the process happened to start. The desktop host passed no token and served everything — including the shell-capable terminal WebSocket — open on every interface. Absence of a token now refuses /api/* with 503; serving unauthenticated is an explicit --no-auth opt-in.

Plugin gate inverted to deny-by-default

Was a denylist: 8 methods blocked, everything else on a 299-method TaskStore passing through. Now reads pass by verb prefix (115 methods, cannot corrupt state), writes are denied unless allowlisted (~180 that used to pass now throw), and destructive writes still require permissions.destructiveTaskOps — which no longer returns the raw ungated store, so the most privileged plugin class is no longer the one class with no deny-by-default at all.

Breaking for third-party plugins performing writes outside the allowlist. The allowed set is derived from what in-repo plugins actually call, and all 14 of their methods were verified to survive. The error names the method.

Verification

pnpm test:gate green. ~153 identity tests across 13 files, plus 422 settings tests and the wider core suites.

Mutation-verified rather than assumed — denial tests are the shape that passes for free when the code never runs:

  • authorizer: removing the unresolved-actor guard reds 3, failing open on resolution error reds 2, treating require-approval as allow reds 1
  • census: dropping a mapping reds 3, a non-catalog permission reds 3
  • the 0059 REVOKE: without it can_insert: true and the escalation test fails at the right assertion

Every denial test has a positive twin with one bit flipped; where a denial must precede grant resolution, the resolveGrants spy returns allow and the test asserts it was never called. The privilege tests pin current_user = fusion_runtime first, because FORCE ROW LEVEL SECURITY applies to the owner too — a passing RLS assertion does not prove the connection switched roles.

Two bugs the tests caught in my own first cuts

Both fail far from their cause: gating data properties turned someCounter from 7 into a thrower function (corrupted data at the read site, not an error at the call site), and denying toString made String(store) throw an authorization error from code that never called a store method.

Known limits, stated rather than implied

  • getAsyncLayer() remains reachable and still hands out a raw drizzle handle that runs SQL past every rule in the gate. The REVOKE closes the grant-yourself-a-role path specifically; it does not make that handle safe. A scoped data layer is the real fix and remains a follow-up.
  • The permission map covers the gate-classified surface, not all ~184 mutating methods. A deliberate first tranche; the census fails CI when a method joins either gate list unmapped.
  • assertCan is not yet applied at the store methods, and there is no custom ESLint rule — the census covers that ground for now. Identity ships disabled by default (identityEnabled: false), so can() short-circuits to allow and existing installs are unaffected until U16.

Pre-existing failures, not caused here

executor-worktree (1/91), triage-explicit-duplicate-marker (3/21), and builtin-workflow-settings-triage (1/9) reproduce identically on origin/main / with these edits reverted.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 425 files, which is 275 over the limit of 150.

To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ebb502b-1cd5-40f1-aa29-5110b48ccb84

📥 Commits

Reviewing files that changed from the base of the PR and between bcf353d and 13e5b74.

📒 Files selected for processing (425)
  • .changeset/harden-transport-auth.md
  • .changeset/pluggable-user-identity-foundation.md
  • .changeset/u18-stage-d-mutation-context-dashboard-cli.md
  • docs/plans/2026-08-07-001-feat-pluggable-user-identity-plan.md
  • docs/solutions/test-failures/suite-only-flakes-observed-register.md
  • packages/cli/src/__tests__/extension-mutation-context.test.ts
  • packages/cli/src/__tests__/mutation-context-matchers.ts
  • packages/cli/src/__tests__/task-command-github-import-tracking.test.ts
  • packages/cli/src/__tests__/task-command-gitlab-import.test.ts
  • packages/cli/src/commands/__tests__/task-lifecycle.test.ts
  • packages/cli/src/commands/__tests__/task-lock-retry.test.ts
  • packages/cli/src/commands/__tests__/task.test.ts
  • packages/cli/src/commands/daemon.ts
  • packages/cli/src/commands/dashboard.ts
  • packages/cli/src/commands/pr.ts
  • packages/cli/src/commands/task-lifecycle.ts
  • packages/cli/src/commands/task.ts
  • packages/cli/src/extension.ts
  • packages/cli/src/identity/cli-operator-mutation-context.ts
  • packages/core/src/__test-utils__/mutation-context-fixture.ts
  • packages/core/src/__test-utils__/pg-test-harness.ts
  • packages/core/src/__tests__/actor-context.test.ts
  • packages/core/src/__tests__/agent-prompts.test.ts
  • packages/core/src/__tests__/board-action-services.test.ts
  • packages/core/src/__tests__/duplicate-guard.test.ts
  • packages/core/src/__tests__/duplicate-intake.test.ts
  • packages/core/src/__tests__/identity-authorize.test.ts
  • packages/core/src/__tests__/identity-credentials.test.ts
  • packages/core/src/__tests__/identity-enabled-setting.test.ts
  • packages/core/src/__tests__/identity-permissions.test.ts
  • packages/core/src/__tests__/identity-schema.test.ts
  • packages/core/src/__tests__/identity-sessions.test.ts
  • packages/core/src/__tests__/mutation-authorization-census.test.ts
  • packages/core/src/__tests__/permission-denied-error.test.ts
  • packages/core/src/__tests__/plugin-task-store-gate.test.ts
  • packages/core/src/__tests__/postgres/schema-applier.test.ts
  • packages/core/src/__tests__/project-table-registry.test.ts
  • packages/core/src/__tests__/same-agent-duplicate-intake.test.ts
  • packages/core/src/__tests__/unattributed-actor-census.test.ts
  • packages/core/src/agents/agent-store.ts
  • packages/core/src/agents/task-execution-task-creation.ts
  • packages/core/src/async-stores/async-agent-store.ts
  • packages/core/src/async-stores/async-mission-store.ts
  • packages/core/src/board/board-action-services.ts
  • packages/core/src/config/settings-schema.ts
  • packages/core/src/duplicates/duplicate-guard.ts
  • packages/core/src/duplicates/duplicate-intake.ts
  • packages/core/src/identity/actor-store.ts
  • packages/core/src/identity/actor.ts
  • packages/core/src/identity/authorize.ts
  • packages/core/src/identity/credentials.ts
  • packages/core/src/identity/identity-enabled.ts
  • packages/core/src/identity/mutation-context.ts
  • packages/core/src/identity/permissions.ts
  • packages/core/src/identity/sessions.ts
  • packages/core/src/identity/store-mutation-permissions.ts
  • packages/core/src/identity/tokens.ts
  • packages/core/src/index.gate.ts
  • packages/core/src/index.ts
  • packages/core/src/missions/mission-store.ts
  • packages/core/src/plugin-task-store-gate.ts
  • packages/core/src/plugins/plugin-loader.ts
  • packages/core/src/postgres/data-layer.ts
  • packages/core/src/postgres/migrations/0067_fn_identity_actors.sql
  • packages/core/src/postgres/schema-applier.ts
  • packages/core/src/postgres/schema/central.ts
  • packages/core/src/postgres/schema/project.ts
  • packages/core/src/session-identity-registry.ts
  • packages/core/src/store.ts
  • packages/core/src/task-delete-attribution.ts
  • packages/core/src/task-store/archive-lifecycle-2.ts
  • packages/core/src/task-store/archive-lifecycle.ts
  • packages/core/src/task-store/branch-and-pr-entities.ts
  • packages/core/src/task-store/branch-group-ops.ts
  • packages/core/src/task-store/comments-ops.ts
  • packages/core/src/task-store/errors.ts
  • packages/core/src/task-store/lifecycle-ops.ts
  • packages/core/src/task-store/merge-queue-ops-2.ts
  • packages/core/src/task-store/moves.ts
  • packages/core/src/task-store/task-artifacts-ops.ts
  • packages/core/src/task-store/task-creation.ts
  • packages/core/src/task-store/task-store-helpers.ts
  • packages/core/src/task-store/workflow-ops.ts
  • packages/core/src/types/agents/agents.ts
  • packages/core/src/types/settings/settings-scope.ts
  • packages/core/src/types/task/task-log.ts
  • packages/dashboard/app/__tests__/auth.test.ts
  • packages/dashboard/app/auth.ts
  • packages/dashboard/app/components/DesktopLaunchGate.tsx
  • packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx
  • packages/dashboard/app/components/settings/sections/VoiceInputSection.tsx
  • packages/dashboard/app/types/native-shell.d.ts
  • packages/dashboard/app/utils/__tests__/appLifecycle.test.ts
  • packages/dashboard/app/utils/appLifecycle.ts
  • packages/dashboard/src/__tests__/api-error.test.ts
  • packages/dashboard/src/__tests__/github-issue-comment.test.ts
  • packages/dashboard/src/__tests__/github-source-issue-close.test.ts
  • packages/dashboard/src/__tests__/github-source-issue-reconciler.test.ts
  • packages/dashboard/src/__tests__/github-tracking-comments.test.ts
  • packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts
  • packages/dashboard/src/__tests__/github-tracking-state.test.ts
  • packages/dashboard/src/__tests__/github-tracking.test.ts
  • packages/dashboard/src/__tests__/gitlab-delete-close.test.ts
  • packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts
  • packages/dashboard/src/__tests__/gitlab-source-issue-close.test.ts
  • packages/dashboard/src/__tests__/gitlab-source-issue-reconciler.test.ts
  • packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts
  • packages/dashboard/src/__tests__/gitlab-tracking-state.test.ts
  • packages/dashboard/src/__tests__/mesh-routes.test.ts
  • packages/dashboard/src/__tests__/mutation-context-matchers.ts
  • packages/dashboard/src/__tests__/no-token-refusal.test.ts
  • packages/dashboard/src/__tests__/planning-e2e-plan-creation.test.ts
  • packages/dashboard/src/__tests__/pr-conflict-resolver.test.ts
  • packages/dashboard/src/__tests__/research-routes.test.ts
  • packages/dashboard/src/__tests__/routes-agent-prompt-sizes-integration.test.ts
  • packages/dashboard/src/__tests__/routes-auth.test.ts
  • packages/dashboard/src/__tests__/routes-automation.test.ts
  • packages/dashboard/src/__tests__/routes-github.test.ts
  • packages/dashboard/src/__tests__/routes-planning.test.ts
  • packages/dashboard/src/__tests__/routes-system.test.ts
  • packages/dashboard/src/__tests__/routes-task-delete-nonblocking.test.ts
  • packages/dashboard/src/__tests__/routes-task-retry-stale-merge-status.test.ts
  • packages/dashboard/src/__tests__/routes-tasks-deterministic-dedup.test.ts
  • packages/dashboard/src/__tests__/routes-tasks.test.ts
  • packages/dashboard/src/__tests__/server-view-preload.test.ts
  • packages/dashboard/src/__tests__/task-effective-settings-route.test.ts
  • packages/dashboard/src/__tests__/task-revert-route.test.ts
  • packages/dashboard/src/api-error.ts
  • packages/dashboard/src/auth-middleware.ts
  • packages/dashboard/src/cli-session-ws.ts
  • packages/dashboard/src/github-issue-comment.ts
  • packages/dashboard/src/github-source-issue-close.ts
  • packages/dashboard/src/github-tracking-comments.ts
  • packages/dashboard/src/github-tracking-reconciler.ts
  • packages/dashboard/src/github-tracking-state.ts
  • packages/dashboard/src/github-tracking.ts
  • packages/dashboard/src/gitlab-lifecycle.ts
  • packages/dashboard/src/gitlab-source-issue-reconciler.ts
  • packages/dashboard/src/monitor-trait.ts
  • packages/dashboard/src/planning.ts
  • packages/dashboard/src/pr-conflict-resolver.ts
  • packages/dashboard/src/research-routes.ts
  • packages/dashboard/src/routes.ts
  • packages/dashboard/src/routes/__tests__/task-proposal-routes.test.ts
  • packages/dashboard/src/routes/automation-step-execution.ts
  • packages/dashboard/src/routes/register-git-github.ts
  • packages/dashboard/src/routes/register-gitlab.ts
  • packages/dashboard/src/routes/register-messaging-scripts.ts
  • packages/dashboard/src/routes/register-planning-subtask-routes.ts
  • packages/dashboard/src/routes/register-signal-routes.ts
  • packages/dashboard/src/routes/register-task-workflow-routes.ts
  • packages/dashboard/src/routes/register-workflow-routes.ts
  • packages/dashboard/src/server.ts
  • packages/dashboard/src/triage-trait.ts
  • packages/desktop/src/__tests__/local-runtime.test.ts
  • packages/desktop/src/__tests__/local-server-auth.test.ts
  • packages/desktop/src/__tests__/local-server.test.ts
  • packages/desktop/src/api-token.ts
  • packages/desktop/src/local-runtime.ts
  • packages/desktop/src/local-server.ts
  • packages/desktop/src/preload.ts
  • packages/desktop/src/types.d.ts
  • packages/engine/src/__tests__/agent-document-tools.test.ts
  • packages/engine/src/__tests__/agent-heartbeat-worktree-renamed-hold.test.ts
  • packages/engine/src/__tests__/agent-heartbeat-worktree.test.ts
  • packages/engine/src/__tests__/agent-tools-delegation.test.ts
  • packages/engine/src/__tests__/agent-tools-task-assign.test.ts
  • packages/engine/src/__tests__/auto-recovery-branch-worktree.test.ts
  • packages/engine/src/__tests__/auto-recovery-contamination.test.ts
  • packages/engine/src/__tests__/backlog-pressure-reporter.test.ts
  • packages/engine/src/__tests__/ce-workflow-step-executor.test.ts
  • packages/engine/src/__tests__/cron-runner.test.ts
  • packages/engine/src/__tests__/executor-approval-gate-suspend.test.ts
  • packages/engine/src/__tests__/executor-base-commit-capture.test.ts
  • packages/engine/src/__tests__/executor-browser-verification.test.ts
  • packages/engine/src/__tests__/executor-capture-modified-files-attribution.test.ts
  • packages/engine/src/__tests__/executor-contamination-base.test.ts
  • packages/engine/src/__tests__/executor-execution-policy-renamed-columns.test.ts
  • packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts
  • packages/engine/src/__tests__/executor-graph-boundary.test.ts
  • packages/engine/src/__tests__/executor-graph-permission-denied.test.ts
  • packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts
  • packages/engine/src/__tests__/executor-implicit-task-done-budget.test.ts
  • packages/engine/src/__tests__/executor-implicit-task-done-revise-guard.test.ts
  • packages/engine/src/__tests__/executor-lease-renewal.test.ts
  • packages/engine/src/__tests__/executor-live-overseer-retry-gate.test.ts
  • packages/engine/src/__tests__/executor-mutation-context-threading.test.ts
  • packages/engine/src/__tests__/executor-outer-dispatch-dependency-gate.test.ts
  • packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts
  • packages/engine/src/__tests__/executor-primitive-exit-events.test.ts
  • packages/engine/src/__tests__/executor-prompt.test.ts
  • packages/engine/src/__tests__/executor-reset-steps-if-work-lost.test.ts
  • packages/engine/src/__tests__/executor-step-numbering-zero-based.test.ts
  • packages/engine/src/__tests__/executor-step-session.test.ts
  • packages/engine/src/__tests__/executor-tool-failure-retry.test.ts
  • packages/engine/src/__tests__/executor-triage-column-audit.test.ts
  • packages/engine/src/__tests__/executor-workflow-step-model.test.ts
  • packages/engine/src/__tests__/executor-worktree-conflict.test.ts
  • packages/engine/src/__tests__/executor-worktree.test.ts
  • packages/engine/src/__tests__/fallback-model-observer.test.ts
  • packages/engine/src/__tests__/heartbeat-executor.test.ts
  • packages/engine/src/__tests__/identity-permissions-shadow.test.ts
  • packages/engine/src/__tests__/merge-error-recovery.test.ts
  • packages/engine/src/__tests__/merger-ai.test.ts
  • packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts
  • packages/engine/src/__tests__/merger-landed-files-capture.test.ts
  • packages/engine/src/__tests__/merger-merge-details.test.ts
  • packages/engine/src/__tests__/merger-merge-lifecycle.test.ts
  • packages/engine/src/__tests__/merger-prompt-and-utils.test.ts
  • packages/engine/src/__tests__/merger-skills.test.ts
  • packages/engine/src/__tests__/merger-trait-rekey.test.ts
  • packages/engine/src/__tests__/merger-verification.test.ts
  • packages/engine/src/__tests__/mesh-lease-manager-renamed-columns.test.ts
  • packages/engine/src/__tests__/mesh-lease-manager.test.ts
  • packages/engine/src/__tests__/mission-autopilot.test.ts
  • packages/engine/src/__tests__/mock-provider.test.ts
  • packages/engine/src/__tests__/mutation-context-matchers.test.ts
  • packages/engine/src/__tests__/mutation-context-matchers.ts
  • packages/engine/src/__tests__/plan-artifact-writeback.test.ts
  • packages/engine/src/__tests__/plan-prompt-write-surfaces.test.ts
  • packages/engine/src/__tests__/plan-review-unavailable-recovery.test.ts
  • packages/engine/src/__tests__/planning-evacuation.test.ts
  • packages/engine/src/__tests__/pr-comment-handler.test.ts
  • packages/engine/src/__tests__/project-engine-deferred-startup.test.ts
  • packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts
  • packages/engine/src/__tests__/promote-force-unplanned.test.ts
  • packages/engine/src/__tests__/reliability-interactions/auto-revive-and-watchdog.test.ts
  • packages/engine/src/__tests__/reliability-interactions/branch-worktree-auto-recovery.test.ts
  • packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts
  • packages/engine/src/__tests__/reliability-interactions/execute-requeue-loop-guard.test.ts
  • packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts
  • packages/engine/src/__tests__/reliability-interactions/executor-no-task-done-vs-worktree-reclaim.test.ts
  • packages/engine/src/__tests__/reliability-interactions/graph-node-missing-worktree-recovery.test.ts
  • packages/engine/src/__tests__/reliability-interactions/in-review-retry-exhausted-policy-convergence.test.ts
  • packages/engine/src/__tests__/reliability-interactions/merge-node-paused-abort-retryable.test.ts
  • packages/engine/src/__tests__/reliability-interactions/no-changes-finalized.real-git.test.ts
  • packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts
  • packages/engine/src/__tests__/reliability-interactions/paused-scope-decay.test.ts
  • packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts
  • packages/engine/src/__tests__/reliability-interactions/pr-changes-requested-reexecution.test.ts
  • packages/engine/src/__tests__/reliability-interactions/reclaim-phantom-executor-binding.test.ts
  • packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts
  • packages/engine/src/__tests__/reliability-interactions/self-healing-interactions.test.ts
  • packages/engine/src/__tests__/reliability-interactions/soft-delete-deadlock-scan-exclusion.test.ts
  • packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts
  • packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts
  • packages/engine/src/__tests__/reliability-interactions/worktree-incomplete-session-start.test.ts
  • packages/engine/src/__tests__/replan-target.test.ts
  • packages/engine/src/__tests__/restart-recovery-coordinator.test.ts
  • packages/engine/src/__tests__/reviewer.test.ts
  • packages/engine/src/__tests__/routine-runner.test.ts
  • packages/engine/src/__tests__/scheduler-deleted-blocker-wip-dependent.test.ts
  • packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts
  • packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts
  • packages/engine/src/__tests__/self-healing-completion-fanout.test.ts
  • packages/engine/src/__tests__/self-healing-fn-5488-fast-path-regressions.test.ts
  • packages/engine/src/__tests__/self-healing-ghost-branch-recovery.test.ts
  • packages/engine/src/__tests__/self-healing-in-progress-limbo.test.ts
  • packages/engine/src/__tests__/self-healing-leaked-slot-reaper.test.ts
  • packages/engine/src/__tests__/self-healing-orphan-only-scope.test.ts
  • packages/engine/src/__tests__/self-healing-paused-abort-recovery.test.ts
  • packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts
  • packages/engine/src/__tests__/self-healing-reclaim-live-zero-commits.test.ts
  • packages/engine/src/__tests__/self-healing-reclaim-paused-review.test.ts
  • packages/engine/src/__tests__/self-healing-stale-merger-status.test.ts
  • packages/engine/src/__tests__/self-healing-stale-paused-characterization.test.ts
  • packages/engine/src/__tests__/self-healing-stale-paused-renamed-hold.test.ts
  • packages/engine/src/__tests__/self-healing-starved-refinement.test.ts
  • packages/engine/src/__tests__/self-healing-trait-rekey.test.ts
  • packages/engine/src/__tests__/self-healing-wedged-active-merge.test.ts
  • packages/engine/src/__tests__/self-healing.test.ts
  • packages/engine/src/__tests__/stale-task-reporter.test.ts
  • packages/engine/src/__tests__/step-execute-skill-loading.test.ts
  • packages/engine/src/__tests__/step-runner.test.ts
  • packages/engine/src/__tests__/stuck-task-detector.test.ts
  • packages/engine/src/__tests__/surfacing-family-shared.test.ts
  • packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts
  • packages/engine/src/__tests__/triage-finalize-duplicate-lineage.test.ts
  • packages/engine/src/__tests__/triage-pause-abort.test.ts
  • packages/engine/src/__tests__/triage-soft-delete-write-abort.test.ts
  • packages/engine/src/__tests__/triage-stale-planning-sweep-renamed-lanes.test.ts
  • packages/engine/src/__tests__/triage.test.ts
  • packages/engine/src/__tests__/unlinked-missions-advisory-reporter.test.ts
  • packages/engine/src/__tests__/usage-limit-detector.test.ts
  • packages/engine/src/__tests__/verify-worktree-invariants-missing.test.ts
  • packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts
  • packages/engine/src/__tests__/workflow-lifecycle-live-e2e.pg.test.ts
  • packages/engine/src/__tests__/workflow-work-scheduler.test.ts
  • packages/engine/src/__tests__/workspace-merger.test.ts
  • packages/engine/src/__tests__/worktree-base-refresh.test.ts
  • packages/engine/src/agent-heartbeat.ts
  • packages/engine/src/agent-tools.ts
  • packages/engine/src/auth/fallback-model-observer.ts
  • packages/engine/src/auto-recovery-handlers/branch-worktree.ts
  • packages/engine/src/auto-recovery-handlers/contamination.ts
  • packages/engine/src/errors/retry-burned-logger.ts
  • packages/engine/src/errors/usage-limit-detector.ts
  • packages/engine/src/eval/eval-followups.ts
  • packages/engine/src/execution/hold-release.ts
  • packages/engine/src/execution/replan-target.ts
  • packages/engine/src/execution/reviewer.ts
  • packages/engine/src/execution/session-token-usage.ts
  • packages/engine/src/execution/step-runner.ts
  • packages/engine/src/execution/step-session-executor.ts
  • packages/engine/src/execution/task-revert.ts
  • packages/engine/src/execution/verification-utils.ts
  • packages/engine/src/executor/__tests__/external-checkout-extraction-guards.test.ts
  • packages/engine/src/executor/__tests__/shared-worker-tools.test.ts
  • packages/engine/src/executor/adopt-column-agent-for-node.ts
  • packages/engine/src/executor/attempt-executor-verification-fix.ts
  • packages/engine/src/executor/await-input-node.ts
  • packages/engine/src/executor/block-outer-dispatch-when-ephemeral-disabled.ts
  • packages/engine/src/executor/bootstrap-misbinding-recovery.ts
  • packages/engine/src/executor/build-action-gate-context.ts
  • packages/engine/src/executor/build-column-boundary-hooks.ts
  • packages/engine/src/executor/build-permanent-agent-gating-context.ts
  • packages/engine/src/executor/cleanup-merge-state.ts
  • packages/engine/src/executor/clear-terminal-step-failures-for-retry.ts
  • packages/engine/src/executor/completion-finalization.ts
  • packages/engine/src/executor/create-authoritative-workflow-primitives.ts
  • packages/engine/src/executor/create-authoritative-workflow-seams.ts
  • packages/engine/src/executor/create-review-dispute-tool.ts
  • packages/engine/src/executor/create-spawn-agent-tool.ts
  • packages/engine/src/executor/create-task-done-tool.ts
  • packages/engine/src/executor/dependency-dispatch-gate.ts
  • packages/engine/src/executor/deps-bags.ts
  • packages/engine/src/executor/deterministic-verification.ts
  • packages/engine/src/executor/ensure-graph-custom-node-worktree.ts
  • packages/engine/src/executor/execute-review-handoff.ts
  • packages/engine/src/executor/execute-workflow-graph.ts
  • packages/engine/src/executor/execute-workflow-step.ts
  • packages/engine/src/executor/finalize-already-reviewed-task.ts
  • packages/engine/src/executor/graph-rethink-reset.ts
  • packages/engine/src/executor/handle-graph-failure.ts
  • packages/engine/src/executor/handle-stale-in-review-parse-pause-abort-replay.ts
  • packages/engine/src/executor/handle-stale-in-review-plan-pause-abort-replay.ts
  • packages/engine/src/executor/handoff-task-to-review.ts
  • packages/engine/src/executor/merge-confirmed-finalize.ts
  • packages/engine/src/executor/non-continuable-session.ts
  • packages/engine/src/executor/park-approval-suspension.ts
  • packages/engine/src/executor/park-plan-review-replan-cap.ts
  • packages/engine/src/executor/persist-token-usage.ts
  • packages/engine/src/executor/plan-review-no-op.ts
  • packages/engine/src/executor/prepare-graph-node-execution.ts
  • packages/engine/src/executor/reconcile-steps-from-git-history.ts
  • packages/engine/src/executor/recover-completed-task.ts
  • packages/engine/src/executor/recover-failed-pre-merge-step.ts
  • packages/engine/src/executor/reenter-paused-aborted-workflow-node.ts
  • packages/engine/src/executor/release-pre-execution-worktree.ts
  • packages/engine/src/executor/renew-task-lease.ts
  • packages/engine/src/executor/request-pre-merge-optional-step-fix.ts
  • packages/engine/src/executor/required-artifact-recovery.ts
  • packages/engine/src/executor/resolve-seam-column-agent.ts
  • packages/engine/src/executor/review-arbitration.ts
  • packages/engine/src/executor/review-convergence-ladder.ts
  • packages/engine/src/executor/route-graph-failure-to-execution-resume.ts
  • packages/engine/src/executor/route-graph-merge-failure-to-retry.ts
  • packages/engine/src/executor/route-implementation-incomplete-merge-graph-failure.ts
  • packages/engine/src/executor/route-reset-parse-pin-mismatch.ts
  • packages/engine/src/executor/route-retryable-remediation.ts
  • packages/engine/src/executor/route-unusable-worktree-graph-failure-to-recovery.ts
  • packages/engine/src/executor/run-cli-agent-node.ts
  • packages/engine/src/executor/run-context-for.ts
  • packages/engine/src/executor/run-graph-custom-node.ts
  • packages/engine/src/executor/run-implementation.ts
  • packages/engine/src/executor/run-raw-cli-command.ts
  • packages/engine/src/executor/safe-log-entry.ts
  • packages/engine/src/executor/send-task-back-for-fix.ts
  • packages/engine/src/executor/session-contention-hold.ts
  • packages/engine/src/executor/shared-worker-tools.ts
  • packages/engine/src/executor/should-defer-completion-for-global-pause.ts
  • packages/engine/src/executor/should-defer-workflow-step-completion.ts
  • packages/engine/src/executor/task-done-refusal-handler.ts
  • packages/engine/src/executor/task-executor-session-facades.ts
  • packages/engine/src/executor/task-executor-state.ts
  • packages/engine/src/executor/unpause-resume.ts
  • packages/engine/src/executor/wire-executor-lifecycle.ts
  • packages/engine/src/executor/workflow-input-markers.ts
  • packages/engine/src/executor/workflow-merge-boundary.ts
  • packages/engine/src/executor/workflow-script-step.ts
  • packages/engine/src/executor/worktree-branch-conflict-handle.ts
  • packages/engine/src/executor/worktree-create-outer.ts
  • packages/engine/src/executor/worktree-git-refs.ts
  • packages/engine/src/executor/worktree-missing-session-recovery.ts
  • packages/engine/src/executor/worktree-stale-lock-recovery.ts
  • packages/engine/src/executor/worktree-task-done-scope-leak.ts
  • packages/engine/src/executor/worktree-verify-invariants.ts
  • packages/engine/src/goals/goal-injection-diagnostics.ts
  • packages/engine/src/healing/restart-recovery-coordinator.ts
  • packages/engine/src/healing/stale-task-reporter.ts
  • packages/engine/src/healing/stuck-task-detector.ts
  • packages/engine/src/merge/auto-merge-finalization.ts
  • packages/engine/src/merge/merger-ai.ts
  • packages/engine/src/merge/pr-comment-handler.ts
  • packages/engine/src/merger.ts
  • packages/engine/src/missions/mission-autopilot.ts
  • packages/engine/src/missions/mission-execution-loop.ts
  • packages/engine/src/missions/unlinked-missions-advisory-reporter.ts
  • packages/engine/src/overseer/planner-overseer.ts
  • packages/engine/src/plan-artifact-writeback.ts
  • packages/engine/src/project-engine.ts
  • packages/engine/src/project/mesh-lease-manager.ts
  • packages/engine/src/recovery/foreign-only-contamination.ts
  • packages/engine/src/runtimes/in-process-runtime.ts
  • packages/engine/src/scheduler.ts
  • packages/engine/src/scheduling/backlog-pressure-reporter.ts
  • packages/engine/src/scheduling/cron-runner.ts
  • packages/engine/src/scheduling/routine-runner.ts
  • packages/engine/src/self-healing.ts
  • packages/engine/src/self-healing/archive-ghost-bug.ts
  • packages/engine/src/self-healing/auto-recover-worktree-session.ts
  • packages/engine/src/surfacing-sweeps.ts
  • packages/engine/src/triage.ts
  • packages/engine/src/util/run-audit.ts
  • packages/engine/src/workflow-column-boundary-hooks.ts
  • packages/engine/src/workflows/workflow-column-boundary.ts
  • packages/engine/src/workflows/workflow-completion-summary.ts
  • packages/engine/src/workflows/workflow-graph-executor.ts
  • packages/engine/src/workflows/workflow-graph-task-runner.ts
  • packages/engine/src/workflows/workflow-task-runtime.ts
  • packages/engine/src/workflows/workflow-work-processor.ts
  • packages/engine/src/workflows/workflow-work-scheduler.ts
  • packages/engine/src/worktree-base-refresh.ts
  • packages/engine/src/worktree/worktree-acquisition.ts
  • packages/engine/src/worktree/worktrunk-failure-handler.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR establishes the initial pluggable identity and authorization foundation while hardening dashboard and plugin security boundaries.

  • Adds actors, credentials, sessions, role grants, permission resolution, and mutation attribution primitives.
  • Converts mutation call sites to carry explicit actor context and introduces deny-by-default plugin store gating.
  • Refuses implicitly unauthenticated dashboard launches, authenticates desktop loopback servers, and removes the process-global action-gate reload endpoint.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/core/src/identity/authorize.ts Introduces the central deny-by-default authorization decision path, including identity-disabled compatibility behavior.
packages/core/src/plugin-task-store-gate.ts Replaces broad plugin store passthrough with read classification and explicit write allowlists.
packages/dashboard/src/server.ts Requires explicit unauthenticated mode and otherwise refuses API access when no daemon token is configured.
packages/desktop/src/local-server.ts Mints a desktop server token and confines the local server to the loopback interface.
packages/core/src/postgres/migrations/0067_fn_identity_actors.sql Adds identity persistence and restricts runtime-role writes to role-grant authority data.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  S[Dashboard, CLI, engine, plugins] --> C[Mutation context with actor]
  C --> A[Core authorization]
  A -->|identity disabled| W[Authorized store mutation]
  A -->|identity enabled and allowed| W
  A -->|unresolved or denied| D[Permission denial]
  A --> G[(Project role grants)]
  A --> R[(Central actor and session records)]
  P[Plugin TaskStore proxy] -->|allowlisted writes| C
  P -->|other writes| D
Loading

Reviews (17): Last reviewed commit: "merge: take the remote branch's latest (..." | Re-trigger Greptile

@gsxdsm

gsxdsm commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by a stacked split into reviewable chunks, all under 150 files (CodeRabbit skipped this PR at 439):

Stack tip is byte-identical to this branch. Closing in favour of the stack.

@gsxdsm gsxdsm closed this Aug 14, 2026
@gsxdsm gsxdsm reopened this Aug 14, 2026
@gsxdsm

gsxdsm commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Reopening: the stacked split (#3429#3433) is good for review but cannot merge green on its own.

Core's RunMutationContext.actor becoming required is atomic across the whole workspace, not just engine. #3429 (core alone) fails Typecheck/Build/Gate because engine, dashboard and CLI are still unconverted there — the deprecated-overload staging covers store arity, not a type gaining a required field.

So: review via the stack, merge via this PR.

@gsxdsm

gsxdsm commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed in 1fa0961294 on feature/user-accounts (the merge path, #3428).

Fixed — the findings were correct:

Finding Resolution
identityEnabled never reaches the process flag (#3429) Wired in the daemon on startup and on settings:updated. This was a real gap in my own work — identity-enabled.ts says "U5 calls this on startup and on change" and U5 never did. Failure was silent in the dangerous direction: UI reads "on", every gate stays on its disabled allow-branch.
moveTask unattributed at the merge boundary (#3430) Threaded. It was the only write in that file still on the context-free shape, so the move audited as system/unknown while the log entries either side of it carried the real actor.
captureBaseCommitSha drops run identity (#3430) Threaded at both callers — each already resolves a context for every other write on the same path.
FNXC comment documents a call that doesn't exist (#3430) Fixed, and found a second instance: an earlier bulk conversion had rewritten text inside safe-log-entry.ts's comment, corrupting the historical record of the FN-7335 bug that comment exists to explain.
ANY_MUTATION_CONTEXT accepts system:unattributed (#3432) Tightened, with proven-failing controls for the matcher itself. This was the highest-value finding — see below.

The matcher finding deserves its own note. It was exactly right, and the blast radius was real: tightening exposed 15 genuinely unattributed writes across 11 suites that the loose matcher had been passing as attributed. Those now assert UNATTRIBUTED_CONTEXT_MATCHER explicitly, so the remaining gap is countable rather than invisible. Since a wrongly-marked (actually-attributed) site fails the assertion, all-green proves the classification is right rather than merely quiet.

Not applied, with reasons:

Verification. Measured against a baseline of the same tree with these edits reverted, so pre-existing failures aren't attributed to this change: 77 failed files / 258 failed tests before → 77 / 263 after, with no file this change touches newly failing. pnpm test:gate, pnpm lint, and CLI typecheck all green. One new suite-only failure (self-healing-pending-wedge-notification) is unmodified by this change, passes in isolation, and is recorded in the observed-flakes register per the standing rule rather than quarantined on a first sighting.

@gsxdsm

gsxdsm commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Stack rebased so every chunk carries its own review fixes; the stack tip is byte-identical to the full branch (git rev-parse identity/5-dashboard-cli^{tree} == feature/user-accounts^{tree} == 1252e4a096).

One further real finding fixed (d6167be9be): rebaseNewWorktreeOntoRemote's dep type stopped at settingsOverride, so the sole production caller could not pass a run context even though createWorktree resolves one for its other writes — its skip/fetch/success/failure breadcrumbs all persisted unattributed. Notably, that parameter's own doc comment claimed it was "REQUIRED so an unwired caller is a compile error, not a silent unattributed write" while being declared optional, and the one caller was in fact unwired. The comment now describes the code rather than contradicting it.

Re-posted findings that are already fixed in the current tree (the bot re-reviewed against pre-fix line numbers):

  • workflow-merge-boundary.ts:114/:136 — there is exactly one moveTask in that file and it takes the context; verified at the current SHA.
  • worktree-git-refs.ts:149 — both callers now pass a resolved context.
  • mutation-context-matchers.ts:40 "generic matcher accepts unattributed writes" — the matcher no longer uses expect.any(String); it matches /^(?!system:unattributed$).+/ and has its own proven-failing controls.
  • extension.ts "published CLI lacks changeset" — .changeset/u18-stage-d-mutation-context-dashboard-cli.md covers this conversion; it lives in a different chunk of the split.

Still declined: the FNXC-trace requests across the executor files. All ten already carry FNXC comments and the changed blocks are mechanical propagation of a conversion documented once at its seam; ten near-identical "attribution threaded here" notes are noise, not traceability.

CI: #3428 7/7 green, #3429 6/6, #3431#3433 all green. The only red mark is the Greptile check itself, which reports its own open findings rather than a build result.

@gsxdsm

gsxdsm commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Conflicts: resolved everywhere. All six PRs now report MERGEABLE. #3428 was the only one conflicting (4 files, 17 commits behind main); every resolution keeps both sides rather than picking a winner — notably worktree-acquisition.ts, where main funnelled seven direct writes through a new persistWorktreeAssignment helper while U18 was adding a context to each. I took main's helper and threaded the context inside it, so attribution lives at one seam that can't drift instead of seven call sites.

#3429 findings — 5 of 18 addressed, verified against code rather than taken on report:

  • Plugin gate had two escapes around the get trap (Critical, and it undercut my own deny-by-default work). constructor was on the intrinsic passthrough list, so store.constructor.prototype.deleteTask.call(store, …) reached a denied method having never entered the trap — one property read defeated the whole gate. Separately, a Proxy with only a get trap forwards set/defineProperty/deleteProperty to the target, so a plugin could monkey-patch a denied method into an undenied one. Both closed, with escape-attempt tests.
  • Unbounded scrypt cost from a stored hash (Critical). maxmem is derived from the N/r/p parsed out of the stored string, and any positive integer was accepted — a row claiming N=2^30 asks for ~128 GiB at login. Bounded, N now required to be a power of two.
  • Migration referenced under three numbers (Critical). 0047/0059/0060 after two renumbers around main's own migrations; schema-applier.test.ts still asserted 0059 and is executable, so it was failing. Those assertions are what catch the next collision.
  • identityEnabled disconnected — already fixed in the earlier pass (daemon syncs at startup and on settings:updated).
  • The mutating-trap fix also answers the plugin-task-store-gate.ts:120/138 pair.

Not yet addressed — 12 findings remain open on #3429, and they should not be assumed benign:

async-agent-store.ts:760 (agent API-key verification not project-scoped — Critical), async-agent-store.ts:748 (missing lookup index), permissions.ts:768 (agentGrantable enforced on grantor kind, not grant target), project.ts:2571 (composite PK overwrites grant revocation history), sessions.ts:477 (rotation not in one transaction), credentials/actor-store.ts:241 (widen layer type for suspendActor/tombstoneActor), archive-lifecycle-2.ts:269/698, moves.ts:420, session-identity-registry.ts:67, index.ts:2935, mission-store.ts:4412.

Several are security-relevant and need the same verify-then-fix treatment rather than a batch apply; the two Criticals above were each real but not quite as the summary described, which is why they were worth checking individually.

CI: #3428 7/7, #3429 6/6, #3431#3433 green. The only red is the Greptile check reporting its own open findings.

@gsxdsm
gsxdsm force-pushed the feature/user-accounts branch from 34727e6 to b9c0321 Compare August 15, 2026 23:21
gsxdsm and others added 16 commits August 15, 2026 16:21
Closes three ways the dashboard API could be served or ungated without
authentication. None depends on the identity model, so they ship ahead of it
(plan units U22, U19, U20).

Auth previously mounted only `if (daemonToken)`, making "is this API
protected?" a property of how the process was launched rather than a
deliberate setting. Serving `/api/*` unauthenticated is now an explicit
opt-in; a launch with neither a token nor `--no-auth` refuses with 503. The
three WebSocket upgrade paths carried the identical
`token && !noAuth && !authed` short-circuit and so accepted every upgrade
when no token was configured — inverted the same way.

The Electron host tripped exactly that: it passed no token and called
`app.listen(0)` with no host argument, binding all interfaces and exposing
the shell-capable terminal WebSocket to the LAN. Both desktop entrypoints
now mint a per-process token and bind loopback, with the token reaching the
renderer over the channel `baseUrl` already uses.

`POST /api/action-gate/reload` is deleted. Because the action gate maps
`exempt` straight to `allow`, one request could disable agent action gating
for every agent in every project at any preset, unaudited and unscoped. The
in-process helpers remain for engine and test use.

Tests that boot a server now declare `noAuth: true` rather than relying on
the previous implicit-open behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…message

Authorization had no typed error in @fusion/core: a denial was an HTTP status
at the edge and a regex over error strings in the engine, so nothing
downstream could distinguish it from any other failure. Adds
PermissionDeniedError with a PERMISSION_DENIED code discriminant, plus a
structural guard that matches the code rather than the prototype so the check
survives duplicate module instances across package boundaries.

Two conflations are now separable. The dashboard gains forbidden(403),
carrying a machine-readable reason, so a denial no longer arrives as 401 and
triggers a re-auth prompt that cannot fix a missing grant. Client recovery
detection keys on that structured discriminant instead of an exact match on
two prose strings, which previously meant any reworded 401 silently failed to
prompt.

The workflow graph flattens a node exception to its message before the
executor's terminal park sees it, so the code cannot be read off a live error
there. The graph executor now stamps the discriminant alongside the flattened
message, and the terminal park preserves a denial's own text instead of
replacing it with the generic node-failure string. Non-permission failures
produce the previous message byte-identically.

Also exports the new symbols from the root and gate barrels — the store facade
alone left them unreachable from @fusion/core, and the gate barrel is aliased
under test:gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(0047)

Additive-only migration creating the identity tables. Actors, credentials,
sessions, and provider links live in `central` because one daemon serves many
projects from a shared database and an identity must resolve across all of
them; role grants live in `project` because authority is per-project.

Sessions store a lookup id and a hash, never the raw value. The plan already
accepts "a local shell user can reach Postgres directly" as a residual;
storing live bearer tokens in plaintext would upgrade that from reading data
to impersonating any administrator over HTTP, and the same applies to anyone
holding a backup dump.

actor_role_grants is keyed (project_id, actor_id, role) — the steady-state
ownership audit throws on every subsequent boot unless a project table's keys
include project_id — and carries 0046's RLS boilerplate verbatim. actor_id is
a plain column rather than a foreign key into `central`: no central table has
RLS, every existing cross-schema reference is central-to-central, and a
tombstoned actor must still resolve, so a cascade would be wrong.

The legacy project_auth_* tables are deliberately left in place. They look
dead but the SQLite cutover migrator copies them by name and a missing target
is a fail-closed startup error, so dropping them would brick upgrades from
legacy databases.

Two pre-existing drifts surfaced and are corrected here rather than left:
0046 created project.workflow_agent_capacity_leases without updating the
table-count assertion (already failing before this change) or registering it
for harness truncation, which is the order-dependent flake this registry
exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cation

Introduces one Actor vocabulary (human/agent/system) to replace the four the
codebase currently disagrees with, and threads it through the mutation
context as a required field. Only the field is required here; the runContext
parameter itself stays optional, so this is 12 production files rather than
the ~2,500 call sites that conversion will touch.

The bootstrap actor is now a real, defined thing: a reserved system actor
with a fixed id and no grants, whose authority comes from the permission
check short-circuiting while identity is off rather than from holding
permissions. Its id and the pre-existing "unknown-agent" sentinel are
reserved against ever being created as actors or receiving a grant, so the
two ids that mean "we do not know who this is" can never accumulate
authority.

Delegation is two persisted fields, never a collapsed effective user, and
authorization takes the intersection when actingFor is present. Keying on the
acting actor alone would let a viewer-role human obtain a merge by queueing
it to an agent that holds merge authority, with the audit row honestly naming
the agent.

Suspending or tombstoning an actor now revokes its sessions, credentials, and
role grants in the same operation. Without that, revocation does nothing until
the absolute session timeout, and the deliberate absence of a cross-schema
foreign key means nothing at the database layer cleans up either.

The unregistered-cwd branch of the session resolver no longer defaults to
operator, so an agent in an unregistered directory is no longer classified as
a human administrator. The inversion is gated behind identity.enabled because
operator-as-absence is load-bearing for human CLI pass-through until the CLI
gains its own credential.

callerKind stays, and stays non-authoritative: it is self-reported via a
request header and must never gate a permission, so the authenticated actor
is a separate field alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e resolution

One catalog covering humans and agents, where a grant is a
(permission, disposition) pair rather than a boolean. Dropping the disposition
axis would silently discard the block and require-approval outcomes that live
approval flows depend on.

Resolution takes the invocation's arguments, not just the permission name. The
existing categories are argument-derived — the same shell tool routes to a
git-write or a plain-command category by inspecting the command string — so a
grant keyed on a static tool name cannot express "allow shell commands but
require approval for git writes", which the shipped default preset relies on.
The mapping check therefore runs against classified invocations; a tool-name
keyed check would pass without testing the property at all.

The catalog needed seven runtime entries beyond the administrative ones. All
eighteen seeded permissions describe board and configuration authority and
none describes a runtime action, so folding a runtime category onto one of
them would have been a false equivalence that silently widens or narrows it.

The coordination exemption survives above the catalog as a non-grantable
floor. It exists so permanent-agent heartbeats cannot deadlock, and under
deny-by-default a grantable version would reintroduce that deadlock.

require-approval is carried as a protocol, not a verdict: the dedupe key binds
a grant to the exact command and target task, redemption is execute-once, and
grants expire. Minting one approval per actor-permission pair would
reintroduce two recorded incidents — one approved command authorizing
arbitrary later ones, and approvals collapsing across tasks.

The no-escalation invariant checks both axes, so a grantor holding a
permission at require-approval cannot hand it out at allow, and it covers the
seed path where no runtime check would otherwise run.

Core cannot import the engine, so the git-command classifier is ported rather
than shared; an engine-side parity test pins the copy against the original
over a command corpus so drift fails a test instead of diverging silently.

The replaced modules and the live gate's call path are untouched — repointing
them now would switch agent gating off for every intermediate commit, since
this model short-circuits while identity is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three services, with the password and machine-token paths deliberately in
separate modules. A password KDF is meant to be slow; a token is presented on
every request, and letting the slow path leak onto the token path is the
documented ~946ms-per-request mistake. The separation is enforced by
import-level tests rather than left as an intention.

Passwords use scrypt at the corrected OWASP floor. The WASM Argon2id package
was evaluated and rejected on evidence, not preference: its entry point is a
bundler-plugin wasm import, and under plain Node ESM — how core is consumed
after tsc emit, with no bundler in the path — it fails to resolve outright.
Stdlib argon2 is unavailable because it landed in Node 24.7 and the engines
floor is 22.5. The naive scrypt call throws at Node's 32 MiB memory default
once N reaches the OWASP floor, so both parameters are pinned and a test
fails in either direction. Hashes are self-describing so raising parameters
later does not lock existing operators out.

Machine tokens carry a lookup id so verification is a keyed row fetch plus a
constant-time compare rather than a scan, and timingSafeEqual's throw on
length mismatch is guarded rather than assumed.

Agent API keys are migrated onto that format. This is safe because nothing
ever consumed the old one: the previous mint wrote an unsalted digest that no
code path anywhere verified, so those rows were already inert. The defect was
never the digest strength — it was the missing lookup id.

Sessions are durable rather than in-memory, so they survive a daemon restart,
and rotate on login and on privilege change. Human and agent sessions carry
different lifetimes on purpose: an agent is idle by design between heartbeats,
so a human idle timeout would kill a multi-hour run mid-tool-call, while
exempting agents from expiry entirely would create the non-expiring credential
the whole boundary rests on. Agents therefore have no idle window, an absolute
lifetime bounded by run duration with in-run renewal, and revocation checked
at each tool call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First package of the parameter conversion. Uses a deprecated overload
alongside the canonical required-parameter one, because a method signature is
a single artifact shared by every consumer: making it required outright would
invalidate every downstream call site in one commit and nothing would compile.
The overload stays until all packages convert.

The design point is what happens where no real actor exists yet. HTTP routes
get one when the identity middleware lands, the CLI when it gains a
credential, engine sweeps when they get a system actor. Defaulting those to
the bootstrap actor would look like attribution while meaning nothing, and
leave no way to find them later. They take an explicit unattributed marker
instead — a reserved id that can never hold a grant — and a census test
records the baseline and fails if it grows. The debt is greppable and CI
drives it down rather than trusting a promise.

Of 43 converted production sites, 17 derive a real actor from context already
in scope and 26 take the marker; the census baseline is 33.

Threading is real rather than a seam. moveTask now feeds the internal run
context that roughly twenty audit sites already read but the public method
could never supply, and createTask carries the actor into the "Task created"
log entry, which previously recorded nothing about who created a task.

Test churn is near zero by design: the deprecated overload absorbed all but
six of roughly 858 call sites across 489 test files. Four of those six now
assert the context positionally, so when a later unit wires a real actor the
assertion fails and names the line rather than silently accepting it.

Note for reviewers: four core test files fail on this branch, and they fail
identically at the parent commit — verified by running them in a detached
worktree at HEAD. They come from the recent durable-role-agent work
(AgentStore init requiring asyncLayer.projectId), not from this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ratchet scanned @fusion/core only, so every other package could
accumulate unattributed markers freely while the test reported core's 33 and
stayed green — the same "resolved seam nobody wired" failure the marker exists
to prevent.

Roots are now per-package with their own baselines. A single total would pass
a change that removed five markers from core and added five to the engine,
which is debt migrating toward the package with the most derivable actors —
exactly the regression worth catching. A second assertion checks each root
resolves to real files, because a mistyped root censuses nothing and reports a
healthy zero forever.

Dashboard and CLI roots are deliberately absent until those packages start
converting: a root asserting a baseline of zero over an unconverted package
claims it has no debt rather than that it has not started.

Also records the nine structural store seams in the engine that re-declare
these methods with their own narrow signatures and no context parameter.
Pick<TaskStore, ...> projections inherit the overloads and are fine; these do
not, so they would keep accepting unattributed writes after a call-site sweep
reported done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s its sweeps

Nine engine interfaces re-declared the store's mutating methods with their own
narrow signatures and no context parameter. Pick<TaskStore, ...> projections
inherit the overloads and were fine; these did not, so they would have kept
accepting unattributed writes even after every call site was converted — a
sweep would have reported done while the seams stayed open. All nine now
mirror the canonical arity, which closes the hole and is also what keeps a
real TaskStore structurally assignable, since the deprecated overload cannot
absorb a run context in an outcome or options slot.

The proof that these were real: with the seam closed, restoring one dashboard
caller to its pre-conversion unattributed form is a type error naming the
missing context; with the seam reverted to its old narrow shape, that same
unattributed board-task creation compiles clean.

Self-healing, the scheduler, and the cron runner are converted to the explicit
marker rather than given an invented system identity. They are genuinely
actorless, and whether they get a real system actor is a design decision that
belongs to the unit that owns those paths, not something to bury inside a
mechanical conversion. Nine extracted helpers of those same sweeps had to
convert too — converting only the entry points would have left one sweep
half-marked across files.

The engine census baseline moves from 0 to 292, so the debt is counted and
attributed by file group rather than implied.

Test assertions were extended to the canonical arity rather than relaxed,
including the negative forms that would otherwise have passed vacuously
forever. Around twenty-five were deliberately left at the original arity
because their emitters are genuinely out of scope or are fakes standing in for
core internals; extending those would have made a true assertion false.

Engine suite: 54 failing files against 55 at the parent commit, with zero
newly failing and one baseline failure now green. The remaining failures are
pre-existing — one reads a source file deleted by an earlier refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts the 379 non-executor engine call sites: 349 derive a real actor, 30
take the marker. That ratio is the point of this change. The sweeps converted
earlier were genuinely actorless and correctly marked; these lanes are not —
merger, triage, reviewer, and the worktree paths all act on behalf of a known
agent, so marking them would have manufactured debt the census then had to
unwind.

Deriving meant threading rather than labelling. Nineteen helpers in merger.ts
and five in merger-ai.ts had the existing run context passed into them, three
optional context parameters became required where the only caller always has
one, and two contexts were hoisted to function scope so a lane's failure and
pause paths carry the same actor as its success path. Every lane label used is
the exact agent id that code path already stamps on its own run-audit rows, so
the task log and the audit stream now agree instead of one of the pair being
anonymous.

The remaining thirty markers are attributed by owner in the census: twelve
belong to the executor threading, eight to the auto-recovery family where the
only agent id in scope names the task being repaired rather than its author,
four to helpers shared with operator-facing routes, and six to sweeps and
residual fallbacks. Engine baseline moves 292 to 322.

The worktree acquisition helpers were deliberately left unthreaded: making
their option required would have forced executor.ts edits, so forty-five sites
resolve through one marker line each and the executor stage has two lines to
fix rather than forty-five.

Test assertions were extended rather than relaxed. A shared matcher pins that
a context carrying a resolved actor actually reached the store, so an
unthreaded call site still fails, and one arity pin was rewritten to keep its
real intent rather than dropped.

Engine suite holds at 54 failing files with no newly failing file or test, and
the failing-test count fell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts all 479 mutating call sites in executor.ts with zero markers, and
lowers the engine census from 322 to 308 by resolving the sites earlier stages
had attributed here.

The file has ~478 mutating calls and almost no run context in scope, so this
was never a call-site edit. The carrier already existed: currentRunContexts is
written once per run and cleared in the outer finally, reachable from every
frame. The defect was that its accessor is partial — off a live run it returns
undefined, which satisfied the deprecated overload and attributed nothing.
This adds a total accessor beside it.

The partial one deliberately stays partial. Twenty-two call sites use it as a
liveness probe via optional chaining, and collapsing the two forms would make
those probes unconditionally true — a behaviour change wearing a refactor's
clothes. Its fallback is derived rather than marked: the lane id is the same
one this file already stamps on its own run context and audit rows, matching
how the earlier lanes resolved theirs.

Behaviour is preserved by construction. Every production edit is an appended
trailing argument, an accessor rename in argument position, or a type-level
optional removal; no branch, guard, ordering, or early return was touched, and
the insertions came from a codemod that refused to run on any site it could
not append to safely. In the store, this context is attribution-only: it
enriches the log entry and selects the auditing write, and never gates a
transition.

The worktree acquisition options are now required, which is what the previous
stage deferred here rather than forcing forty-five edits to keep them optional.

Test assertions were extended, not relaxed, and five codemod edits were
reverted where the product genuinely still passes no context — leaving a
permissive matcher there would have made three negative assertions pass
vacuously. The new carrier test was mutation-checked to confirm it is not
vacuous itself.

Engine suite holds at 54 failing files and 377 failing tests, unchanged from
the parent commit, with zero newly failing files or tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… CLI

Completes U18 across all four packages. Dashboard converts 151 sites, CLI 67.
Census roots are now core 33, engine 308, dashboard 150, cli 2.

The dashboard is almost entirely marker, and that is the honest answer rather
than a shortcut: no run id, agent id, or lane label exists anywhere in the
package to derive from, because its actor is the authenticated session that
does not exist yet. Markers sit at the call site rather than aliased per file,
since the work that replaces them is per handler.

The CLI distinguishes three cases instead of collapsing them. An agent caller
derives a real actor. An operator or unregistered caller takes the marker, to
be replaced when the CLI gains its own credential. An ambiguous caller — two
live agent sessions sharing one directory, where the registry genuinely cannot
tell them apart — now refuses the write on eight task-mutating tools rather
than guessing. That is a safety stop, not deferred debt: crediting the wrong
one of two sessions is unrecoverable from the audit trail afterwards, and
delegation is the sharpest case because the actor is the delegating caller and
never the target agent.

Unregistered callers are deliberately NOT folded into that refusal even though
the secrets path does fold them. Unregistered means an ordinary human terminal
with no cwd registration, and refusing those would deny normal CLI use for
several phases before the CLI has a credential to offer.

Two more structural seams are closed. A relaunch listener re-declared three
store methods at narrow arity and received the real store through a double
cast, so nothing would ever have complained; a custom-fields route widened the
store inline the same way. Reverting either signature makes an unattributed
write compile clean again, which is what these seams were quietly permitting.

The CLI delete also stops claiming the bootstrap actor, which was asserting
real attribution for a caller that had none, and now reads its actor from the
same resolution the context carries so one delete cannot record two different
answers to who did it.

Assertions were extended rather than relaxed, including two negative ones that
had gone vacuous and would have passed even if the behaviour they forbid
occurred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… AE18)

project.actor_role_grants is the only table that says who holds authority, and it was
writable by every plugin. The chain is short and each link already existed: a plugin gets
the real TaskStore via PluginContext, createPluginGatedTaskStore deliberately does not deny
getAsyncLayer() (four in-repo plugins need it), that handle is the SAME pooled fusion_runtime
connection core uses, and migration 0006 sets ALTER DEFAULT PRIVILEGES on schema project
granting INSERT/UPDATE/DELETE to fusion_runtime — so this table was born plugin-writable.

RLS did not close it. fusion_project_isolation filters on project_id only, never on caller,
so a plugin writing a grant for its own project passes the policy cleanly. Net effect: a
plugin could INSERT itself an admin grant and every downstream can() check would then
honestly return allow.

0059 now revokes INSERT/UPDATE/DELETE on that table from fusion_runtime. SELECT is retained
deliberately — authorization reads grants over the runtime connection on every mutation, so
revoking reads would break enforcement rather than harden it. Grant writes must therefore run
on the owner/migration connection, which plugins never receive; that connection sets
fusion.project_bypass=on, so those writes must pass project_id explicitly (noted in the SQL).

Proven-failing control, per the plan's execution note: with the REVOKE removed the new test
fails with can_insert: true (escalation live); restored, it passes. The test pins
current_user=fusion_runtime first, because FORCE ROW LEVEL SECURITY applies to the owner too
and a passing RLS assertion does not prove the connection switched roles. Denials assert
PostgreSQL's 42501 code walked from the cause chain rather than message text: drizzle wraps
the driver error, and a bare rejects.toThrow() would pass on any failure at all.

The pre-existing isolation test now seeds over the owner connection, since its runtime-role
INSERT is exactly what this revoke forbids; it still proves RLS filters reads per project.

Verified: identity-schema + schema-applier 90 passed; core typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
can() answers "may this actor run this TOOL INVOCATION", deriving the permission from the
invocation's arguments. A mutating TaskStore method asks a different question: it knows its
permission statically (deleteTask is always tasks:delete), has no tool name, no argument
classification, and no approval channel to prompt on. authorize()/assertAuthorized() is that
second entry point, a single testable function per KTD14.

It reuses can()'s resolveHeldDisposition rather than reimplementing precedence — a fork there
drifts toward silently allowing, which is why that function is now exported instead of copied.

Four guarantees, each a way authorization stops working silently rather than visibly:
  1. Deny by default — an absent permission is block, never "unset".
  2. An unresolved actor is a denial, not a pass-through. Absence of an actor is the most
     likely state at an unconverted call site, so defaulting it to allow would make the seam
     report success while enforcing nothing. The unattributed marker counts as unresolved.
  3. Fail closed on error. If grant resolution throws, the answer is deny; an authorizer that
     treats "I could not tell" as "yes" is worse than none, because it looks like one. The
     thrown error is kept out of the decision, which flows into messages and audit metadata.
  4. require-approval is not allow. It drives real ApprovalRequest rows and this layer cannot
     run that protocol, so it refuses with its own reason — collapsing it into the absent-grant
     message would send an operator to fix a grant that is already correct.

Delegation intersects to the narrower disposition (R5), so delegated work can never exceed
what the delegator could do alone.

Tests: 22 passing. Every denial has a positive twin with one bit flipped, because a denial
assertion passes for free when the code never runs; where the denial must precede grant
resolution, resolveGrants is a spy returning ALLOW and the test asserts it was never called.
Mutation-verified: removing the unresolved-actor guard reds 3, failing open on resolution
error reds 2, treating require-approval as allow reds 1; restored, 22 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate was a DENYLIST: 8 named methods blocked, everything else on a 299-method TaskStore
passing through via Reflect.get. That inverts the security default — every method added to
TaskStore since it was written became silently plugin-callable, and pluginId was used only in
the throw message, never to decide anything.

Splitting by risk rather than hand-enumerating 299 methods, because an allowlist of ~180
writes rots immediately and its rot is invisible:
  - reads pass by verb prefix (115 methods; they cannot corrupt state)
  - writes are denied unless explicitly allowed — ~180 mutating methods that used to pass
    now throw. This is the branch that actually changes behavior.
  - destructive writes still additionally require permissions.destructiveTaskOps

The allowed-write list is derived from what the in-repo plugins actually call, not from what
seems reasonable: createTask, updateTask, moveTask, archiveTask, unarchiveTask, updateSettings,
plus init/close as lifecycle. All 14 methods the in-repo plugins use were verified to survive.

destructiveTaskOps no longer returns the RAW store. It previously short-circuited the whole
gate, so the most privileged plugin class was the one class with no deny-by-default at all;
it now widens the destructive set only.

Two limits kept honest in the comments rather than implied away: getAsyncLayer() is still
reachable (four in-repo plugins need it) and hands out a raw drizzle handle that runs SQL past
every rule here — 0059's REVOKE closes the grant-yourself-a-role escalation specifically, not
the handle in general; and reads can still leak, since this governs mutation, not confidentiality.

Two bugs the new tests caught in my own first cut, both of which fail far from their cause:
  - gating data properties replaced a value with a thrower, so store.someCounter stopped being
    7 and became a function nobody calls — corrupted data at the read site instead of an error
    at the call site. Only functions are gated now.
  - toString/valueOf/constructor were being denied, so String(store) and any log line or
    template literal containing the store threw an authorization error from code that never
    called a store method. Language intrinsics and symbols now pass through, along with `then`
    (a thenable proxy is a hang, not an error).

Tests: 19 passing (14 pre-existing + 5 pinning the inversion). The pre-existing binding test
was renamed to a read-prefixed method — it asserts `this`-binding, and deny-by-default refuses
an arbitrarily-named method before binding is reached, so it would have failed for a reason
unrelated to what it asserts. pnpm test:gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gsxdsm and others added 11 commits August 15, 2026 16:22
The function doc still said destructiveTaskOps returns the raw store unchanged. It now
widens the destructive set only, so a declaring plugin remains subject to deny-by-default
on the rest of the write surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ection (U5)

0059's REVOKE closed the plugin escalation but left every LEGITIMATE grant write on the
wrong side of it: grantActorRole, revokeActorRole, and the grant revoke inside setActorStatus
all write over layer.db, which is the fusion_runtime role that just lost the privilege. They
will fail with 42501 once identity is enabled. Harmless today only because nothing calls them
outside tests, and the actor-store suite builds its layer on the harness's OWNER connection,
so the failure is invisible there.

I tried routing them through PostgresConnections.migration and reverted it: that pool is
deliberately max:1, reserved for session advisory locks and session_replication_role, and a
transaction opened on it hangs — measured with a trivial SELECT 1, which timed out at 20s.
Single statements on it DO succeed, which is the trap: a partial fix moving only
grantActorRole would have looked correct while setActorStatus hung.

Recorded in code rather than left to memory, including the two hazards a real owner
connection brings, both silent:
  - it bypasses project isolation, and revokeActorRole carries no project predicate today —
    moved as-is it would revoke a role in EVERY project rather than the bound one
  - it is a separate connection, so setActorStatus cannot split its status write and grant
    revoke across handles without risking an actor recorded suspended while its grants stay live

Tests: 75 passing across the four identity suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vious diagnosis (U5)

CORRECTION. The previous commit claimed transactions on PostgresConnections.migration hang,
"measured with a trivial SELECT 1 timing out at 20s", and recorded that as a design blocker.
That was wrong. Re-probed across five pool configurations (max 1/5, prepare true/false, with
and without project_bypass) and through the real connection set: plain transactions, accessMode
transactions, bare statements, and privilegedTransactionImmediate via createAsyncDataLayer all
succeed. The original timeout was the known pg-test-harness flake — a cold golden-template
build against the 15s hookTimeout — which also timed out four UNRELATED tests in that same run
(a fresh-database test and the project_auth_* test among them). I attributed a harness flake to
a design flaw and stopped on it. The suite now runs 13 tests in ~5.7s across three consecutive
runs.

What this actually needed was the plumbing, which is what this commit adds:

AsyncDataLayer gains privilegedDb and privilegedTransactionImmediate, sourced from the owner
connection. 0059 revokes write on project.actor_role_grants from fusion_runtime — the role a
plugin's getAsyncLayer() handle connects as — so core needs a handle plugins never receive.

Both hazards of that handle are handled rather than documented away:
  - It bypasses project isolation, so RLS stops confining statements moved onto it.
    revokeActorRole carried NO project predicate and leaned on the policy; it would have
    revoked a role in every project. It now scopes project_id explicitly.
  - It is a separate connection, so setActorStatus runs its status write and grant revoke
    WHOLLY on it. Splitting them across handles gives two transactions, and a failure between
    leaves an actor recorded suspended while its grants stay live.

Side effect worth naming: this makes revokeActorRoleGrants do what it always documented —
drop an actor's authority in EVERY project. Under RLS on a project-bound runtime connection it
only ever reached the bound one, so its stated intent had never been true.

Tests: 13 in identity-schema (3 new), 145 across the wider identity/schema suites, gate green.
The grant test asserts the identical write over the runtime handle is refused with 42501, so a
regression back to layer.db cannot pass it — the existing actor-store suite could not catch
this because it builds its layer on the harness's owner connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…KTD20/KTD22)

Declares identityEnabled in DEFAULT_GLOBAL_SETTINGS and GlobalSettings, which is what makes
isProjectSettingsKey("identityEnabled") false — a project-scoped patch cannot carry the key, so
no project can locally disable enforcement.

The scope IS the enforcement, not a preference. One daemon serves N projects from a shared
database while actors and sessions are global, so a per-project switch would let an actor denied
in project A operate freely in project B, where every caller resolves to the bootstrap actor with
full authority. Nothing would throw; authorization would just quietly stop applying in one project.

Defaults to false so existing installs behave exactly as today until U16 turns it on. can()
short-circuits to allow while off, so only the CHECK is bypassed — attribution still runs.

Tests (7): scope is asserted against the exported GLOBAL/PROJECT key registries, not only the
predicates — the predicates read from those arrays, so a key added to BOTH lists would satisfy
isGlobalSettingsKey while still being accepted on a project patch, which is the actual failure
mode. AE16 is covered by the partition pair: an actor with settings:update but not
identity:configure is denied, with a positive twin proving that same actor can still edit ordinary
settings, so the denial is about the partition and not a deny-everything fixture. A final test
flips one identical input across the gate in both directions, because a gate stuck either way
passes half the identity suite.

Pre-existing failure noted, not caused here: builtin-workflow-settings-triage fails identically
with these edits reverted (memoryConsolidationEnabled drift in BUILTIN_OVERSIGHT_SETTINGS).

Verified: 153 identity tests across 13 files, 422 settings tests, typecheck, lint, gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… R15/R17)

assertAuthorized answers "may this actor do X". This table supplies X at the store seam, so a
mutation cannot be guarded with whatever permission happened to be in scope at the call site.

Scope stated plainly rather than implied: TaskStore exposes ~299 methods, ~184 mutating. This
covers the surface the plugin gate already classifies — destructive methods plus the writes
plugins may perform — NOT all 184. A deliberate first tranche; the census is what keeps it
honest, failing CI when a method joins either gate list without a permission.

Mapped to the narrowest permission that is true:
  - archiveTask is tasks:archive, not tasks:delete — archive is reversible via unarchiveTask,
    and collapsing them forces an operator to grant deletion authority for board hygiene
  - bypassFailedPreMergeReviewStep is runtime:review-gate-bypass, so tasks:merge can be granted
    without it
  - getDatabase is runtime:file-write-delete, not a tasks:* entry — its blast radius is arbitrary
    SQL, and mapping it to tasks:delete would let ordinary task-deletion authority reach the
    whole database
  - init/close are an explicit NAMED exclusion, not an implicit gap: they are lifecycle, and
    gating them would fail an unauthenticated read path at construction

The census also rejects a permission that is not in the catalog, which is worse than a missing
one: resolveHeldDisposition deny-by-defaults forever on a permission no role can hold, so a
typo reads as "correctly locked down" rather than as a bug.

Mutation-verified, because a census that cannot fail is a rubber stamp and reports "0 unmapped"
whether the map is complete or the scan is dead: dropping one destructive mapping reds 3 tests,
pointing a mapping at a non-catalog permission reds 3; restored, 6 pass. One test asserts the
scan actually inspects the gate lists rather than reporting an empty set.

Verified: 6 census tests, typecheck, lint, gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's Lint job fails on `0 errors, 2 warnings`, so these two pre-existing no-empty warnings
block every PR, not just this one. Both landed on main today (FN-8990 plugin reload cleanup,
FN-9039 voice runtime recheck). Locally `eslint .` exits 0 on the identical output, which is
why they were not caught before push.

no-empty ignores a block containing a comment, so each catch now states why the throw is
swallowed rather than being silenced with a directive:
  - plugin-loader: the rm runs inside `finally`, so throwing there would replace the import's
    real error (or success) with an unlink failure — a leftover temp file beats losing the
    reason a plugin failed to load
  - VoiceInputSection: `loadStatus()` in `finally` re-reads authoritative runtime state, so it
    reports the true outcome regardless; surfacing this error too would show a failure toast
    beside a panel that just refreshed correctly

Verified: pnpm lint exits 0 with zero problems; core and dashboard-app typechecks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… stack

Wire the identityEnabled master switch, close three unattributed store
writes, and make the attribution ratchet actually able to fail.

- daemon: sync `identityEnabled` into the process flag on startup and on
  `settings:updated`. The setting persisted and `setIdentityEnabled` existed,
  but nothing connected them, so turning identity on left every gate on its
  disabled allow-branch while the UI read "on".
- ANY_MUTATION_CONTEXT matched the actor id with `expect.any(String)`, which
  accepts "system:unattributed" — the exact value the ratchet exists to catch.
  Tightened, and given its own proven-failing controls. This exposed 15 real
  unattributed writes across 11 suites that the loose matcher was hiding; each
  now asserts UNATTRIBUTED_CONTEXT_MATCHER so the gap is countable instead of
  silently green.
- workflow-merge-boundary: the merge move was the one write in that file still
  using the context-free shape, so it audited as system/unknown while the log
  entries on either side carried the real actor.
- captureBaseCommitSha: both callers omitted the context they already resolve
  for every other write on the path.
- Repaired two FNXC comments that an earlier bulk conversion rewrote *inside*
  the comment body — one of them corrupted the historical record of the FN-7335
  bug it exists to explain.
- external-checkout guard: pinned as one exact source line, so adding an
  argument broke it without violating the invariant. Now format-tolerant while
  still rejecting reordered args, a dropped arg, and non-adjacent gates.

Verified against a pre-change baseline of the same tree: 77 failed files /
258 failed tests before, 77 / 263 after, with no file newly failing that this
change touches. `pnpm test:gate`, `pnpm lint`, and CLI typecheck are green.
The one new suite-only failure is unmodified by this change, passes in
isolation, and is recorded in the observed-flakes register.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dep type for `rebaseNewWorktreeOntoRemote` stopped at `settingsOverride`,
so the sole production caller could not pass a run context even though
`createWorktree` resolves one for its other writes. Its skip, fetch, success,
and failure breadcrumbs went through `safeLog` with `runContext === undefined`.

The parameter's own doc comment claimed it was "REQUIRED so an unwired caller
is a compile error, not a silent unattributed write" while being declared
optional — and the one caller was in fact unwired, which is precisely the
failure that claim promised to prevent. Wired the caller and rewrote the
comment to describe the code: it stays optional because the U18 conversion
keeps the context optional until the final stage makes it required everywhere
at once.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…igration number (review)

- Plugin gate had two escapes *around* the `get` trap, so no amount of
  read/write classification caught them. `constructor` was on the intrinsic
  passthrough list, so `store.constructor.prototype.deleteTask.call(store, ...)`
  reached a denied method having never entered the trap — one property read
  defeated the whole gate. And a Proxy with only a `get` trap forwards `set`,
  `defineProperty`, and `deleteProperty` to the target, so a plugin could
  monkey-patch a denied method into an undenied one. The mutating traps throw
  rather than returning false: a silent false is only observable in strict mode
  and reads as success everywhere else.

- `parsePasswordHash` accepted any positive integer for N/r/p, and `maxmem` is
  derived from those parsed values — so the stored string decided how much
  memory verification allocates. A row claiming N=2^30 asks for ~128 GiB and
  takes the process down at login. Bounded, with N required to be a power of
  two (scrypt requires it anyway, so this turns a throw inside the crypto call
  into a clean unparseable-hash result).

- The identity migration was referenced under three numbers (0047/0059/0060)
  after it was renumbered twice around main's own migrations. `0060` is
  canonical; `schema-applier.test.ts` still asserted `0059` and is executable,
  so it was failing. Those assertions are what catch the next collision, so
  they must track the real filename.

Core typecheck and lint clean; gate, credential, schema, census, and authorize
suites green (22 + 64 tests).

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gsxdsm
gsxdsm force-pushed the feature/user-accounts branch from b9c0321 to dcf3397 Compare August 15, 2026 23:22
gsxdsm and others added 16 commits August 15, 2026 21:51
…and non-atomic rotation (review)

Five more #3429 findings, each verified against code first:

- CRITICAL: `findApiKeyByLookupId` applied no project predicate while both its
  siblings scope on `projectId`. On a shared cluster a token minted in project A
  resolved against project B's store and verified there — the HMAC check that
  follows passes, because it is the SAME key row. Scoping is what makes a token
  mean "valid here" rather than "valid somewhere".

- `evaluateGrantAuthority` enforced `agentGrantable` on the GRANTOR kind only.
  KTD20 says no agent may ever HOLD such a permission, so a human legitimately
  holding `roles:grant` + `identity:configure` could hand `identity:configure`
  to an agent — nothing about the grantor's own authority is violated, which is
  why grantor-side checking could never catch it. `targetKind` is now required:
  an omitted target kind silently disables the check, and there are no
  production callers yet, so a compile error is the cheapest time to catch it.
  The regression test was confirmed to fail with the target clause removed.

- Session rotation revoked the predecessor and inserted the successor as two
  statements. A failure between them left the operator holding a revoked session
  with no replacement, and rotation is triggered by a privilege change — so the
  failure mode was "role updated, user logged out permanently, no retry can
  recover". Both `rotateSessionForPrivilegeChange` and `startSession` now own a
  transaction when the caller did not supply a handle.

- `suspendActor`/`tombstoneActor` forwarded a layer without
  `privilegedTransactionImmediate` to `setActorStatus`, which needs it because
  0060 revokes grant writes from `fusion_runtime`. The member is optional, so it
  compiled and failed at runtime with 42501 instead.

- `maxmemFor` used a factor of 256 while the constant's doc called 192 MiB the
  operating value and a test asserted the 128 floor as if it were the factor.
  The factor is now `SCRYPT_MAXMEM_FACTOR`, defined once.

Note for reviewers: core's tsconfig excludes `*.test.ts`, so tests are NOT
typechecked — the existing `evaluateGrantAuthority` call sites were passing
`targetKind: undefined` and silently skipping the new check until updated here.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bel (review)

- `deletedBy` read `auditContext` only while the audit row beside it preferred
  `runContext`. When the two carried different agents, one delete produced two
  different actors in two places and a consumer reading the lifecycle payload
  disagreed with the audit trail. Same precedence in both.

- An evidence-free handoff used the BOOTSTRAP actor, which means specifically
  "a pre-enablement internal write from before identity existed". The write was
  always unattributed; it was filed under a historical category that made both
  it and real bootstrap writes unreadable. It now carries the unattributed
  marker, which is what that state is for.

- Census baseline raised with the cause recorded. +5 is a MERGE artifact: main
  added store call sites to engine/dashboard files this branch had already
  converted, and the merged result carries both sides. +1 is the deliberate
  relabel above. The ratchet exists to make growth visible and explained, so
  the movement is documented at the baseline rather than hidden by excluding
  the files.

Full core suite green: 554 files, 5666 tests.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`mergeRunContext` closed with `});` instead of `};` — a leftover paren from
when the object was hoisted out of an inline `createRunAuditor(...)` argument.
The file did not parse, so `check-capacity-pool-id` reported it as
"unparseable: a tracked source file could not be parsed, so it was NOT
inspected" and the merge gate failed. An unparseable file is worse than a
failing one: every static validator silently skips it.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase of this branch dropped or mangled pieces of the U18 run-carrier
threading. Repairs, grouped by what was actually broken:

- merger-ai: `landWorkspaceTask` lost its `const audit = createRunAuditor(...)`
  binding when the rebase collapsed it into the hoisted context literal (9
  errors); a required `runContext` followed an optional `fence` (TS1016); and a
  `runContext` argument had been moved INTO a trailing `.catch()` instead of the
  `updateTask` it belongs to.

- `runContextFor`, the U18 total carrier, lost its supply chain: three
  deps-bags projections stopped copying it, two source types stopped declaring
  it, and four consumer `Pick<>` types omitted it while their bodies called it.

- Attribution reverted at several writes, which lint and the gate caught as
  symptoms: `captureBaseCommitSha` passed a raw optional context (so an
  omitting caller wrote `undefined`), the remote-rebase breadcrumbs went back to
  a literal `undefined`, a workspace landed-sha write lost its carrier, and four
  `moveTask` rehome calls lost theirs.

Two notes on judgement calls:

- The two lint "unused parameter" errors were NOT lint noise. In both cases the
  parameter was unused precisely because the rebase had deleted the write that
  consumed it, so the real defect was an unattributed mutation and the unused
  argument was its shadow.

- One assertion in `executor-graph-requeue-gate` still pinned the pre-U18
  three-argument `moveTask` shape while its siblings in the same file asserted
  the context. It was updated to match rather than the production call being
  left unattributed to satisfy it.

Engine typecheck 0 errors, `pnpm lint` clean, `pnpm test:gate` fully green
(432 + 184 + 10 + 72).

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ibution on both sides

Eight conflicts, all the same shape as before: main changed behaviour on lines
U18 had converted to carry a mutation context. Every resolution keeps BOTH.

- self-healing.ts (8 hunks) / self-healing tests: main added
  `preserveWorktree: true` to the rehome moves; the carrier is re-applied on
  top of main's options rather than either side winning.
- merger.ts: main added a `MergeAbortedError` branch that logs its own
  breadcrumb. Took main's structure and attributed BOTH log writes — attributing
  only the pre-existing one would have left the new abort path unattributed the
  day it landed.
- cli task tests / routes-github: main added `{ invokeTaskCreatedHook: false }`
  in argument slot 2 and tightened the approved-fingerprint expectation; the
  carrier belongs in slot 3, so both are asserted.
- step-execute-skill-loading: kept main's new `forcedSkillNames` assertion
  alongside the attributed `logEntry` form.
- The flakes register is append-only by nature: both sides appended different
  observations and dropping either would lose a recorded sighting, which is the
  one thing that register exists to prevent. Both kept.
- Removed a duplicate `UNATTRIBUTED_MUTATION_CONTEXT` import the merge
  re-introduced in register-task-workflow-routes.ts.

core/engine/dashboard typecheck 0 errors; `pnpm lint` clean. Two `@fusion/core`
module-resolution errors remain in `plugins/fusion-plugin-{cursor,hermes}-runtime`
— this branch changes zero plugin files and those arrived from main in FN-9098,
so they are inherited, not introduced here.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ge assertions

Follow-on to the main merge:

- main added a `[skills] [executor]` summary `logEntry` to run-implementation
  after U18 had converted that file, so it arrived unattributed. Attributed like
  every other write on the path.

- Five self-healing assertions came from main against the pre-conversion
  two-argument shape and were updated to assert the unattributed marker (which
  is what those self-healing writes legitimately carry). The matcher had to be
  imported — without it the "fix" silently compared against `undefined`, so the
  import is what makes these assertions real rather than vacuous.

`pnpm lint` clean; `pnpm test:gate` green (432 + 184 + 10 + 72).

Known remaining: two cases in `step-execute-skill-loading.test.ts` fail with
`mockedStepSessionExecutor` never invoked. They are NOT attribution-related —
they still fail with the carrier added, no error is logged, and both are main's
own tests for FN-9114 ("expose enabled skills and enforce agent skill reads")
whose shape is unchanged from main. Recorded rather than papered over.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The earlier merge (97f1252) resolved all eight conflicts correctly but was
committed with a single parent: a `git stash push --keep-index` / `pop` run
mid-merge (while checking whether a typecheck error was environmental) cleared
MERGE_HEAD, so `git commit` wrote an ordinary commit instead of a merge. The
content was right and verified; only the ancestry was missing, which is why
GitHub kept reporting #3428 as CONFLICTING and 75 commits behind.

This commit carries the SAME tree — verified byte-identical to HEAD^{tree} — and
adds origin/main as the second parent. No file changes, no re-resolution: the
conflict resolutions already reviewed and tested are exactly what ships.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolve PR 3428 conflicts by keeping the identity/actor/authorization work
and threading it through main's current signatures.

- Renumber identity actors schema 0061 -> 0067 after main shipped 0061-0066
- Union project table registry (actor_role_grants + workspace/lease tables)
- Keep FN-9175 bounded run-audit; do not restore engine recordRunAuditEvent
- Drop resurrected split-into-subtasks paths deleted on main
- Thread RunMutationContext through main's branchWriteOrigin, review
  convergence, workspace, and self-healing recovery updates
Catch up the seven commits that landed on main during conflict
resolution. Keep branchWriteOrigin provenance assertions from main
and identity mutation-context matchers from the PR.
… two run-context designs

61 conflicting files / 133 hunks. This merge was different in kind from the
earlier ones: main has grown its OWN run-context threading, so most hunks were
two overlapping implementations of the same idea rather than unrelated edits.

Decisions worth stating:

- Where main used the PARTIAL getter (`getRunContextFor`, returns undefined
  off-run) and this branch used the TOTAL carrier, the total form wins. Main's
  shape compiles but writes an unattributed row whenever the task is momentarily
  off-run, which is the hole U18 exists to close.

- Migration renumbered 0061 -> 0067. Upstream claimed 0060 (workspace leases),
  0061-0064 (FN-066..FN-094), 0065 (FN-149) and 0066 (memory focus) while this
  branch was in review. A released number is canonical, so identity moves —
  keeping 0061 would make upgraded databases SKIP the identity tables, since the
  ledger already records 0061 as applied. Ceiling, registrations, doc references
  and the two version assertions all move together; `identity-schema.test.ts`
  was NOT conflicted and had silently kept 0061, which is exactly the drift the
  earlier Critical finding was about.

- FN-074 removed task splitting and parent deletion upstream, taking
  `TaskDeleteClosureContext` with it. This branch still carried that plumbing;
  the feature's owner deleted it, so the `closureContext` option, its engine
  call site and its test follow rather than being revived.

- `store.ts`: main inlined a widened `updates` literal where this branch had
  replaced it with named types behind the staged overload pair. The named types
  absorbed main's seven new fields instead, so both main's fields and the
  required-context staging survive.

- `server.ts` auth: main's shape assumed a token is always present. Taking it
  would have deleted this branch's fail-closed else — no token and no `--no-auth`
  must REFUSE to serve /api/*, not serve it unauthenticated. Main's
  remote-session validator is folded into the token branch instead.

- Test assertions main retired (an unreachable log line after FN-9157; the
  comment-text assertions its new static validator forbids) are removed rather
  than re-asserted, and its tightened expectations are adopted with the carrier
  re-attached.

Also fixed in passing: a zero-width space in `check-no-comment-assertions-in-tests.mjs`
(inherited from main) that failed `no-irregular-whitespace`; it is replaced with an
escaped delimiter, which keeps the prose meaning without an invisible character.

core/engine/dashboard typecheck 0 errors; `pnpm lint` 0 errors;
`pnpm test:gate` green (16 validators, 203 + 433 + 10 + 72).

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sus floor

- schema-applier ledger assertions: identity's renumber to 0067 needed adding as
  the new head, and the import-union during conflict resolution had dropped
  CHAT_SESSION_MEMORY_FOCUS_VERSION (0066) from five expected-version lists.
  Both restored, in ledger order.

- Unattributed-actor census floor LOWERED 499 -> 481 (engine 310 -> 307,
  dashboard 153 -> 138). The drop is real: main's own run-context threading
  replaced marker sites with derived actors, and FN-074's removal of task
  splitting took several more with it. Lowering the floor in the same commit is
  what stops the reclaimed ground being given back.

Core suite: 591/592 files green (6141 tests). The one failure,
`agent-prompts.test.ts > executor prompt variants block workflow moves`, is
INHERITED: both the source and its test are byte-identical to origin/main here,
and the test still fails when run against main's own copies of both files.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The concurrent automation merged origin/main into feature/user-accounts while
this session was resolving the same 220-commit merge locally, so both sides
carry a resolution of the SAME upstream change and 31 files conflicted.

Resolved by provenance rather than by side: only ONE conflicted file
(`routes-tasks.test.ts`) carries work unique to the remote — its "repair the
dashboard suite" commit — so that file takes the remote's version. The other 30
are two resolutions of the same upstream diff, and this side's are the ones that
were reviewed hunk-by-hunk and verified (typecheck, lint, gate), so they stand.
The remote's non-conflicting unique commits (FN-9204, the Stash memory docs
index, the rest of the dashboard repair) merge in untouched.

core/engine/dashboard typecheck 0 errors; lint 0 errors; gate green
(16 validators, 433 + 203 + 10 + 72).

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keep both intents: identity/actor authorization at migration 0067 (main's
ceiling is 0066) and main's flake-register entries, comment-assertion
wording, runtime subscribe compatibility, and i18n/auth-status stubs.

Un-nest identity apply from the 0066 memory-focus gate so upgraded main
databases still receive actor tables, and drop the duplicate
workflow_agent_capacity_leases registry entry.
…about

`executor prompt variants block workflow moves` asserted the FN-125 outcome (no
"you may still set the workflow on tasks you create" carve-out) while resolving
the prompt with NO tool surface. `taskCreateToolAvailable` defaults to available,
so an unspecified surface asks for the creator-capable persona — where that
carve-out is CORRECT. The test was describing one surface and resolving another.

FN-125 made the clause conditional on the resolved tool surface; it did not
delete it, and the sibling test asserts it still renders for a creator-capable
persona. Both variants here now resolve with both creation tools withheld, which
is the task-execution surface the test's own comment is about. The stale comment
claiming outright deletion is corrected.

The source is unchanged and correct: the executor's only production caller
(`system-prompt.ts`) always passes the real availability, so an unspecified
surface was a test artifact rather than a reachable state.

Confirmed the test still discriminates — forcing the clause to render
unconditionally fails it (and its sibling), so this is not a vacuous pass.

Census floor: engine 307 -> 312. Reconciling with the remote branch's merge
brought in five self-healing writes carrying the marker; that is the correct
carrier there (automation with no human actor), so they are counted, not
converted.

Core suite now fully green: 592/592 files, 6142 tests. Gate green.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… whitespace fix

Clean merge, no conflicts. Notable: upstream fixed the same
`no-irregular-whitespace` failure in `check-no-comment-assertions-in-tests.mjs`
independently (#3518), and its fix — rewording the prose so it never names the
block-comment delimiters — is better than the escaped form used here and
supersedes it without conflict.

core/engine typecheck 0 errors; lint 0 errors; gate green (16 validators,
433 + 203 + 10 + 72); census, agent-prompts and schema-applier suites green.

Fusion-Task-Id: FN-8821

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant