Add project memory billing-plan switch

Task 6670 · Done · Coder · created · updated

Epic 6659: Project memory, open the pipe, honest trust. This is child C0 (subtask S0). C1–C5 depend on it. Source documents, as the epic's settled artifacts: PRD (BA Task 6660) §3.0 and Architecture (Task 6661) §1 C0, §2.1, §2.2. Read them with `task_read_artifact` on Tasks 6660 and 6661 if you need more depth. The audit report is attached to the epic as `project-memory-audit-2.html`. ## Original requirement (verbatim) Operator ask: "File all 5 as an epic. It is important that all memory related mechanism stays behind the feature flag on the billing plan." "## Constraint that binds every subtask: a billing-plan feature switch Today project memory is gated only by operator knobs (`runner_project_memory` kill switch, `project_memory_default` org-tier default, `project_memory_holdout_percent`, `memory_llm_credential_id` in `apps/api/app/operator_settings.py`; resolution in `apps/api/app/project_memory/switch.py::effective_memory`). There is NO plan switch: `billing_plans` carries `compliance_enabled`, `nodevault_allowed`, `nodelink_allowed`, `merge_lane_enabled`, but nothing for memory, and `app/plan_terms.py` has no memory term. Subtask 0 (first, the others depend on it): add `project_memory_enabled` as a billing-plan switch with the standard per-org override, following the existing plan-switch recipe (mirror commit 544a3f27d / `merge_lane_enabled`: plan column + `orgs.<name>_override`, additive migration, `plan_terms` SWITCH_DEFAULT_KNOBS / OPERATOR_PLANE_SWITCHES / PlanTerms / effective, operator_settings default knob, `routes/billing_plans.py`, `apps/web/src/lib/planSwitches.tsx`, plan editor UI). `effective_memory` must return `(False, "plan")` when the org's plan term is off, ahead of task/epic/holdout/default; `memory_active` must read the same term so a running task loses memory within the settings-cache window when the plan flips. Every memory mechanism — prompt section and hook bundle, `memory_search`/`memory_read`/`memory_propose`/`memory_feedback`, settle votes, the curate/nightly crons' work for that org's projects, the node helper's background jobs, the Project › Memory tab — must be inert for an org whose plan term is off, answering the existing `MEMORY_OFF_DETAIL` refusal where a runner tool is involved. Default: off on every plan except where the operator turns it on. Each later subtask ships its change inside this gate and adds a test proving the mechanism is inert with the term off." ## Acceptance criteria (verbatim, PRD §3.0) - **S0-1 Plan column and override exist, and the default is off.** `billing_plans.project_memory_enabled` (not null, default false) and `orgs.project_memory_enabled_override` (nullable) are added by an additive migration. Every existing plan row reads false after migrating. The knob `plan_default_project_memory_enabled` defaults to false. The switch is in `SWITCH_DEFAULT_KNOBS`, `OPERATOR_PLANE_SWITCHES`, `PlanTerms` and `effective`, and it is cleared like the others on a plan change. - **S0-2 Editable in the console.** The Settings › Billing › Plans › (plan) dialog shows a "Project memory" switch that saves and reloads. The Org › Plan tab shows a "Project memory" override row (On, Off, or follow the plan) that saves, is audited in the org history, and is allowed for an operator-plane org. Operator Settings › Billing › Plans shows the default knob. Labels follow `docs/standards/ui-copy-standards.md`: one or two words, never truncated, and the same height as the sibling switches at every supported viewport width. - **S0-3 Resolution order.** `effective_memory` returns `(False,"killed")` when the kill switch is off, then `(False,"plan")` when the org's term is off, and only then consults task, epic, holdout and default. A task or epic set to ON cannot override a plan OFF. - **S0-4 A running task loses memory when the term flips off.** `memory_active` reads the same term. After the flip, within the knob cache (3 s), plus the node's runner-flags/bundle refresh (60 s) for node-side surfaces: `memory_search`/`memory_read`/`memory_propose`/`memory_feedback` answer 409 `MEMORY_OFF_DETAIL`; settle votes are not recorded; `memory.ingest`/`memory.report` are refused; the bundle answers 410 and the node empties it, so no `[memory]` line appears. - **S0-5 Background and console are inert with the term off.** For projects of an org whose term is off, curate (promote, recheck, corroborate, mine, distill, embed) and nightly (retrust, demote, decay, consolidate, citation promotion, graduation filing) change no unit, write no vote and file no task. The data is kept unchanged, not deleted. The Project › Memory tab is not rendered for such a project. Its routes (`/api/projects/{id}/memory/*`) answer 403 with a short plan message, the same refusal class as other plan switches. Flipping the term on restores the tab with the data intact. - **S0-6 Available and ON on dev for this project.** The default stays off, as the operator asked. After S0 deploys, the S0 coder turns the term on through the dev console for the org owning project `jaah-ai/jaah`, using the org Plan tab override, so S1–S5 can be verified on dev and the epic's re-measure has data. - **S0-7 Inert-test contract.** S1–S5 each add at least one test that sets the term off and asserts the subtask's new behaviour does nothing. - Cross-cutting **Q1** No UI text is truncated. **Q2** Every runner-facing refusal names its cause in plain words. **Q3** Single-home: limits and weights are named constants in `apps/api/app/project_memory/vocabulary.py`. ## Approach (Architect §1 C0; settled, do not reopen) - **Recipe.** Follow commit `544a3f27d` hunk by hunk. mem-14220 and mem-17928 record the recipe; verify them against `plan_terms.py:340-466`. The `merge_lane_enabled` pattern lives at `models.py:6640` and `:493`, `operator_settings.py:574-580`, `routes/orgs.py:1704/1827/1880` with `org_plan_admin.py`, `BillingPlanDialog.tsx`, `PlanTab.tsx:83`, and the Operator Settings knob row. `cleared_overrides`/`clear_overrides` take the label "Project memory". - **Arm source "plan".** Add `"plan"` to `vocabulary.ARM_SOURCES`. The SAME migration widens the `tasks.memory_arm_source` CHECK constraint (built from `ARM_SOURCES`, `models.py:2390`) by drop-and-recreate. Add `plan: 'plan'` to the label map in `apps/web/src/pages/audit/ProjectMemoryPanel.tsx:43`. `comparison.py` treats a `plan` arm like `killed`, outside the A/B population; test it. This is the epic's only migration. If a rebase forces a revision-id renumber, see mem-11867. - **The single gate, all in `project_memory/switch.py`.** `PLAN_SWITCH = "project_memory_enabled"`; `memory_plan_on(session, org_id)` via `plan_terms.switch_on`; `effective_memory` resolves killed → plan → task → epic → holdout → default; `memory_active(session, task)` changes signature to take the session first and adds `memory_plan_on`; `memory_project_ids(session) -> Select` reads each org once through the `_switch` semantics, with no SQL re-spelling. Run `lsp_find_references` before the signature change. Known callers: `routes/agent_memory.py:177`, `routes/agent.py:5524`, `capture.py:76` (`require_on(session, task)` at `:154`, `:424`). - **Gate points:** - The bundle `_memory_in_use` (`routes/agent_memory_bundle.py:43-49`) adds `memory_plan_on` and answers 410. - `curate.run_tick` computes `scope = memory_project_ids(session)` once and passes `scope=` to mine `:218`, promote `:464`, recheck `:576`, corroborate `:681`, distill `distill.py:134`. - `nightly` passes the same `scope` to `promote_citations`, `retrust`, `demote_unused`, `decay`, consolidation, `complete_graduations`, graduation candidates and the slice list. - The console routes `routes/project_memory.py:206-277` take a shared dependency `require_memory_plan(project)` → 403 "Project memory isn't included in this org's plan.", shaped like `routes/credentials.py:437` `_not_in_plan`. - `routes/auth.py:601` `_PAYLOAD_SWITCHES` gets `project_memory_enabled`. - `planSwitches.tsx` gets the `PlanSwitch` union member and `FEATURE_NAME` "Project memory". - `ProjectEditPage.tsx` `tabsFor` shows the Memory tab only when `planSurfaceShown(admin, 'project_memory_enabled')`. - Hide the Project memory field in `TaskSpecFields.tsx` and `project_memory_default` in `OrgSettingsTab.tsx:58` when `planSwitchOn` is false. - An operator viewing an off-plan tenant's project sees a plain notice in the Memory tab ("Not in this org's plan" plus a link to Org › Plan) in place of the error Banner when feed-status answers 403. - **Seams other children use (pinned, Architect §2.1/§2.2).** - `switch.memory_active(session, task)` is the only per-request reader. `switch.memory_project_ids(session)` is the only background-scope reader. Every curate/nightly step takes the `scope` keyword. - Test fixture `plan_off_project(session)` in `apps/api/tests/project_memory/conftest.py`: a project whose org has `project_memory_enabled_override = False`. - `vocabulary.RESTORABLE_REASONS` is created as an empty frozenset. - `votes.restorable_injectable(unit) -> bool` returns true when trust ≥ `TRUST_FLOOR`, kind ≠ codemap, and feed ∈ `INJECTABLE_ON_ACCEPT_FEEDS`. It gets a unit test. C2 builds the restore branch that calls it, and C4 adds a reason. - **S0-6 dev enablement.** After Jaah Deploy succeeds, sign in to `https://app.dev.jaah.ai` as the operator and set Org › Plan › Project memory to On for the org owning `jaah-ai/jaah`. The PR body must state that memory goes dark on deploy for every org, and that prod stays dark until the operator does the same on `app.jaah.ai` at promotion. Shared edit points (rebase expected; C1–C5 touch them next): `switch.py`, `vocabulary.py`, `votes.py`, `curate.run_tick`, `nightly` step signatures, and `routes/agent_memory.py`. Credentials & reach (verbatim rows from Architecture §4): - All unit/API/web tests, `./dc check full`, and the "local stack" oracles | Nothing beyond the worktree and its `./dc` stack's Postgres | verified: worktree present, gate runs in-slot. **Decided:** the "local stack" oracles run as API integration tests. They drive the real agent routes through the FastAPI test client against the slot DB, with a test-minted node caller. `jaah-call` targets the node's control plane (dev/prod), never the `./dc` stack (`apps/jaahd/README.md`), so the live leg is the dev runner check. - Dev console checks (S0-2, S0-4, S0-5, S0-6), operator sign-in on `https://app.dev.jaah.ai` | `jaah/debug/main/operator-login` (operator; its description says it works on dev and main, and it covers the non-admin `gmind/skynet/dev*` logins) | verified: on the roster, `task_credential_get` succeeded, and `GET https://app.dev.jaah.ai/api/health` returned 200 from h6-3. - Tab-absent check for a tenant (S0-5 dev) | `gmind/skynet/dev-orgb` (tenant admin, org B, term off by default) on `https://app.dev.jaah.ai`. Its stored base_url is stale (mem-7834) | verified: on the roster, `task_credential_get` succeeded, and the dev health probe returned 200. - PR merge and Jaah Deploy watch (E1) | `jaah-forge` (authenticated per task) | verified: a read-only `jaah-forge pr view 5590` from h6-3 returned `state: merged`. - Prod memory enablement | Operator action on `app.jaah.ai` at promotion | deferred: promotion to master is human-gated, so this is an operator act and out of scope. Out of scope: - From the epic, verbatim: "configuring the production memory model credential (an operator action in Settings › Runners › Project memory, not code); retiring mem-2/mem-3 and purging mem-7834 (operator does it in the console); any change to the OMC notepad/project-memory tier; new embedding providers." - From PRD §4: do NOT change `memory_llm_credential_id` resolution. Do NOT change the existing knobs' meaning: `runner_project_memory` stays the global kill switch ahead of the plan, and `project_memory_default`/`project_memory_holdout_percent` are unchanged below it. Make no destructive migration, and delete no stored memory data when the term is off. Do not change the codemap feed's exclusion from prompts and search (Task 6297). Make no change to non-memory context-router directives or to other plan switches' behaviour. - Do NOT implement S1–S5 behaviour here: no canon release, vote weighting, settle-evidence, proposal clamp or hook-tier changes. C0 only creates the empty `RESTORABLE_REASONS` and the `restorable_injectable` predicate. Done means: - S0-1 — the migration test (pattern of `test_migration_202610122400_ci_runners_switch.py`) asserts the column default is false and existing rows read false. `tests/test_plan_terms.py` covers override, then plan, then the knob for a planless org, each resolving as expected. The same migration widens the `memory_arm_source` CHECK, with a test inserting `plan`. - S0-2 — web tests cover `BillingPlanDialog`, `PlanTab` and `planSwitches`. On dev: open Settings › Billing › Plans, edit a plan, toggle Project memory, save, reopen, and observe it persisted. Open the org owning `jaah-ai/jaah` › Plan, set the override On, and observe the history line. Labels are readable and untruncated at every supported viewport width. - S0-3 — a unit test runs a matrix of kill switch × term × task/epic/holdout/default and asserts the tuple for every combination. - S0-4 — an API test arms a task, flips the term off, and asserts: 409 `MEMORY_OFF_DETAIL` on all four tools, zero votes after settle, and refusal on ingest/report. A bundle-route test asserts 410. On dev, set the jaah-ai org override to Off. From a memory-armed runner, call `memory_search` and observe "Project memory is off for this task". Set it back On. - S0-5 — a curate+nightly test with two projects (one org ON, one OFF) asserts the OFF project's rows are byte-identical after a tick and a nightly run, and no graduation task is filed. Gating only the runner tools FAILS this criterion. A route test asserts 403. A web test asserts the tab is absent for a tenant, and that an operator sees the plain plan notice rather than an error banner. On dev, as `gmind/skynet/dev-orgb`, open a project and observe no Memory tab. As the operator, switch the jaah-ai override On and observe the tab with its units intact. - S0-6 — on dev, Org › Plan shows Project memory "On (override)", and a newly dispatched runner in project 1 shows a PROJECT MEMORY section. - S0-7 — the `plan_off_project` fixture exists, and `restorable_injectable` and `memory_project_ids` have unit tests. - `comparison.py` treats a `plan` arm like `killed`, with a test. - E1 — the PR is merged to develop through auto-merge with `./dc check full` green, and the Jaah Deploy run succeeded (or was superseded). The PR body names the dark-on-deploy and prod-enablement note.

Outcome

Project memory now behind a billing-plan switch (off by default); every memory mechanism inert when off. Full gate green, PR #5601 merged, deploy OK, dev override ON for Gmind. Dev runner legs unverified (tests cover); follow-up draft #6688. Used mem-2.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)