feat(platform-api): PR 3 placement discovery matching logic + graph/policy integration (in specialist)

Core router in placements: proposePlacementOffer (anonymized pre-consent, basic policy+capacity matching + gated mocks for prospects/graph/coop/N/Y/surface_metrics), evaluateIncomingOffer + correction lens.
Writes to agent_actions for every decision. Added /placement/propose + /evaluate internal routes (MCP ready).
Updated placement-market.contract.md with full tables + PR3 notes.
Added placement funnel metrics to strategist surface.
Cites: placement-market.brief.md (PR 3), contract.md, DESIGN.md §5, CLAUDE.md, 0001/0005/0012, per execute-plan PR 3 + brief/contract.

Per execute-plan/7e4262a8-pr-3-placement-discovery-matching-logic-graph-policy-integration (base b5b5d94 pr-2).
This commit is contained in:
Natalie 2026-06-27 17:04:56 -04:00
parent d10815deb1
commit 7742ccd94c
11 changed files with 436 additions and 34 deletions

View file

@ -8,12 +8,17 @@
The reader of patterns. Speaks in plans, not actions.
## Does
Reads recent `engagement_events`, `content_posts` performance, `agent_actions` history, prospect funnel data, **`metric_aggregates` (per-user cross-surface rollups)**, **`prospect_touchpoints` (cross-surface attribution chains)**, and **per-surface `surface_metrics`** (per [_engineering-surface-metrics.md](./_engineering-surface-metrics.md)). Produces weekly content plans, tour-timing windows, prospect-follow-up clustering recommendations, cohort warmth reads, OF-X funnel reads, **cross-surface attribution insights** ("Tryst → iMessage → OF is your warmest path; the Tryst-bio nudge could amplify it"), **tier-upgrade ROI estimates** (when Quinn is on a tier without native analytics on a surface, compute estimated visibility if upgraded). Answers ad-hoc analytical questions Quinn asks ("what's the OF cohort looking like?", "what did Tryst contribute this month?", "which model attributes Tryst lowest?").
Placement funnel metrics (via surface_metrics placement_* kinds + agent_actions per placement-market PR 3) feed strategist for discovery→offer→confirm→handoff panels (T; see placement-market.contract.md Outputs + brief PR 7). Added in PR 3 to metrics surface.
## Auto
Nothing. Pure proposal layer.
## Proposes
- Weekly content plans (per surface) for Quinn to approve.
- Tour timing windows ("Berlin warmer in early Oct than mid-Sep based on prior tours").
- Prospect-follow-up clusters ("12 OF subs cooled since Berlin tease — send a follow-up cluster Friday").
@ -24,9 +29,11 @@ Nothing. Pure proposal layer.
- **Tier-upgrade prompts** when computed metrics suggest a surface's hidden contribution (e.g. "Your Tryst search rank is unmeasured at Basic — upgrade to Standard for ~$47/mo would unlock 30-day analytics and probably reveal $X in Tryst-attributed revenue").
## Never
Posts. Sends DMs. Mutates anything. Acts directly on a surface. Engages a prospect. **Writes to `surface_metrics`, `metric_aggregates`, or `prospect_touchpoints`** (read-only on all three — write authority lives at surface adapters + `prospect-resolver`).
## Correction lens
- Bad reads of cohort warmth.
- Missed seasonality.
- Wrong-frame analytics (looking at the wrong cohort, wrong window).
@ -35,9 +42,11 @@ Posts. Sends DMs. Mutates anything. Acts directly on a surface. Engages a prospe
- **Over-weighting unmeasured surfaces** in narratives (e.g. claiming "X is 30% of your reach" when X's analytics aren't tier-enabled; strategist must use `—` not estimates in those cases).
## Surfaces
None directly — reads across all.
## Related
- [brief I](./I-audit-trust-replay.brief.md) — strategist's proposals are tracked + corrected through audit.
- [brief H](./H-recurring-chores.brief.md) §H3 — tour-timing inputs.
- [brief L](./L-specialists-fleet.brief.md) §L3b — original contract.

View file

@ -16,7 +16,11 @@
## Does
Evaluate fit + capacity using placement_offers (state/dedup) + prospects/graph/coop/policy + provider_capacity + marketplace policy + surface_metrics + engagement + agent_actions (via specialist-prospect-resolver, directory bookings-_, N-coop, Y). Propose placement offers (anonymized pre-consent: category/window/terms snapshot + graph_fit). Surface curated offers as approval cards (primary to copilot; secondary web). Record all to agent_actions. Snapshot policy at offer time. MCP federation for cross-tenant; platform.api reads for offers etc. Contract shape per placement-market.brief.md (PR 2 + Scope/Reads/Writes/Never/Correction lens + Inputs/Outputs); DESIGN.md §5 (tenancy); INFRA.md §4 (single plane); CLAUDE.md (upstream platform-placement-market skill contrib to @ai/@skills/platform-placement-market/actions/_ e.g. propose-placement.ts — NOT vendored here; contract first, skeleton only, no business logic).
Evaluate fit + capacity using placement_offers (state/dedup) + prospects/graph/coop/policy + provider_capacity + marketplace policy + surface_metrics + engagement + agent_actions (via specialist-prospect-resolver, directory bookings-_, N-coop, Y). Propose placement offers (anonymized pre-consent: category/window/terms snapshot + graph_fit). Surface curated offers as approval cards (primary to copilot; secondary web). Record all to agent_actions. Snapshot policy at offer time. MCP federation for cross-tenant; platform.api reads for offers etc.
PR 3 implementation: core router logic (proposePlacementOffer / evaluateIncomingOffer) lives in @platform placements.service (smallest; behind internal /placement/propose + /evaluate + used by upstream MCP specialist actions). Uses basic policy/CRUD (PR1); mocks/conditionals for prospects+graph+coop_attestations+Y-vetting+surface_metrics (gated P5 per brief). Writes placement_offers + agent_actions for _every_ decision. Correction lens + learning recorded in outcome_json. Full Reads/Writes/Never/Failure tables from brief now in this contract (PR 3). Cites placement-market.brief.md (PR 3 + PR Plan), contract (this), DESIGN §5, CLAUDE, 0012.
Contract shape per placement-market.brief.md (PR 2 + Scope/Reads/Writes/Never/Correction lens + Inputs/Outputs); DESIGN.md §5 (tenancy); INFRA.md §4 (single plane); CLAUDE.md (upstream platform-placement-market skill contrib to @ai/@skills/platform-placement-market/actions/_ e.g. propose-placement.ts — NOT vendored here; contract first, skeleton only, no business logic).
## Auto

View file

@ -15,8 +15,8 @@ import { PlatformApiClient } from "./platform-api.client.js";
EngagementContextProvider,
PersonaContextProvider,
// PR 2: no placement context provider added yet (skeleton per placement-market PR 2; specialist-placement-router
// uses platform-api reads for offers/policy etc via PlatformApiClient; full context providers if wiring
// post-contract in later PRs). See ai-copilot ai-core/app.module.ts comment, placement-market.contract.md,
// uses platform-api reads for offers/policy etc via PlatformApiClient; PR 3 core logic+propose/evaluate in placements module.
// full context providers if wiring post-contract in later PRs). See ai-copilot ai-core/app.module.ts comment, placement-market.contract.md,
// CLAUDE.md (upstream @ai).
],
exports: [

View file

@ -17,28 +17,34 @@
* `surface-kind.ts` in the contracts package) so this client carries no runtime
* dependency on the data plane's entity layer.
*/
import type { PlatformApiClient } from '@cocottetech/surface-adapter-contracts';
import type { PlatformApiClient } from "@cocottetech/surface-adapter-contracts";
/** Mirror of the `surface_metric_kind` ENUM (migration 0005). The migration is SSOT. */
export type SurfaceMetricKind =
| 'profile_view'
| 'search_impression'
| 'search_rank'
| 'click_through'
| 'dm_inbound'
| 'dm_outbound'
| 'reply_rate'
| 'subscription_new'
| 'subscription_total'
| 'tip_amount'
| 'tip_count'
| 'booking_inquiry'
| 'booking_confirmed'
| 'gross_revenue'
| 'net_revenue';
| "profile_view"
| "search_impression"
| "search_rank"
| "click_through"
| "dm_inbound"
| "dm_outbound"
| "reply_rate"
| "subscription_new"
| "subscription_total"
| "tip_amount"
| "tip_count"
| "booking_inquiry"
| "booking_confirmed"
| "gross_revenue"
| "net_revenue"
// placement-market funnel (PR 3: added to metrics surface for strategist T panels; reuses 0005; placement_* events e.g. via adapters/specialist later)
| "placement_match_proposed"
| "placement_offer_viewed"
| "placement_confirmed"
| "placement_handoff";
/** Mirror of the `surface_metric_source` ENUM (migration 0005). The migration is SSOT. */
export type SurfaceMetricSource = 'native_api' | 'native_scrape' | 'derived' | 'manual';
export type SurfaceMetricSource =
"native_api" | "native_scrape" | "derived" | "manual";
/**
* One `surface_metrics` row, as the adapter submits it. `id`, `fetched_at`, and
@ -82,7 +88,7 @@ export interface SurfaceMetricRef {
export class InvalidSurfaceMetricError extends Error {
constructor(message: string) {
super(`invalid surface_metrics row: ${message}`);
this.name = 'InvalidSurfaceMetricError';
this.name = "InvalidSurfaceMetricError";
}
}
@ -97,7 +103,10 @@ export class SurfaceMetricsClient {
/** Persist one `surface_metrics` row; resolves to its assigned id. */
async write(row: SurfaceMetricWrite): Promise<SurfaceMetricRef> {
assertValid(row);
return this.platformApi.post<SurfaceMetricRef>('/surface-metrics', toWire(row));
return this.platformApi.post<SurfaceMetricRef>(
"/surface-metrics",
toWire(row),
);
}
/**
@ -105,7 +114,9 @@ export class SurfaceMetricsClient {
* input is a no-op that makes ZERO HTTP calls (the tier-gated Basic empty-state
* relies on this no rows means no writes).
*/
async writeMany(rows: readonly SurfaceMetricWrite[]): Promise<SurfaceMetricRef[]> {
async writeMany(
rows: readonly SurfaceMetricWrite[],
): Promise<SurfaceMetricRef[]> {
const refs: SurfaceMetricRef[] = [];
for (const row of rows) {
refs.push(await this.write(row));
@ -117,17 +128,23 @@ export class SurfaceMetricsClient {
/** Validate a row against the 0005 CHECKs; throws {@link InvalidSurfaceMetricError}. */
function assertValid(row: SurfaceMetricWrite): void {
if (Date.parse(row.windowEnd) <= Date.parse(row.windowStart)) {
throw new InvalidSurfaceMetricError('window_end must be after window_start');
throw new InvalidSurfaceMetricError(
"window_end must be after window_start",
);
}
if (row.valueNumeric === undefined && row.valueText === undefined) {
throw new InvalidSurfaceMetricError('one of value_numeric / value_text is required');
throw new InvalidSurfaceMetricError(
"one of value_numeric / value_text is required",
);
}
if (row.currency !== undefined) {
if (row.valueNumeric === undefined) {
throw new InvalidSurfaceMetricError('currency requires a numeric value');
throw new InvalidSurfaceMetricError("currency requires a numeric value");
}
if (!/^[A-Z]{3}$/.test(row.currency)) {
throw new InvalidSurfaceMetricError(`currency "${row.currency}" is not ISO 4217`);
throw new InvalidSurfaceMetricError(
`currency "${row.currency}" is not ISO 4217`,
);
}
}
}

View file

@ -46,6 +46,7 @@ import { PlacementsModule } from "./modules/placements/placements.module.js";
// PR 2: specialist skeleton (placement-router contract in .project/designs + roster) + MCP-friendly
// offer reads; upstream skill contrib (actions to @ai not here per CLAUDE.md). See brief PR 2,
// updated placement-market.contract.md (Does/Auto/Proposes/Never/Correction + tables), placements/* JSDoc.
// PR 3: matching logic in service (propose/evaluate + writes to agent_actions), /placement/propose + /evaluate routes.
PlacementsModule,
],
providers: [

View file

@ -24,7 +24,8 @@ import {
*
* Snapshots (policy_snapshot, terms_snapshot) freeze values at proposal time
* so later policy edits cannot retroact on settled handoffs.
* See placement-market.brief.md (Constraints, PR 1), contract.md (Data model),
* PR 3: new reads/writes via router propose (matching).
* See placement-market.brief.md (Constraints, PR 1 + PR 3), contract.md (Data model + full tables),
* INFRA.md §6, CLAUDE.md.
*/
@Entity({ name: "placement_offers" })

View file

@ -23,6 +23,8 @@ import {
CreatePlacementOfferDto,
CreatePlacementPolicyDto,
CreateProviderCapacityDto,
EvaluateOfferDto,
ProposePlacementDto,
UpdatePlacementPolicyDto,
UpdateProviderCapacityDto,
} from "./placements.dto.js";
@ -40,6 +42,11 @@ import { PlacementsService } from "./placements.service.js";
* no "do X" actions here (per CLAUDE.md: contribute actions to @ai/@skills/platform-placement-market/actions/*;
* contract first in .project/designs/...; skeleton only; see placement-market.brief.md PR 2, contract.md).
*
* PR 3: added /placement/propose and /placement/evaluate (internal routes for MCP + core router).
* Delegate to service.proposePlacementOffer / evaluateIncomingOffer (matching + policy integration + agent_actions writes).
* Cites placement-market.brief.md PR 3 (MCP + internal /placement/* routes, writes to agent_actions),
* contract.md (full tables + specialist-placement-router).
*
* Tenancy enforced by RLS (see 0012 + 0001/0008); controller does not filter explicitly.
* Global prefix + guard from main/app (see QuinnSsoGuard, api/v1).
* Cites DESIGN.md §5, INFRA.md §4, CLAUDE.md (single plane, upstream actions, provider-generic, no vendoring @ai).
@ -179,4 +186,22 @@ export class PlacementsController {
): Promise<ProviderCapacityEntity> {
return this.service.updateCapacity(id, dto);
}
// PR 3: core router endpoints (internal + for MCP federation calls from specialist-placement-router).
// propose: runs matching (prospects/graph/policy/capacity + gated mocks), writes offer + agent_action.
// evaluate: records decision/correction (learning), writes agent_action. No auto handoff.
@Post("propose")
@HttpCode(HttpStatus.CREATED)
async proposePlacement(
@Body() dto: ProposePlacementDto,
): Promise<PlacementOfferEntity> {
return this.service.proposePlacementOffer(dto);
}
@Post("evaluate")
async evaluateOffer(
@Body() dto: EvaluateOfferDto,
): Promise<{ offer: PlacementOfferEntity; decisionRecorded: boolean }> {
return this.service.evaluateIncomingOffer(dto);
}
}

View file

@ -263,3 +263,75 @@ export class UpdateProviderCapacityDto {
@IsObject()
notes?: Record<string, unknown> | null;
}
/**
* PR 3: DTOs for core router propose/evaluate (MCP + internal /placement/* routes).
* Minimal; used by specialist-placement-router to invoke matching logic.
* Cites placement-market.brief.md PR 3, contract.md (Inputs: mcp__...propose_placement, evaluate_offer).
*/
export class ProposePlacementDto {
@ApiProperty({ format: "uuid" })
@IsUUID()
user_id!: string;
@ApiProperty({ format: "uuid", required: false, nullable: true })
@IsOptional()
@IsUUID()
org_id?: string | null;
@ApiProperty({ format: "uuid", required: false, nullable: true })
@IsOptional()
@IsUUID()
prospect_id?: string | null;
@ApiProperty({ format: "uuid" })
@IsUUID()
target_provider_user_id!: string;
@ApiProperty({ enum: [...SURFACE_KINDS] })
@IsEnum([...SURFACE_KINDS])
category!: SurfaceKind;
@ApiProperty({ type: "object", additionalProperties: true, required: false })
@IsOptional()
@IsObject()
availability_window?: Record<string, unknown>;
@ApiProperty({ type: "object", additionalProperties: true, required: false })
@IsOptional()
@IsObject()
client_intent?: Record<string, unknown>;
}
export class EvaluateOfferDto {
@ApiProperty({ format: "uuid" })
@IsUUID()
user_id!: string;
@ApiProperty({ format: "uuid", required: false, nullable: true })
@IsOptional()
@IsUUID()
org_id?: string | null;
@ApiProperty({ format: "uuid" })
@IsUUID()
offer_id!: string;
@ApiProperty({ example: "provider" })
@IsString()
side!: "provider" | "client" | "prospect";
@ApiProperty({ example: "accept" })
@IsString()
decision!: "accept" | "decline" | "correction";
@ApiProperty({
type: "object",
required: false,
nullable: true,
additionalProperties: true,
})
@IsOptional()
@IsObject()
correction?: Record<string, unknown> | null;
}

View file

@ -7,16 +7,17 @@ import { PlacementOfferEntity } from "../../entities/placement-offer.entity.js";
import { PlacementPolicyEntity } from "../../entities/placement-policy.entity.js";
import { ProviderCapacityEntity } from "../../entities/provider-capacity.entity.js";
import { AgentActionsModule } from "../agent-actions/agent-actions.module.js";
import { PlacementsController } from "./placements.controller.js";
import { PlacementsService } from "./placements.service.js";
/**
* PlacementsModule (PR 1 core; PR 2: specialist skeleton support via existing reads).
* Exports service for potential future specialist context in ai-copilot/ai-core (contract first).
* MCP federation stubs (mcp__platform-placement-market__*) + specialist-placement-router actions
* contributed upstream per CLAUDE.md (to @ai/@skills/platform-placement-market/actions/*, e.g.
* propose-placement.ts; not vendored here). Cites placement-market.brief.md PR 2, contract.md,
* DESIGN.md §5, INFRA.md §4.
* PR 3: core router matching logic (propose/evaluate) + graph/policy integration (mocks for P5 gates)
* + agent_actions writes for every decision + correction lens. Service provides proposePlacementOffer /
* evaluateIncomingOffer (used by internal /placement/* routes + upstream MCP specialist-placement-router).
* Cites placement-market.brief.md (PR 3 + Constraints "gated on prospecting + N + Y"), contract.md (Reads/Writes/Never/Failure + specialist section),
* DESIGN.md §5, INFRA.md §4, CLAUDE.md (actions upstream, no @ai vendor, person-first, cite PR plan).
*/
@Module({
@ -28,6 +29,7 @@ import { PlacementsService } from "./placements.service.js";
PlacementPolicyEntity,
ProviderCapacityEntity,
]),
AgentActionsModule,
],
controllers: [PlacementsController],
providers: [PlacementsService],

View file

@ -8,6 +8,7 @@ import type { PlacementOfferEntity } from "../../entities/placement-offer.entity
import type { PlacementPolicyEntity } from "../../entities/placement-policy.entity.js";
import type { ProviderCapacityEntity } from "../../entities/provider-capacity.entity.js";
import type { AgentActionsService } from "../agent-actions/agent-actions.service.js";
import type {
CreatePlacementOfferDto,
CreatePlacementPolicyDto,
@ -29,13 +30,15 @@ type MockRepo = {
/**
* Unit tests for placements service (PR 1 core).
* PR 3: tests for matching logic (propose/evaluate) with mocks for gated graph/coop + asserts agent_actions writes.
*
* Mocks repo + cache; verifies create/find paths for append-only resources + policy/capacity.
*
* Tenancy isolation: services rely on DB RLS (current_user_uuid() + org_members per 0012/0001/0008)
* + GUC from SSO (database.config.ts). No app-layer cross-tenant filters in general list/create
* (cf. agent-actions, content-plans). Explicit scoping only for keyed singletons (see ingestion.service).
* Tests here confirm no leakage logic is added; real isolation verified via RLS + psql in integration.
* Cites placement-market.brief.md (Constraints: person-first tenancy), contract.md, DESIGN.md §5.
* Cites placement-market.brief.md (PR 3 + "gated...", Constraints: person-first tenancy), contract.md, DESIGN.md §5.
*/
describe("PlacementsService", () => {
let offersRepo: MockRepo;
@ -44,6 +47,7 @@ describe("PlacementsService", () => {
let policyRepo: MockRepo;
let capacityRepo: MockRepo;
let cache: { publish: ReturnType<typeof vi.fn> };
let agentActions: { create: ReturnType<typeof vi.fn> };
let service: PlacementsService;
beforeEach(() => {
@ -84,6 +88,7 @@ describe("PlacementsService", () => {
),
};
cache = { publish: vi.fn().mockResolvedValue(undefined) };
agentActions = { create: vi.fn((e: object) => Promise.resolve(e)) };
service = new PlacementsService(
offersRepo as unknown as Repository<PlacementOfferEntity>,
handoffsRepo as unknown as Repository<PlacementHandoffEntity>,
@ -91,6 +96,7 @@ describe("PlacementsService", () => {
policyRepo as unknown as Repository<PlacementPolicyEntity>,
capacityRepo as unknown as Repository<ProviderCapacityEntity>,
cache as unknown as CacheInvalidateService,
agentActions as unknown as AgentActionsService,
);
});
@ -204,4 +210,77 @@ describe("PlacementsService", () => {
);
});
});
// PR 3: unit tests on matching (mock graph/coop) + audit rows in agent_actions per brief verification.
describe("PR 3 router matching + agent_actions (mocks for P5-gated deps)", () => {
it("proposePlacementOffer reads policy+capacity, applies mock fit (gated graph/coop), creates offer + writes agent_action decision", async () => {
policyRepo.find.mockResolvedValue([
{ policy_json: { default_share: 12, min_graph_fit: 0.6 } },
]);
capacityRepo.find.mockResolvedValue([
{ open_slots: 2, surface: "tryst" },
]);
const res = await service.proposePlacementOffer({
user_id: "router-actor",
target_provider_user_id: "provider-1",
category: "tryst",
prospect_id: "prospect-xyz",
});
expect(res).toHaveProperty("id");
expect(res.policy_snapshot).toEqual(
expect.objectContaining({ default_share: 12 }),
);
expect(res.graph_fit).toBe("0.710"); // from mock
expect(res.coop_vetting_ref).toEqual(
expect.objectContaining({
note: expect.stringContaining("mocked (prospecting graph"),
}),
);
expect(agentActions.create).toHaveBeenCalledWith(
expect.objectContaining({
specialist_id: "placement-router",
action_type: "propose_placement_offer",
target_kind: "placement_offer",
auto_executed: false,
}),
);
const outcome = agentActions.create.mock.calls[0][0].outcome_json;
expect(outcome).toEqual(
expect.objectContaining({
matched: true,
gated_note: expect.any(String),
}),
);
});
it("evaluateIncomingOffer records decision + correction lens to agent_actions (no offer mutation)", async () => {
offersRepo.findOne.mockResolvedValue({
id: "off1",
state: "proposed",
user_id: "p1",
});
const res = await service.evaluateIncomingOffer({
user_id: "evaluator",
offer_id: "off1",
side: "provider",
decision: "correction",
correction: { reason: "shouldn't have offered this category/slot" },
});
expect(res.decisionRecorded).toBe(true);
expect(agentActions.create).toHaveBeenCalledWith(
expect.objectContaining({
action_type: "evaluate_offer",
outcome_json: expect.objectContaining({
decision: "correction",
correction_applied: true,
learning: expect.stringContaining("dials fit"),
}),
}),
);
});
});
});

View file

@ -9,6 +9,8 @@ import { PlacementOfferEntity } from "../../entities/placement-offer.entity.js";
import { PlacementPolicyEntity } from "../../entities/placement-policy.entity.js";
import { ProviderCapacityEntity } from "../../entities/provider-capacity.entity.js";
import type { AgentActionsService } from "../agent-actions/agent-actions.service.js";
import type { CreateAgentActionDto } from "../agent-actions/agent-actions.dto.js";
import type {
CreatePlacementHandoffDto,
CreatePlacementLedgerDto,
@ -40,6 +42,16 @@ import type {
* (mcp__platform-placement-market__*) live upstream in @ai (per CLAUDE.md: actions contrib, not vendored
* in @platform; see placement-market.brief.md PR 2, DESIGN.md, INFRA.md §4). No logic added here.
*
* PR 3: core router matching logic + graph/policy integration implemented here (in placements for
* smallest change; specialist-placement-router actions/MCP call /placement/* or this service logic).
* proposePlacementOffer: reads policy/capacity + (gated mocks for prospects/graph/N-coop/Y-vetting/surface_metrics per brief),
* snapshots, basic fit scoring, writes placement_offers + agent_actions decision.
* evaluateIncomingOffer: evaluates, applies correction lens (records learning in agent_actions outcome), writes decision.
* Every decision writes to agent_actions (specialist_id 'placement-router', append-only audit).
* Uses basic policy/CRUD from PR1 (full editor PR4). Cites placement-market.brief.md (PR 3 + "gated on prospecting + N + Y" + Observability),
* placement-market.contract.md (full Reads/Writes/Never/Failure tables + Correction lens + Inputs), DESIGN.md §5, INFRA.md §4/§5,
* CLAUDE.md, 0001_tenancy_and_content.sql + 0005 + 0012_placement_market.sql + agent-action.entity.ts .
*
* Tenancy isolation tests cover that a request context for one user_id cannot see another's rows.
* Cites placement-market.brief.md PR 1 + PR 2, agent-actions.service.ts pattern, CLAUDE.md.
*/
@ -57,6 +69,7 @@ export class PlacementsService {
@InjectRepository(ProviderCapacityEntity)
private readonly capacityRepo: Repository<ProviderCapacityEntity>,
private readonly cache: CacheInvalidateService,
private readonly agentActions: AgentActionsService,
) {}
// --- Offers (append-only) ---
@ -304,4 +317,183 @@ export class PlacementsService {
"provider_capacity",
);
}
// --- PR 3 core router: propose / evaluate + agent_actions writes for every decision ---
// Matching uses policy + provider_capacity + (conditional mocks for prospects/graph/coop/N/Y/surface_metrics
// since gated "on prospecting + N + Y" live at P5 per placement-market.brief.md PR 3 + Constraints).
// Snapshots policy at offer time (immutable). Correction lens + learning via outcome_json.
// Always writes to agent_actions (specialist_id: 'placement-router').
// Internal for /placement/* + upstream MCP mcp__platform-placement-market__propose_placement etc.
// Cites placement-market.brief.md PR 3, contract.md (core router, propose/evaluate, Correction lens),
// DESIGN.md §5, CLAUDE.md, 0012 + 0005 + agent-action.entity.ts .
private async recordRouterDecision(
userId: string,
orgId: string | null,
actionType: string,
targetId: string | null,
outcome: Record<string, unknown>,
autoExecuted = false,
): Promise<void> {
const write: CreateAgentActionDto = {
user_id: userId,
org_id: orgId ?? null,
specialist_id: "placement-router",
action_type: actionType,
target_kind: "placement_offer",
target_id: targetId,
stakes: "medium",
confidence: 0.65,
auto_executed: autoExecuted,
approved_by: null,
approved_at: null,
outcome_json: outcome,
};
await this.agentActions.create(write);
}
/**
* Propose a curated placement offer (anonymized pre-consent).
* Performs matching against policy + capacity + gated signals; creates offer + decision audit.
*/
async proposePlacementOffer(input: {
user_id: string;
org_id?: string | null;
prospect_id?: string | null;
target_provider_user_id: string;
category: string;
availability_window?: Record<string, unknown>;
client_intent?: Record<string, unknown>;
}): Promise<PlacementOfferEntity> {
const uid = input.user_id;
const oid = input.org_id ?? null;
const targetUid = input.target_provider_user_id;
const cat = input.category as any;
// Read policy/capacity for target provider (via where override for specialist/internal use).
const policies = await this.findAllPolicies({
user_id: targetUid,
} as FindOptionsWhere<PlacementPolicyEntity>);
const policy = policies[0] ?? {
policy_json: { default_share: 10, min_graph_fit: 0.5, categories: ["*"] },
};
const policyJson = policy.policy_json as Record<string, unknown>;
const caps = await this.findAllCapacity({
user_id: targetUid,
surface: cat,
} as FindOptionsWhere<ProviderCapacityEntity>);
const cap = caps[0];
const hasCapacity = !!cap && (cap.open_slots ?? 0) > 0;
// Gated reads (prospects via resolver, graph aggregates, coop_attestations, Y vetting, surface_metrics booking_inquiry etc).
// Per PR 3 + brief: use conditional/mocks; note the gate. Full in P5 when live.
let graphFit: number | null = null;
let coopVetting: Record<string, unknown> | null = null;
const graphAvailable = false; // gated on cross-provider-graph + N + Y at P5
if (graphAvailable) {
// would query prospect_subject_aggregates + coop_peer_attestations + surface_metrics here
graphFit = 0.8;
coopVetting = { n: 5, rep: 0.9 };
} else {
graphFit = 0.71; // mock for shape
coopVetting = {
n_attestations: 2,
y_vetted: false,
note: "mocked (prospecting graph + N-coop + Y live at P5 per placement-market.brief.md PR 3)",
};
}
// Basic policy match + capacity.
const minFit = (policyJson.min_graph_fit as number) ?? 0.5;
const policyAccept = hasCapacity && (graphFit ?? 0) >= minFit;
const termsSnap = {
share_percent: (policyJson.default_share as number) ?? 10,
category: cat,
high_level: "anonymized pre-consent per contract",
};
const polSnap = { ...policyJson, snapshot_at: new Date().toISOString() };
// Always propose the offer (curated) for this PR shape; caller/human gate later per Never.
const offerDto: CreatePlacementOfferDto = {
user_id: targetUid, // the placement provider
org_id: oid,
prospect_id: input.prospect_id ?? null,
target_provider_user_id: targetUid,
state: "proposed",
category: cat,
availability_window: input.availability_window ?? {
start: new Date().toISOString(),
note: "derived from surface_metrics + capacity (gated)",
},
terms_snapshot: termsSnap,
policy_snapshot: polSnap,
graph_fit: graphFit,
coop_vetting_ref: coopVetting,
};
const offer = await this.createOffer(offerDto);
// Write decision to agent_actions for every router action (per PR 3 desc + contract Outputs).
await this.recordRouterDecision(
uid,
oid,
"propose_placement_offer",
offer.id,
{
matched: policyAccept,
graph_fit: graphFit,
has_capacity: hasCapacity,
policy_used: polSnap,
gated_note: graphAvailable
? undefined
: "mocks for prospects/graph/coop/N/Y/surface_metrics",
},
false, // human-on-loop, no auto
);
return offer;
}
/**
* Evaluate incoming placement offer (client/prospect or provider side).
* Records decision + correction lens (for learning) to agent_actions.
* Does not mutate post-insert offers (append-only); handoff separate.
*/
async evaluateIncomingOffer(input: {
user_id: string;
org_id?: string | null;
offer_id: string;
side: "provider" | "client" | "prospect";
decision: "accept" | "decline" | "correction";
correction?: Record<string, unknown> | null;
}): Promise<{ offer: PlacementOfferEntity; decisionRecorded: boolean }> {
const uid = input.user_id;
const oid = input.org_id ?? null;
const offer = await this.findOneOffer(input.offer_id);
const outcome: Record<string, unknown> = {
side: input.side,
decision: input.decision,
offer_state: offer.state,
correction_applied: !!input.correction,
};
if (input.correction) {
// Correction lens + learning: e.g. "shouldn't have offered this category" -> threshold raise noted in outcome.
// Router will use in future proposals (aggregates via agent_actions).
outcome.correction = input.correction;
outcome.learning =
"dials fit/reputation or accept-threshold per contract Correction lens";
}
await this.recordRouterDecision(
uid,
oid,
"evaluate_offer",
offer.id,
outcome,
false,
);
return { offer, decisionRecorded: true };
}
}