# WS8 Teaching Loop Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. **Goal:** Turn the dead, scaffolded teaching path into a real but safe feedback loop that improves the brain from aggregate reviewer craft, never from any single org or candidate, and never auto changes a score. **Posture (founder decision, locked):** In tenant LIVE, cross tenant differential privacy OFF. The in tenant producer and the human approved curation pipeline ship LIVE (the brain improves from this tenant's own reviewer craft, with two human steps before anything affects a future score). The cross tenant federated and differential privacy step ships fully built but DISABLED behind `MM_FEDERATED_DP_ENABLED` (default false), inert until five or more live non test tenants exist and a privacy budget is signed off. **Architecture:** A pure in tenant producer reads `slate_members.decision` and `candidate_evaluations` inside `RunInTenantTx` and folds reviewer outcomes into the existing closed `scrub.TeachingSignal` vocabulary. A deterministic proposal generator turns aggregated craft into `curation_proposals` that a human approves, then a SECOND human action verifies, before any curated resolver edge goes live. A separate `internal/platform/federated` package holds the pure Go differential privacy accountant and aggregator, dead unless the flag is set. The frozen deterministic engine math is never touched. **Tech Stack:** Go 1.26, chi, pgx/v5, sqlc v1.31.1, goose v3.24.1, shopspring/decimal, FIPS 140-3 (`GOFIPS140=v1.0.0`, `CGO_ENABLED=0`), no Python. **Branch:** `ws8-teaching-loop` off `master` (master is the merged grounding stack at 8c692ce). Migrations from `0028`. Validate on M4 `brain_staging` only, never production. --- ## Hard invariants (every task) 1. **The honest score invariant (load bearing).** WS8 never changes a weight, a band cut, the 0.62 `DefaultSemanticThreshold`, the engine `ModelVersion` math, or any already served and audited score. It CAN, only after TWO human steps, ADD a curated `skill_relationships` edge that the resolver reads (`aliasresolver.go:223` loads `verified_at IS NOT NULL` substitutes_for and broader_than edges into the engine `SkillMatcher`), which changes FUTURE scores. State it this way everywhere; do not claim it never affects a score. 2. **Two step human in the loop via `verified_at`.** The proposal apply path inserts `skill_relationships` with `verified_at NULL` (STAGED and INERT, because the resolver ignores unverified edges). A SECOND distinct compliance or operator action sets `verified_at` to activate the edge across tenants. Record the proposal approver and approved_at on the proposal, and a separate verifier and verified_at on the edge. Nothing is ever auto applied; nothing auto rejects. 3. **Test org exclusion is real.** A contributing tenant is a live, non test, distinct organization. The cross tenant cohort count (and the `MinTenants` gate) must exclude the roughly 1088 test orgs, sourced from a production flag (the Mission A `tenant_directory` is the natural source; until present, an explicit allow list). Record the actual contributing tenant count in `dp_budget_ledger` and the model card. Because a real cohort of five does not exist today, the cross tenant path stays flag off. 4. **`model_version` from `engine.ModelVersion` only, never a literal** (it is `v1.4.0` today), in the producer and the aggregator. `scrub` is `internal/platform` and MUST NOT import the `engine` module; pin the version in the producer (which may import engine) and add a registered version seam in `scrub` (a settable allow list, or require non empty) so a drifted version is refused at the boundary. Add a uniqueness constraint on `(tenant, pattern, band, outcome, model_version, window)` so a forked version is visible, not silent. 5. **Audit split.** Per tenant events (`teaching.signal.aggregated`) run through `audit.Writer.OnTx` inside the same `RunInTenantTx` that read the decisions, so they commit with the read and carry the correct org. Cross tenant and global events (`federated.dp.released`, `teaching.proposal.created`, `teaching.proposal.approved`, `ontology.edge.added`) run through `audit.Writer.System` (NULL org). No event carries PII, a candidate id, or a raw score. 6. **App role cannot write global tables.** The API request path uses the app pool only for RLS scoped tenant reads. Every write to a GLOBAL migrator owned table (`curation_proposals` insert, the `skill_relationships` apply, the `verified_at` activation, `dp_budget_ledger`, `teaching_signal_aggregates`) goes through a separate migrator pool (`MM_MIGRATOR_DATABASE_URL`), the `cmd/tag-missions` two pool precedent, never to bypass tenant RLS. The `/compliance` approve and verify actions carry a migrator scoped write gated behind the compliance or operator role. 7. **Frozen engine + EEO + FIPS + no Python + no dashes.** Re run `golden_test.go`, `frozen_test.go`, `safety_test.go` unchanged and prove byte identical engine numbers. No WS8 file references the eeo schema. Build `cmd/teach-aggregate` with `CGO_ENABLED=0 GOFIPS140=v1.0.0`. No dashes in any comment, log, UI copy, or migration COMMENT, except URLs, CLI flags, identifiers. No Python, no new crypto dependency (noise, when activated, uses only in module primitives). 8. **v1 is the scalar count channel only.** Defer the 384 dim embedding centroid (it widens the attack surface and breaks the closed struct tripwire). Keep `scrub.TeachingSignal` closed. --- ## File Structure - Create `internal/modules/ai/teaching.go` + `_test.go`: the in tenant producer (LIVE). - Create `internal/platform/federated/federated.go` + `accountant.go` + `_test.go`: the pure Go DP accountant and aggregator interfaces (FLAG OFF dead code). - Create `db/migrations/0028_dp_budget_ledger.sql`, `0029_teaching_signal_aggregates.sql`, `0030_curation_proposals.sql`. - Create `db/queries/teaching.sql`: the sqlc queries (per tenant aggregate upserts, proposal CRUD, the apply and verify paths). - Create `cmd/teach-aggregate/main.go`: the FIPS stamped cadence job. - Modify `internal/platform/scrub/scrub.go`: the registered model version seam and a non empty model_version requirement. - Modify `internal/platform/config/config.go`: `MM_FEDERATED_DP_ENABLED` (default false), `MM_FEDERATED_DP_EPSILON`, `MM_FEDERATED_DP_DELTA`. - Modify the compliance handler and the `/compliance` page: surface pending proposals, the approve action, and the separate verify action. - Create the model card / DP notice doc + a CI reachability gate test. --- ## Phase 0: config and the boundary seam ### Task 0.1: config flags and scrub model_version seam - [ ] Add `MM_FEDERATED_DP_ENABLED` (default false), `MM_FEDERATED_DP_EPSILON` (default 10), `MM_FEDERATED_DP_DELTA` (default 1e-7) to config, alongside the existing `TeachingEnabled` flag. Express epsilon and delta as config, never code constants. - [ ] In `scrub.go`: require `ModelVersion` to be non empty in `Validate` (it currently only length caps). Add a settable registered version allow list (for example `var registeredModelVersions = map[string]bool{}` plus `func RegisterModelVersion(v string)`), and have `Validate` refuse a `ModelVersion` that is non empty but not registered, the same way an unknown `Pattern` is refused. `scrub` must NOT import the engine module. Extend `scrub_test.go` to cover the non empty and unregistered cases. - [ ] Decide and document: v1 is the scalar count channel only; the centroid is deferred (do not add a float field to `TeachingSignal`). --- ## Phase 1: the in tenant producer (LIVE) ### Task 1.1: the producer **Files:** Create `internal/modules/ai/teaching.go`, `internal/modules/ai/teaching_test.go`. - [ ] Write `TeachingProducer` with a method `AggregateForOrg(ctx) (Result, error)` that, inside `RunInTenantTx` on the app pool, reads the org's `slate_members.decision` joined to `candidate_evaluations` and folds reviewer outcomes into the four `scrub.Patterns`: - an approved candidate who covered a required skill through an adjacent or semantic match increments `adjacent_skill_substitution_outcome` with `Band` from the cosine bucket and `Outcome` from the decision; - a clearance gap override a reviewer approved increments `clearance_gap_override_outcome`; - an experience shortfall a reviewer approved increments `experience_shortfall_outcome`; - reranker agreement increments `reranker_label_agreement`. - [ ] Set `ModelVersion` from `engine.ModelVersion` (import engine here, this is the ai module), and call `scrub.RegisterModelVersion(engine.ModelVersion)` at init so the boundary accepts it. - [ ] Call `inference.Client.Teach(ctx, signal)` for each aggregated signal (it has zero non test callers today; this exercises `scrub.Marshal` end to end). `Teach` still does not transmit while `TeachingEnabled` is false, so this is safe. - [ ] Write the per tenant aggregate into `teaching_signal_aggregates` with `tenant_id` set and a `dp_applied = false` marker (the live in tenant rows; the flag on path later writes `tenant_id NULL, dp_applied = true` cross tenant rows). This write goes through the MIGRATOR pool (global table), not the app role. - [ ] Emit `teaching.signal.aggregated` through `audit.Writer.OnTx` on the same tenant tx, no PII, no candidate id, counts only. - [ ] Unit test the folding logic with no DB (inject a fake reader returning fixture decisions). Integration test the RLS scoped read on staging (controller runs it). --- ## Phase 2: the pure Go DP accountant (FLAG OFF dead code) ### Task 2.1: the federated package **Files:** Create `internal/platform/federated/federated.go`, `accountant.go`, `federated_test.go`. - [ ] Define `Aggregator` (`Clip`, `Noise`, `Emit`) and `Accountant` (`Charge(release) (epsilonNow, error)`, `Remaining()`) interfaces. - [ ] Implement the Gaussian mechanism RDP or zCDP accountant in pure Go: per release RDP is closed form `alpha / (2 * sigma^2)`, composition is summation over a fixed Renyi order grid, RDP to `(epsilon, delta)` is the closed form min over orders. Account WITHOUT subsampling amplification (a small fixed GovCon tenant set); document the worst case. Grid math in float64; record the spent epsilon in the ledger as `shopspring/decimal` for a bit stable audit record. - [ ] HARD test the accountant: per release RDP, composition monotonicity (epsilon never decreases as releases accumulate), and the RDP to `(epsilon, delta)` conversion, against hand computed closed form values for a few `(sigma, C, delta)` points AND against published Google differential privacy accountant numbers QUOTED AS FIXED CONSTANTS in the test (never by calling Python). - [ ] Mark the Mironov safe discrete or snapped Gaussian sampler a HARD activation blocker (a documented `// TODO activation blocker` is acceptable while the flag is off; the float sampler must never run on an activated path). - [ ] The whole package is dead unless `MM_FEDERATED_DP_ENABLED` is set; no non test caller exists yet. --- ## Phase 3: schema and the hard spend gate ### Task 3.1: migrations - [ ] `0028_dp_budget_ledger.sql`: global, migrator owned, app SELECT, outside RLS (the 0025 grant pattern). Columns: `model_version`, `tenant_id` (nullable uuid), `rdp_orders` jsonb, `accumulated_rdp` numeric, `epsilon_at_delta` numeric, `delta` numeric, `contributing_tenant_count` int, `released_at` timestamptz. Tested down. - [ ] `0029_teaching_signal_aggregates.sql`: global, migrator owned, app SELECT. Columns: `tenant_id` (nullable, set for live in tenant rows, NULL for cross tenant post DP rows), `dp_applied` bool, `pattern`, `band`, `outcome`, `model_version`, `observations` int, `successes` int, `window_start` date, `window_end` date. UNIQUE `(coalesce(tenant_id,'00000000...'), pattern, band, outcome, model_version, window_start)` so a forked version is visible. Tested down. - [ ] Wire `Charge` to refuse a release once the ledger budget is spent, a hard server side gate, never client trusted. Every release is a distinct audited `federated.dp.released` event through `audit.Writer.System`. --- ## Phase 4: deterministic curation proposals, human in the loop (LIVE) ### Task 4.1: proposals + two step apply - [ ] `0030_curation_proposals.sql`: global, migrator owned, app SELECT plus migrator apply. Columns: `id`, `kind` (`substitutes_for_edge` | `reciprocity_review` | `recalibration_evidence`), `from_skill_id`, `to_skill_id`, `relation`, `proposed_strength` numeric, `evidence_observations` int, `evidence_successes` int, `model_version`, `status` (`pending` default | `approved` | `rejected`), `approver` uuid, `approved_at`, `created_at`. Never auto applied. Tested down. - [ ] Write the deterministic proposal generator (LIVE path reads the per tenant `teaching_signal_aggregates` rows where `dp_applied = false`; the flag on path reads the post DP `tenant_id NULL` rows). A repeated `adjacent_skill_substitution_outcome` above a curated threshold proposes a `substitutes_for_edge`; a repeated `clearance_gap_override_outcome` proposes a `reciprocity_review`; agreement counts become `recalibration_evidence` for WS4 (evidence only, never an automatic threshold change). Emit `curation_proposals` with `status = pending`. Audit `teaching.proposal.created` through `audit.Writer.System`. - [ ] The apply path (migrator pool, gated behind the compliance or operator role): on human APPROVE, set `status = approved`, `approver`, `approved_at`, and for a `substitutes_for_edge` insert a `skill_relationships` row with `verified_at NULL` (STAGED and INERT). Audit `teaching.proposal.approved`. - [ ] The verify path (a SECOND distinct compliance or operator action, migrator pool): set `verified_at = now()` and the verifier on the staged `skill_relationships` row, activating it across tenants. Audit `ontology.edge.added` carrying `from_skill_id`, `to_skill_id`, `relation`, `strength`, verifier. The engine `ModelVersion` stays `v1.4.0`; note in the model card that the resolver reference set grew. - [ ] Surface pending proposals on the `/compliance` page with the approve action and the separate verify action. - [ ] Add a test proving a newly inserted `verified_at NULL` edge is INERT (the resolver ignores it, scores are unchanged) and that only after verify does the resolver read it. --- ## Phase 5: cron, flag gating, CI, model card, validation ### Task 5.1: cmd, CI gate, docs, validation - [ ] Write `cmd/teach-aggregate/main.go` (`CGO_ENABLED=0 GOFIPS140=v1.0.0`, the FIPS self test gate like `cmd/missionclass-sync`): runs the in tenant producer for each org (app pool reads, migrator pool writes), and the proposal generator. The cross tenant DP aggregation runs ONLY when `MM_FEDERATED_DP_ENABLED` is set AND the contributing tenant count (live non test orgs) is at least `MinTenants`; otherwise it is skipped and logged. At most one signal plus one DP release per `(tenant, pattern, model_version, window)`. Add to the Makefile build list and the FIPS stamp gate. - [ ] Add the CI reachability gate test asserting no federated emission path (no `inference.Teach` transmission, no `dp_budget_ledger` release) is reachable when `MM_FEDERATED_DP_ENABLED` is unset, mirroring the X-Org-ID build tag backdoor removal test. - [ ] Author the model card / DP notice for the model registry and `/compliance`: unit of privacy tenant level, the `(epsilon, delta)` target, the accountant, the explicit central DP trusted server threat model (the Go API tier is in the TCB and sees raw per tenant vectors before noising, inside one cloud boundary), the TEE or SecAgg upgrade seam, the contributing tenant definition excluding test orgs, and the honest score invariant (a verified edge changes future scores, never an already served score, never the engine math). - [ ] Run the dash sweep over every new comment, log string, UI string, and migration COMMENT. - [ ] Re run `golden_test.go`, `frozen_test.go`, `safety_test.go` and prove byte identical engine numbers. Validate on M4 `brain_staging` from the production head, gated and rollback ready. No production step in this workstream. --- ## Self review - Posture B: Phases 1 and 4 ship LIVE (in tenant producer + human approved curation with the two step verified_at gate). Phases 2, 3, and the cross tenant aggregation in Phase 5 ship FLAG OFF behind `MM_FEDERATED_DP_ENABLED`. - Every concern from the WS8 adversarial review is encoded: the honest score invariant (1), two step verified_at (2), test org exclusion (3), model_version from engine (4), audit split (5), migrator pool for global writes (6), frozen engine and EEO and FIPS and no dashes (7), scalar only v1 (8), DP accountant hard tests and the Mironov sampler activation blocker (Phase 2), the spend gate (Phase 3), the gateway re run as an activation gate not a v1 deliverable (model card). - The frozen engine is never modified. The only score affecting change is an additive resolver edge that two humans must approve then verify, recorded as a model input change.