Skip to main content

Rate-History Baseline (#2413) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Every assignment-rate write maintains a contiguous rate history (baseline on create, change rows on every reprice path), plus a backfill, so the ops line-item preview stops 400ing on never-repriced assignments.

Architecture: Spec at docs/superpowers/specs/2026-06-11-rate-history-baseline-design.md. New AssignmentRateHistoryWriter injectable owns all history writes; three call sites delegate to it; one data migration backfills empty-history assignments.

Working directory: ~/.config/superpowers/worktrees/humanwork/rate-history-baseline/api, branch fix/2413-rate-history-baseline. Commits end with Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>.


Task 1: AssignmentRateHistoryWriter (new injectable + unit spec)

Files:

  • Create: src/organizations/assignment-rate-history.writer.ts

  • Create: src/organizations/assignment-rate-history.writer.spec.ts

  • Modify: src/organizations/organizations.module.ts (add provider + export)

  • Step 1: failing spec. The writer takes the repo as a parameter, so the spec uses a tiny in-memory fake repo (rows array; findOne by where-match on assignmentId+effectiveDate; save upsert by id; createQueryBuilder is NOT used — see Step 3 design note). Tests:

import { AssignmentRateHistoryWriter } from "./assignment-rate-history.writer";

type Row = {
id?: string; assignmentId: string; effectiveDate: string; endDate: string | null;
rateConfig: any; changedByUserId: string | null; changeReason: string | null;
};

class FakeHistoryRepo {
rows: Row[] = [];
async find(opts: any) {
const { assignmentId } = opts.where;
return this.rows.filter((r) => r.assignmentId === assignmentId);
}
async findOne(opts: any) {
const { assignmentId, effectiveDate } = opts.where;
return this.rows.find((r) => r.assignmentId === assignmentId && r.effectiveDate === effectiveDate) ?? null;
}
create(r: Row) { return { ...r }; }
async save(r: Row) {
if (!r.id) { r.id = `h-${this.rows.length + 1}`; this.rows.push(r); }
else { this.rows = this.rows.map((x) => (x.id === r.id ? r : x)); }
return r;
}
}

const RATE = { base_rate: 2200, currency: "USD" };

describe("AssignmentRateHistoryWriter", () => {
const writer = new AssignmentRateHistoryWriter();

it("seedBaseline inserts an open-ended row at the assignment date", async () => {
const repo = new FakeHistoryRepo();
const ok = await writer.seedBaseline(repo as any, {
assignmentId: "a1", assignedAt: new Date("2026-06-11T09:00:00Z"), rateConfig: RATE, userId: "am-1",
});
expect(ok).toBe(true);
expect(repo.rows).toEqual([expect.objectContaining({
assignmentId: "a1", effectiveDate: "2026-06-11", endDate: null,
rateConfig: RATE, changedByUserId: "am-1", changeReason: "baseline (assignment created)",
})]);
});

it("seedBaseline skips when no rate config resolves", async () => {
const repo = new FakeHistoryRepo();
const ok = await writer.seedBaseline(repo as any, {
assignmentId: "a1", assignedAt: new Date(), rateConfig: null, userId: "am-1",
});
expect(ok).toBe(false);
expect(repo.rows).toEqual([]);
});

it("seedBaseline is idempotent on (assignment, date)", async () => {
const repo = new FakeHistoryRepo();
const input = { assignmentId: "a1", assignedAt: new Date("2026-06-11T00:00:00Z"), rateConfig: RATE, userId: "am-1" };
await writer.seedBaseline(repo as any, input);
await writer.seedBaseline(repo as any, input);
expect(repo.rows).toHaveLength(1);
});

it("recordChange closes the prior open row and inserts the new one", async () => {
const repo = new FakeHistoryRepo();
await writer.seedBaseline(repo as any, {
assignmentId: "a1", assignedAt: new Date("2026-06-01T00:00:00Z"), rateConfig: RATE, userId: "am-1",
});
await writer.recordChange(repo as any, {
assignmentId: "a1", effectiveDateIso: "2026-06-20",
rateConfig: { base_rate: 2500, currency: "USD" }, userId: "am-2", reason: "renegotiated",
});
const baseline = repo.rows.find((r) => r.effectiveDate === "2026-06-01")!;
const change = repo.rows.find((r) => r.effectiveDate === "2026-06-20")!;
expect(baseline.endDate).toBe("2026-06-19");
expect(change.endDate).toBeNull();
expect(change.rateConfig.base_rate).toBe(2500);
expect(change.changeReason).toBe("renegotiated");
});

it("recordChange upserts in place when re-issued at the same effective date (no self-close corruption)", async () => {
const repo = new FakeHistoryRepo();
await writer.recordChange(repo as any, {
assignmentId: "a1", effectiveDateIso: "2026-06-20", rateConfig: RATE, userId: "am-1", reason: null,
});
await writer.recordChange(repo as any, {
assignmentId: "a1", effectiveDateIso: "2026-06-20",
rateConfig: { base_rate: 2500, currency: "USD" }, userId: "am-1", reason: null,
});
expect(repo.rows).toHaveLength(1);
expect(repo.rows[0].endDate).toBeNull();
expect(repo.rows[0].rateConfig.base_rate).toBe(2500);
});
});
  • Step 2: run, watch it fail (npm test -- assignment-rate-history.writer.spec → module not found).

  • Step 3: implement. Design note: the existing inline close-out uses createQueryBuilder to find the prior open row; the writer instead uses repo.find({ where: { assignmentId } }) + in-memory filter for the open row — equivalent for this table's tiny per-assignment cardinality and keeps the fake repo trivial. Implementation:

import { Injectable } from "@nestjs/common";
import { Repository } from "typeorm";
import { OrgSpecialistAssignmentRateHistory } from "../onboarding/onboarding.entities";
import { MonthlyRateConfig } from "../common/entities";

/**
* #2413 — single owner of all writes to org_specialist_assignment_rate_history.
*
* Invariant maintained: per assignment, a contiguous timeline from the baseline
* (assignment creation) onward, with exactly one open row (end_date IS NULL)
* holding the currently-effective rate.
*
* Methods take the repository as a parameter so call sites can pass a
* transaction-scoped repo (em.getRepository) or the module-injected one.
*/
@Injectable()
export class AssignmentRateHistoryWriter {
/**
* Insert the open-ended baseline row at assignment creation.
* Returns false (no write) when no rate config resolves — "no rate
* configured" must stay detectable, not be seeded as a zero rate.
* Idempotent on (assignment_id, effective_date).
*/
async seedBaseline(
repo: Repository<OrgSpecialistAssignmentRateHistory>,
input: {
assignmentId: string;
assignedAt: Date;
rateConfig: MonthlyRateConfig | null;
userId: string | null;
},
): Promise<boolean> {
if (!input.rateConfig) return false;
const effectiveDate = input.assignedAt.toISOString().slice(0, 10);
const existing = await repo.findOne({
where: { assignmentId: input.assignmentId, effectiveDate },
});
if (existing) return false;
await repo.save(
repo.create({
assignmentId: input.assignmentId,
effectiveDate,
endDate: null,
rateConfig: input.rateConfig,
changedByUserId: input.userId,
changeReason: "baseline (assignment created)",
}),
);
return true;
}

/**
* Record a rate change: close the prior open row at effectiveDate − 1 and
* upsert the new open row. Extracted from updateSpecialistAssignment's
* inline runHistoryWrite (#631) so every reprice path shares one
* implementation. The close is skipped when the prior open row IS the row
* being re-upserted (same effective date) — closing it first would write
* end_date < effective_date into the row we are about to re-open.
*/
async recordChange(
repo: Repository<OrgSpecialistAssignmentRateHistory>,
input: {
assignmentId: string;
effectiveDateIso: string;
rateConfig: MonthlyRateConfig;
userId: string | null;
reason: string | null;
},
): Promise<void> {
const rows = await repo.find({ where: { assignmentId: input.assignmentId } });
const priorOpen = rows
.filter((r) => r.endDate === null)
.sort((a, b) => (a.effectiveDate < b.effectiveDate ? 1 : -1))[0];

if (priorOpen && priorOpen.effectiveDate !== input.effectiveDateIso) {
const prevEnd = new Date(`${input.effectiveDateIso}T00:00:00Z`);
prevEnd.setUTCDate(prevEnd.getUTCDate() - 1);
priorOpen.endDate = prevEnd.toISOString().slice(0, 10);
await repo.save(priorOpen);
}

const existing = await repo.findOne({
where: { assignmentId: input.assignmentId, effectiveDate: input.effectiveDateIso },
});
const row =
existing ??
repo.create({
assignmentId: input.assignmentId,
effectiveDate: input.effectiveDateIso,
endDate: null,
});
row.rateConfig = input.rateConfig;
row.endDate = row.endDate ?? null;
row.changedByUserId = input.userId;
row.changeReason = input.reason;
await repo.save(row);
}
}

Register in organizations.module.ts: add AssignmentRateHistoryWriter to providers (and exports only if another module needs it — none does today; skip export).

  • Step 4: npm test -- assignment-rate-history.writer.spec → PASS. npm run build → clean.
  • Step 5: commit feat(orgs): AssignmentRateHistoryWriter — single owner of rate-history writes (#2413).

Task 2: wire the three call sites

Files:

  • Modify: src/organizations/organizations.service.ts (constructor injection + assignSpecialist + updateSpecialistAssignment)

  • Test: extend src/organizations/trial-realign-hook.spec.ts-style focused spec — Create: src/organizations/rate-history-seeding.spec.ts

  • Step 1: failing spec (Object.create(OrganizationsService.prototype) pattern; spy on the writer):

Tests (construct svc with svc.rateHistoryWriter = { seedBaseline: jest.fn(), recordChange: jest.fn() }, plus the minimum repos assignSpecialist touches — read the method first; stub specialistRepo/catalog resolution by spying on private helpers where cheaper):

  1. create-branch: after assignSpecialist creates a NEW assignment with dto.monthlyRateConfig, seedBaseline called with the assignment id + that config.
  2. create-branch, no dto rate but Specialist has catalog rate: seedBaseline called with the catalog config.
  3. existing-assignment branch with changed dto.monthlyRateConfig: recordChange called (reason "rate changed via re-assign"), seedBaseline NOT called.
  4. existing-assignment branch without dto.monthlyRateConfig: neither called.

If full assignSpecialist stubbing is too heavy (it touches catalog materialization, billing hooks, gsuite provisioning), extract the seeding decision into a small private method _writeAssignmentRateHistory(saved, dto, amId, wasCreate) and unit-test THAT with the wiring asserted by one cheaper integration-ish test — report which route was taken.

  • Step 2: implement.
    • Inject private readonly rateHistoryWriter: AssignmentRateHistoryWriter in the service constructor (it's a long constructor — append; NestJS resolves by type).
    • In assignSpecialist, after the upsert save resolves (saved), add:
    // #2413: every assignment-rate write maintains the rate-history invariant.
// Create → open-ended baseline at the resolved rate (assignment override,
// else catalog). Re-assign with a changed rate → change row (this path
// previously wrote NO history at all). Not fail-open: these are cheap local
// writes upholding a data invariant.
const wasCreate = !existingAssignmentBeforeUpsert; // capture before the upsert branch
if (wasCreate) {
await this.rateHistoryWriter.seedBaseline(this.rateHistoryRepo, {
assignmentId: saved.id,
assignedAt: saved.assignedAt ?? new Date(),
rateConfig: saved.monthlyRateConfig ?? specialist.monthlyRateConfig ?? null,
userId: amId,
});
} else if (dto.monthlyRateConfig !== undefined && rateChangedOnReassign) {
await this.rateHistoryWriter.recordChange(this.rateHistoryRepo, {
assignmentId: saved.id,
effectiveDateIso: new Date().toISOString().slice(0, 10),
rateConfig: saved.monthlyRateConfig!,
userId: amId,
reason: "rate changed via re-assign",
});
}

where rateChangedOnReassign is computed in the existing-assignment branch by comparing the prior monthlyRateConfig JSON to the new one (same JSON.stringify comparison style as updateSpecialistAssignment) — capture const priorConfigJson = JSON.stringify(assignment.monthlyRateConfig ?? null) before mutating. Adapt names to the actual code (the upsert branch local is assignment; whether it was found is known at the if (assignment) fork — hoist a const wasCreate = !assignment; there). NOTE the 409-race path (uq_osa_org_catalog_slug winner reuse) sets saved to the pre-existing winner — treat that as NOT create (no baseline seed; the winner's create already seeded).

  • In updateSpecialistAssignment.runHistoryWrite, replace the inlined close-out + upsert block with:
      if (rateChanged && effectiveDateIso) {
await this.rateHistoryWriter.recordChange(rateHistoryRepo, {
assignmentId: persisted.id,
effectiveDateIso,
rateConfig: persisted.monthlyRateConfig as MonthlyRateConfig,
userId: amId,
reason: dto.reason ?? null,
});
}

keeping the surrounding transaction wrapper and runHistoryWrite(assignmentRepo, rateHistoryRepo) signature intact.

  • Step 3: new spec green; npm test -- src/organizations ALL green (updateSpecialistAssignment behavior must be preserved — its existing specs are the regression net); build clean.
  • Step 4: commit fix(orgs): seed rate-history baseline on assign; record re-assign rate changes (#2413).

Task 3: backfill migration + line-item preview regression test

Files:

  • Create: migrations/1799000000002-BackfillRateHistoryBaseline.ts (verify ls migrations | sort | tail -3 — bump if taken)

  • Test: src/billing/application/line-item-calculator.service.spec.ts (extend)

  • Step 1: migration (Postgres-only guard like 1796000000003):

import { MigrationInterface, QueryRunner } from "typeorm";

/**
* #2413: seed baseline rate-history rows for assignments that have none, so the
* ops line-item preview works for never-repriced assignments. Assignments that
* already have history are deliberately NOT touched — their pre-first-change
* rate is unknowable and inventing one would be false data.
*/
export class BackfillRateHistoryBaseline1799000000002 implements MigrationInterface {
name = "BackfillRateHistoryBaseline1799000000002";

public async up(queryRunner: QueryRunner): Promise<void> {
if (queryRunner.connection.options.type !== "postgres") return;
await queryRunner.query(`
INSERT INTO org_specialist_assignment_rate_history
(id, assignment_id, effective_date, end_date, rate_config, changed_by_user_id, change_reason)
SELECT gen_random_uuid(), a.id, a.assigned_at::date, NULL,
COALESCE(a.monthly_rate_config, s.monthly_rate_config), NULL, 'backfill #2413'
FROM org_specialist_assignments a
LEFT JOIN specialists s ON s.id = a.specialist_id
WHERE NOT EXISTS (
SELECT 1 FROM org_specialist_assignment_rate_history h WHERE h.assignment_id = a.id
)
AND COALESCE(a.monthly_rate_config, s.monthly_rate_config) IS NOT NULL
ON CONFLICT (assignment_id, effective_date) DO NOTHING
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
if (queryRunner.connection.options.type !== "postgres") return;
await queryRunner.query(
`DELETE FROM org_specialist_assignment_rate_history WHERE change_reason = 'backfill #2413'`,
);
}
}

Check the actual jsonb column name for specialists' rate config (s.monthly_rate_config) against the Specialist entity before committing — adjust if the column is named differently.

  • Step 2: line-item preview regression test. In line-item-calculator.service.spec.ts, find the existing fixture pattern (it stubs the rate-history and assignment repos) and add: an assignment whose history contains ONLY a baseline row (open-ended, from assigned_at) → getRateWindows(assignmentId, periodStart, periodEnd) returns one window clipped to the period at the baseline rate, and calculateLineItems yields one full-period line item. (This is the end-state the seeding+backfill produce; it passes with current calculator code — a regression pin, not TDD red.)

  • Step 3: npm test -- line-item-calculator.service.spec green; npm run build clean.

  • Step 4: commit feat(orgs): backfill baseline rate-history rows for never-repriced assignments (#2413).


Task 4: full verification + PR

  • npm test (full suite) — all green.
  • npm run lint — no new errors in touched files.
  • Push fix/2413-rate-history-baseline, PR against dev titled fix(orgs): rate-history baseline on assignment creation + backfill (#2413), body summarizing the invariant, the three call sites, the re-assign gap found during design, the deliberate non-backfill of repriced assignments, and verification numbers. PR body ends with the Claude Code attribution line.