Initial v1 code layout
The destination of the wayfinder map: directory and module structure, the domain model as real schema files, permission boundaries expressed as code, and typed seams where the undecided parts land. Satisfies the four structural requirements earlier resolutions handed to a layout that did not exist: - a single database entry point (#11) — the pool is not exported - a scheduler entry point with actor context and no request (#13) - an entitlement assertion helper (#20, #34) — its call sites are the paid-feature list - an enumerated list of privileged RLS bypasses (#33) — three of them Seams carry real types and throw with the ticket that owns them, so the skeleton wires up and fails only where a decision is genuinely missing. Verified: tsc --noEmit clean; drizzle-kit generate produces RLS on 18 tables and 6 policies calling the release-level functions. Resolves #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Retail RFP transmission system — v1 layout
|
||||||
|
|
||||||
|
A distribution and evaluation layer for retail sourcing. Large retailers issue
|
||||||
|
RFPs and evaluate bids for free; vendors pay for the workspace that helps them
|
||||||
|
win. This repository is the **v1 code layout**: directory and module structure,
|
||||||
|
the domain model as real schema files, permission boundaries expressed as code,
|
||||||
|
and typed seams where the undecided parts will land.
|
||||||
|
|
||||||
|
**It is not a running product.** It is the skeleton a build can start from
|
||||||
|
without being redesigned.
|
||||||
|
|
||||||
|
Decisions live on the issue tracker, indexed by
|
||||||
|
[the map](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/1).
|
||||||
|
Nearly every file here carries a comment naming the ticket that fixed it — if a
|
||||||
|
choice looks arbitrary, the ticket says why, and what it cost.
|
||||||
|
|
||||||
|
## The four structural requirements this layout exists to satisfy
|
||||||
|
|
||||||
|
Four resolutions handed requirements to a layout that did not exist yet. They
|
||||||
|
are the reason this repository is shaped the way it is.
|
||||||
|
|
||||||
|
| Requirement | Where it lives | From |
|
||||||
|
|---|---|---|
|
||||||
|
| A single database entry point that makes bypassing actor context awkward | `src/db/index.ts` — the pool is not exported | #11 |
|
||||||
|
| A scheduled-job entry point establishing actor context with no request | `src/scheduler/` | #13 |
|
||||||
|
| An entitlement assertion helper carrying the convenience-not-correctness principle | `src/auth/entitlement.ts` | #20, #34 |
|
||||||
|
| An explicit, reviewable list of privileged RLS bypasses | `src/db/privileged.ts` — three of them | #33 |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
db/
|
||||||
|
index.ts THE database entry point. withActor() is the only way in.
|
||||||
|
actor.ts ActorContext; SET LOCAL discipline
|
||||||
|
privileged.ts the enumerated RLS bypasses — keep this short
|
||||||
|
schema/ the domain model, with RLS policies beside their tables
|
||||||
|
auth/
|
||||||
|
entitlement.ts assert per feature; its call sites ARE the paid-feature list
|
||||||
|
release/
|
||||||
|
matrix.ts the release matrix, mirrored from SQL so screen and policy agree
|
||||||
|
scoring/
|
||||||
|
routing.ts what a model may decide. Two lanes of six.
|
||||||
|
rank.ts ranking over committed scores only
|
||||||
|
extraction/
|
||||||
|
index.ts ONE module used twice — RFP and response
|
||||||
|
completeness.ts the second, INDEPENDENT pass. Must share nothing with the above.
|
||||||
|
scheduler/ jobs, with actor context and no request
|
||||||
|
http/
|
||||||
|
context.ts request -> actor context; entitlement resolved once
|
||||||
|
routes/ two capability-scoped nav trees, plus a public pre-auth surface
|
||||||
|
pricing/ rate function evaluation — deterministic, no model fallback
|
||||||
|
audit/ integration/ helmdocs/ kickoff/ claiming/ seams
|
||||||
|
migrations/
|
||||||
|
0000_* SQL functions, hand-authored — must precede the policies
|
||||||
|
0001_* generated schema, with RLS policies beside their tables
|
||||||
|
0002_* the release matrix seed, hand-authored
|
||||||
|
docs/
|
||||||
|
seams.md what is open, and what is deliberately closed
|
||||||
|
migrations.md why policies sit beside tables and the matrix is a data migration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Three invariants worth knowing before you touch anything
|
||||||
|
|
||||||
|
**Permissions never resolve against an org alone.** Always
|
||||||
|
`(org, capability, resource)`. An org may hold both the issuer and vendor
|
||||||
|
capability, and those are two authorization contexts that cannot see each other
|
||||||
|
— a dual-role company's vendor vault is invisible to its own issuing side. This
|
||||||
|
is enforced in Postgres, not in application code, because the invariant that a
|
||||||
|
vendor's bid is never reachable by another vendor is the one failure that would
|
||||||
|
destroy the product's credibility.
|
||||||
|
|
||||||
|
**Entitlement is not row visibility.** RLS answers *whose row is this*; a lapsed
|
||||||
|
vendor still owns every row they owned yesterday. Policies must not reference
|
||||||
|
entitlement at all. Entitlement gates what a vendor may newly undertake, never
|
||||||
|
what a retailer has already come to rely on.
|
||||||
|
|
||||||
|
**Bids are sealed until the deadline, then the whole field opens at once.** Bid
|
||||||
|
shopping is structurally impossible rather than discouraged. A surprising amount
|
||||||
|
depends on this: it is also what makes mid-flight amendments safe, since the
|
||||||
|
retailer has read nothing and cannot tailor one to advantage a response they
|
||||||
|
have already seen.
|
||||||
|
|
||||||
|
## Working on a seam
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn seams
|
||||||
|
```
|
||||||
|
|
||||||
|
Every seam throws with the ticket that owns it. Replace the body; the signature
|
||||||
|
is already the handoff. If you find yourself changing a seam's *types*, check
|
||||||
|
the ticket first — the shape usually came out of a resolution rather than out of
|
||||||
|
convenience.
|
||||||
48
docs/migrations.md
Normal file
48
docs/migrations.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Migrations
|
||||||
|
|
||||||
|
**Generated SQL is never hand-edited.** Hand-editing poisons Drizzle's diff
|
||||||
|
baseline, and the baseline is what tells you the database still matches the
|
||||||
|
schema that the types are inferred from.
|
||||||
|
|
||||||
|
The split follows what Drizzle can actually diff:
|
||||||
|
|
||||||
|
| Owned by Drizzle | Owned by hand |
|
||||||
|
|---|---|
|
||||||
|
| Tables, columns, indexes | SQL functions |
|
||||||
|
| RLS policies, via `pgPolicy` beside the table | Data seeds |
|
||||||
|
| Enums | The release matrix and its versions |
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn generate # schema diff -> migrations/
|
||||||
|
yarn generate:custom # empty file -> migrations/, for functions and data
|
||||||
|
yarn migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why policies sit beside their tables
|
||||||
|
|
||||||
|
A new table shipping with no policy is the failure mode that matters, and RLS
|
||||||
|
default-deny only helps if it was enabled on that table. Declaring the policy in
|
||||||
|
the same file as the table makes the omission visible at review time.
|
||||||
|
|
||||||
|
## Why the matrix is a data migration
|
||||||
|
|
||||||
|
The release matrix is **versioned, never updated** — a past release has to be
|
||||||
|
reconstructable when an award is protested. A rule change is an INSERT at a new
|
||||||
|
version. That is exactly what `--custom` is for.
|
||||||
|
|
||||||
|
## Ordering: why the functions are migration 0000, and why they are plpgsql
|
||||||
|
|
||||||
|
Drizzle emits an RLS policy in the **same migration as the table it guards**, so
|
||||||
|
the functions those policies call must already exist. But the functions read
|
||||||
|
`solicitation`, `participation` and `release_matrix` — which that same later
|
||||||
|
migration creates. A straightforward cycle.
|
||||||
|
|
||||||
|
`LANGUAGE sql` validates a function body at `CREATE` time and would fail. plpgsql
|
||||||
|
defers validation to first call, which breaks the cycle. For the same reason the
|
||||||
|
signatures use `text` rather than the `release_level` enum: the enum does not
|
||||||
|
exist yet either.
|
||||||
|
|
||||||
|
The cost is real and worth knowing: **a typo inside a function body is not caught
|
||||||
|
at migration time.** It surfaces the first time a policy runs — which, because
|
||||||
|
policy failures present as empty results, is the failure mode #11 already flagged
|
||||||
|
as hard to diagnose. Database-level tests over these functions are not optional.
|
||||||
34
docs/seams.md
Normal file
34
docs/seams.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Seams
|
||||||
|
|
||||||
|
A seam is a place the map has not decided yet. Every one has **real types** —
|
||||||
|
the signature is the handoff, so a ticket lands by replacing a body rather than
|
||||||
|
by designing an interface.
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn seams # this list, from the code
|
||||||
|
```
|
||||||
|
|
||||||
|
| Seam | Ticket | What is undecided |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/audit/` | #21 | Retention and query design. Every *input* is already fixed by #14, #15 and #16. |
|
||||||
|
| `src/integration/` | #18 | Formats and transport. Export can never exceed read — fixed by #14. |
|
||||||
|
| `src/helmdocs/` | #19 | The API/MCP boundary. |
|
||||||
|
| `src/kickoff/` | #17 | Everything. Starts where release reaches `awarded`. |
|
||||||
|
| `src/claiming/` | #24 | Verification, and how "Acme Foods Inc" and "Acme Foods" resolve to one org. |
|
||||||
|
| `src/pricing/` | #29 | The closed set of rate structure types. No model fallback exists, by #15. |
|
||||||
|
| `src/extraction/` | #30 | Cannot be built or measured before the corpus exists. |
|
||||||
|
| `src/extraction/completeness.ts` | #30 | Same, and needs *labelled known-misses* to have measurable recall. |
|
||||||
|
| `src/scheduler/jobs/expiry.ts` | #27 | Per-type expiry rules — the catalogue defines them. |
|
||||||
|
| `src/auth/entitlement.ts` | #35 | Resolution wiring only. The principle and the tier table are decided. |
|
||||||
|
|
||||||
|
## What is deliberately *not* a seam
|
||||||
|
|
||||||
|
These are decided, and implemented as real code. Changing them means reopening
|
||||||
|
a closed ticket, not filling in a blank.
|
||||||
|
|
||||||
|
- **`src/db/index.ts`** — the single database entry point. The pool is not
|
||||||
|
exported; `withActor` is the only way in.
|
||||||
|
- **`src/db/privileged.ts`** — the enumerated privileged paths. Three of them.
|
||||||
|
Adding a fourth should feel like a decision.
|
||||||
|
- **`src/release/matrix.ts`** — the release matrix. One rule set, versioned.
|
||||||
|
- **`src/scoring/routing.ts`** — what a model may decide. Two lanes of six.
|
||||||
18
drizzle.config.ts
Normal file
18
drizzle.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Config } from 'drizzle-kit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle owns what it can diff: tables, and the RLS policies declared
|
||||||
|
* beside them via `pgPolicy`. It cannot diff a SQL function, so
|
||||||
|
* `release_level`, `class_released` and the release-matrix seed live in
|
||||||
|
* hand-authored custom migrations under `migrations/custom`.
|
||||||
|
*
|
||||||
|
* Generated SQL is never hand-edited. See docs/migrations.md.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
schema: './src/db/schema/index.ts',
|
||||||
|
out: './migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL! },
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
} satisfies Config
|
||||||
96
migrations/0000_release_level_functions.sql
Normal file
96
migrations/0000_release_level_functions.sql
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
-- Custom migration, hand-authored. Drizzle cannot diff a SQL function.
|
||||||
|
--
|
||||||
|
-- Fixed by #14 decision 1: ONE function collapses the four inputs #12 named
|
||||||
|
-- into a release level, and ONE lookup answers whether a class is open at it.
|
||||||
|
-- Nothing else in the system encodes lifecycle.
|
||||||
|
--
|
||||||
|
-- ── WHY plpgsql AND WHY FIRST ────────────────────────────────────────────
|
||||||
|
-- The RLS policies are emitted by Drizzle in the same migration as the tables
|
||||||
|
-- they guard, so the functions those policies call must already exist. But the
|
||||||
|
-- functions read `solicitation`, `participation` and `release_matrix`, which
|
||||||
|
-- that same later migration creates.
|
||||||
|
--
|
||||||
|
-- `LANGUAGE sql` validates the body at CREATE time and would fail here.
|
||||||
|
-- plpgsql defers validation to first call, which is exactly the property
|
||||||
|
-- needed to break the cycle. Signatures use `text` rather than the
|
||||||
|
-- `release_level` enum for the same reason — the enum does not exist yet.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- The four inputs: audience membership, stage, participation state, acknowledgement.
|
||||||
|
CREATE FUNCTION release_level_for_solicitation(p_solicitation uuid)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql STABLE AS $$
|
||||||
|
DECLARE
|
||||||
|
v_level text;
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE
|
||||||
|
WHEN p.id IS NULL THEN 'none'
|
||||||
|
WHEN s.stage = 'awarded' THEN 'awarded'
|
||||||
|
WHEN s.stage = 'finalists' THEN 'finalist'
|
||||||
|
WHEN s.stage = 'evaluating' THEN 'evaluating'
|
||||||
|
-- The deadline is the moment the whole field opens at once (#14 decision 2).
|
||||||
|
-- Before it, no response content is readable by anyone but its author, which
|
||||||
|
-- is what makes bid shopping structurally impossible rather than discouraged.
|
||||||
|
WHEN s.responses_due_at IS NOT NULL AND now() >= s.responses_due_at THEN 'open'
|
||||||
|
WHEN p.state = 'submitted' THEN 'submitted'
|
||||||
|
WHEN p.acknowledged_at IS NOT NULL OR NOT s.acknowledgement_required
|
||||||
|
THEN 'acknowledged'
|
||||||
|
ELSE 'invited'
|
||||||
|
END
|
||||||
|
INTO v_level
|
||||||
|
FROM solicitation s
|
||||||
|
LEFT JOIN participation p
|
||||||
|
ON p.solicitation_id = s.id
|
||||||
|
AND p.vendor_org_id = nullif(current_setting('app.org_id', true), '')::uuid
|
||||||
|
WHERE s.id = p_solicitation;
|
||||||
|
|
||||||
|
RETURN coalesce(v_level, 'none');
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The issuer reaches their own event at the level its stage implies, without
|
||||||
|
-- being a participant in it. Everyone else gets 'none' — including a dual-role
|
||||||
|
-- org's issuing side looking at its own vendor side (#11 decision 2).
|
||||||
|
CREATE FUNCTION release_level_for_response(p_response uuid)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql STABLE AS $$
|
||||||
|
DECLARE
|
||||||
|
v_level text;
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE
|
||||||
|
WHEN current_setting('app.capability', true) = 'issuer'
|
||||||
|
AND s.issuer_org_id = nullif(current_setting('app.org_id', true), '')::uuid
|
||||||
|
THEN release_level_for_solicitation(s.id)
|
||||||
|
WHEN current_setting('app.capability', true) = 'vendor'
|
||||||
|
AND r.vendor_org_id = nullif(current_setting('app.org_id', true), '')::uuid
|
||||||
|
THEN release_level_for_solicitation(s.id)
|
||||||
|
ELSE 'none'
|
||||||
|
END
|
||||||
|
INTO v_level
|
||||||
|
FROM response r
|
||||||
|
JOIN solicitation s ON s.id = r.solicitation_id
|
||||||
|
WHERE r.id = p_response;
|
||||||
|
|
||||||
|
RETURN coalesce(v_level, 'none');
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Reads the CURRENT matrix version. A past release is reconstructed by reading
|
||||||
|
-- an older version, which is why the matrix is versioned rather than updated.
|
||||||
|
CREATE FUNCTION class_released(p_level text, p_class text)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE plpgsql STABLE AS $$
|
||||||
|
DECLARE
|
||||||
|
v_released boolean;
|
||||||
|
BEGIN
|
||||||
|
SELECT released = 'true'
|
||||||
|
INTO v_released
|
||||||
|
FROM release_matrix
|
||||||
|
WHERE class = p_class::disclosure_class
|
||||||
|
AND level = p_level::release_level
|
||||||
|
ORDER BY version DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
RETURN coalesce(v_released, false);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
445
migrations/0001_initial.sql
Normal file
445
migrations/0001_initial.sql
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
CREATE TYPE "public"."capability" AS ENUM('issuer', 'vendor');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."category_change_kind" AS ENUM('split', 'merge', 'retire', 'remap');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."grant_provenance" AS ENUM('manual', 'split_event', 'merge_event');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."amendment_class" AS ENUM('substantive', 'clarification');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."confirmation_state" AS ENUM('unconfirmed', 'confirmed', 'corrected');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."requirement_kind" AS ENUM('mandatory_gate', 'scored_criterion', 'certification_form', 'compliance_schedule', 'pricing', 'narrative');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."solicitation_stage" AS ENUM('draft', 'open', 'evaluating', 'finalists', 'awarded', 'cancelled');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."coverage" AS ENUM('answered', 'not_answered', 'indeterminate');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."participation_state" AS ENUM('invited', 'opened', 'acknowledged', 'intends_to_bid', 'submitted', 'withdrawn');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."disclosure_class" AS ENUM('vendor_identity', 'participation_signal', 'scenario_quantity', 'answer_text', 'exception_text', 'price_quote', 'derived_cost', 'vault_document', 'own_scoring');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."release_level" AS ENUM('none', 'invited', 'acknowledged', 'submitted', 'open', 'evaluating', 'finalist', 'awarded');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."expiry_state" AS ENUM('current', 'expiring_soon', 'expired');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."tier" AS ENUM('base', 'pro', 'enterprise');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."sweep_finding_kind" AS ENUM('asserted_obligation', 'uncovered_span');--> statement-breakpoint
|
||||||
|
CREATE TABLE "actor" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
CONSTRAINT "actor_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "membership" (
|
||||||
|
"actor_id" uuid NOT NULL,
|
||||||
|
"org_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "membership_actor_id_org_id_pk" PRIMARY KEY("actor_id","org_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "org_capability" (
|
||||||
|
"org_id" uuid NOT NULL,
|
||||||
|
"capability" "capability" NOT NULL,
|
||||||
|
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "org_capability_org_id_capability_pk" PRIMARY KEY("org_id","capability")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "organization" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"legal_name" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "roster_entry" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"retailer_org_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"contact_email" text,
|
||||||
|
"duns" text,
|
||||||
|
"claimed_vendor_org_id" uuid,
|
||||||
|
"claimed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "roster_entry_retailer_org_id_name_unique" UNIQUE("retailer_org_id","name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "category_change_event" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"kind" "category_change_kind" NOT NULL,
|
||||||
|
"retailer_org_id" uuid,
|
||||||
|
"from_ids" uuid[] NOT NULL,
|
||||||
|
"to_ids" uuid[] NOT NULL,
|
||||||
|
"rows_written" integer DEFAULT 0 NOT NULL,
|
||||||
|
"performed_by" uuid NOT NULL,
|
||||||
|
"performed_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "eligibility" (
|
||||||
|
"retailer_org_id" uuid NOT NULL,
|
||||||
|
"vendor_org_id" uuid NOT NULL,
|
||||||
|
"local_category_id" uuid NOT NULL,
|
||||||
|
"provenance" "grant_provenance" DEFAULT 'manual' NOT NULL,
|
||||||
|
"provenance_event_id" uuid,
|
||||||
|
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "eligibility_retailer_org_id_vendor_org_id_local_category_id_pk" PRIMARY KEY("retailer_org_id","vendor_org_id","local_category_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "local_category" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"retailer_org_id" uuid NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"spine_leaf_id" uuid,
|
||||||
|
"valid_from" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"valid_to" timestamp with time zone,
|
||||||
|
"superseded_by" uuid[] DEFAULT '{}' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "spine_node" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"group_id" uuid,
|
||||||
|
"unspsc_families" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"valid_from" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"valid_to" timestamp with time zone,
|
||||||
|
"superseded_by" uuid[] DEFAULT '{}' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "criterion" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"max_points" numeric(8, 2) NOT NULL,
|
||||||
|
"weight" numeric(8, 2) NOT NULL,
|
||||||
|
"scale_anchors" jsonb DEFAULT '[]'::jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "criterion" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "criterion_requirement" (
|
||||||
|
"criterion_id" uuid NOT NULL,
|
||||||
|
"requirement_id" uuid NOT NULL,
|
||||||
|
"authored_by" uuid NOT NULL,
|
||||||
|
"frozen_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "criterion_requirement_criterion_id_requirement_id_pk" PRIMARY KEY("criterion_id","requirement_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "criterion_requirement" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "evaluation_scenario" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"declared_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "price_line" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"unit" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "requirement" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"ordinal" integer NOT NULL,
|
||||||
|
"kind" "requirement_kind" NOT NULL,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"response_obligation" text,
|
||||||
|
"provenance_document_id" uuid,
|
||||||
|
"provenance_page" integer,
|
||||||
|
"provenance_span" jsonb,
|
||||||
|
"confirmation_state" "confirmation_state" DEFAULT 'unconfirmed' NOT NULL,
|
||||||
|
"confirmed_by" uuid,
|
||||||
|
"confirmed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "requirement" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "scenario_quantity" (
|
||||||
|
"scenario_id" uuid NOT NULL,
|
||||||
|
"price_line_id" uuid NOT NULL,
|
||||||
|
"quantity" numeric(14, 3) NOT NULL,
|
||||||
|
CONSTRAINT "scenario_quantity_scenario_id_price_line_id_pk" PRIMARY KEY("scenario_id","price_line_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "scenario_quantity" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "solicitation" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"issuer_org_id" uuid NOT NULL,
|
||||||
|
"local_category_id" uuid NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"stage" "solicitation_stage" DEFAULT 'draft' NOT NULL,
|
||||||
|
"issued_at" timestamp with time zone,
|
||||||
|
"responses_due_at" timestamp with time zone,
|
||||||
|
"acknowledgement_required" boolean DEFAULT false NOT NULL,
|
||||||
|
"volume_band" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "solicitation" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "solicitation_amendment" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"class" "amendment_class" NOT NULL,
|
||||||
|
"summary" text NOT NULL,
|
||||||
|
"requirement_ids" uuid[] DEFAULT '{}' NOT NULL,
|
||||||
|
"deadline_extended_to" timestamp with time zone,
|
||||||
|
"authored_by" uuid NOT NULL,
|
||||||
|
"authored_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "solicitation_document" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"blob_ref" text NOT NULL,
|
||||||
|
"sha256" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "solicitation_document" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "answer" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"requirement_id" uuid NOT NULL,
|
||||||
|
"coverage" "coverage" NOT NULL,
|
||||||
|
"text" text,
|
||||||
|
"provenance_document_id" uuid,
|
||||||
|
"provenance_page" integer,
|
||||||
|
"provenance_span" jsonb,
|
||||||
|
"vendor_attested" boolean DEFAULT false NOT NULL,
|
||||||
|
"reopened_by_amendment_id" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "answer" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "derived_cost" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"scenario_id" uuid NOT NULL,
|
||||||
|
"amount" numeric(16, 2) NOT NULL,
|
||||||
|
"computed_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "derived_cost" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "exception" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"requirement_id" uuid,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"price_delta" numeric(14, 2)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "exception" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "participation" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"vendor_org_id" uuid NOT NULL,
|
||||||
|
"state" "participation_state" DEFAULT 'invited' NOT NULL,
|
||||||
|
"acknowledged_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "participation" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "price_quote" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"price_line_id" uuid NOT NULL,
|
||||||
|
"rate_structure" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "price_quote" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "response" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"vendor_org_id" uuid NOT NULL,
|
||||||
|
"submitted_at" timestamp with time zone,
|
||||||
|
"attested_by" uuid,
|
||||||
|
"attested_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "response" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "response_document" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"blob_ref" text NOT NULL,
|
||||||
|
"sha256" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "response_document" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "gate_result" (
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"requirement_id" uuid NOT NULL,
|
||||||
|
"passed" boolean NOT NULL,
|
||||||
|
"computed_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "gate_result" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "rubric_amendment" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"reason" text NOT NULL,
|
||||||
|
"voided_score_ids" uuid[] DEFAULT '{}' NOT NULL,
|
||||||
|
"authored_by" uuid NOT NULL,
|
||||||
|
"authored_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "score" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"criterion_id" uuid NOT NULL,
|
||||||
|
"points" numeric(8, 2) NOT NULL,
|
||||||
|
"committed_by" uuid NOT NULL,
|
||||||
|
"committed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"derived_from_suggestion_id" uuid,
|
||||||
|
"voided_by_amendment_id" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "score" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "suggestion" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"response_id" uuid NOT NULL,
|
||||||
|
"criterion_id" uuid NOT NULL,
|
||||||
|
"proposed_points" numeric(8, 2),
|
||||||
|
"anchor_matched" text,
|
||||||
|
"citations" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"confidence" numeric(4, 3),
|
||||||
|
"model_version" text NOT NULL,
|
||||||
|
"no_evidence_found" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "suggestion" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "export_event" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"actor_id" uuid NOT NULL,
|
||||||
|
"org_id" uuid NOT NULL,
|
||||||
|
"capability" text NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"classes" text[] NOT NULL,
|
||||||
|
"row_count" integer NOT NULL,
|
||||||
|
"at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "release_matrix" (
|
||||||
|
"version" integer NOT NULL,
|
||||||
|
"class" "disclosure_class" NOT NULL,
|
||||||
|
"level" "release_level" NOT NULL,
|
||||||
|
"released" text NOT NULL,
|
||||||
|
"effective_from" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "release_matrix_version_class_level_pk" PRIMARY KEY("version","class","level")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "document_type" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"expiry_rule" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "retailer_acceptance" (
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"retailer_org_id" uuid NOT NULL,
|
||||||
|
"accepted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"vendor_disclosed_name" text,
|
||||||
|
CONSTRAINT "retailer_acceptance_document_id_retailer_org_id_pk" PRIMARY KEY("document_id","retailer_org_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "retailer_acceptance" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "vault_document" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"vendor_org_id" uuid NOT NULL,
|
||||||
|
"type_id" text NOT NULL,
|
||||||
|
"blob_ref" text NOT NULL,
|
||||||
|
"sha256" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"expiry_state" "expiry_state" DEFAULT 'current' NOT NULL,
|
||||||
|
"expiry_computed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "vault_document" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE "entitlement" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"beneficiary_org_id" uuid NOT NULL,
|
||||||
|
"payer_org_id" uuid NOT NULL,
|
||||||
|
"tier" "tier" NOT NULL,
|
||||||
|
"seats" integer,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"lapsed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "confirmation_event" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"requirement_id" uuid NOT NULL,
|
||||||
|
"confirmed_by" uuid NOT NULL,
|
||||||
|
"corrected" text,
|
||||||
|
"at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sweep_finding" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"kind" "sweep_finding_kind" NOT NULL,
|
||||||
|
"page" integer NOT NULL,
|
||||||
|
"span" jsonb NOT NULL,
|
||||||
|
"quoted_text" text NOT NULL,
|
||||||
|
"resolved_by_requirement_id" uuid,
|
||||||
|
"dismissed_by" uuid,
|
||||||
|
"dismissed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "vendor_omission_report" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"solicitation_id" uuid NOT NULL,
|
||||||
|
"vendor_org_id" uuid NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"reported_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"acted_on_at" timestamp with time zone,
|
||||||
|
"acted_on_by" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "membership" ADD CONSTRAINT "membership_actor_id_actor_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."actor"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "membership" ADD CONSTRAINT "membership_org_id_organization_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "org_capability" ADD CONSTRAINT "org_capability_org_id_organization_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "roster_entry" ADD CONSTRAINT "roster_entry_retailer_org_id_organization_id_fk" FOREIGN KEY ("retailer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "roster_entry" ADD CONSTRAINT "roster_entry_claimed_vendor_org_id_organization_id_fk" FOREIGN KEY ("claimed_vendor_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "category_change_event" ADD CONSTRAINT "category_change_event_retailer_org_id_organization_id_fk" FOREIGN KEY ("retailer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "eligibility" ADD CONSTRAINT "eligibility_retailer_org_id_organization_id_fk" FOREIGN KEY ("retailer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "eligibility" ADD CONSTRAINT "eligibility_vendor_org_id_organization_id_fk" FOREIGN KEY ("vendor_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "eligibility" ADD CONSTRAINT "eligibility_local_category_id_local_category_id_fk" FOREIGN KEY ("local_category_id") REFERENCES "public"."local_category"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "local_category" ADD CONSTRAINT "local_category_retailer_org_id_organization_id_fk" FOREIGN KEY ("retailer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "local_category" ADD CONSTRAINT "local_category_spine_leaf_id_spine_node_id_fk" FOREIGN KEY ("spine_leaf_id") REFERENCES "public"."spine_node"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "criterion" ADD CONSTRAINT "criterion_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "criterion_requirement" ADD CONSTRAINT "criterion_requirement_criterion_id_criterion_id_fk" FOREIGN KEY ("criterion_id") REFERENCES "public"."criterion"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "criterion_requirement" ADD CONSTRAINT "criterion_requirement_requirement_id_requirement_id_fk" FOREIGN KEY ("requirement_id") REFERENCES "public"."requirement"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "evaluation_scenario" ADD CONSTRAINT "evaluation_scenario_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "price_line" ADD CONSTRAINT "price_line_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "requirement" ADD CONSTRAINT "requirement_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "requirement" ADD CONSTRAINT "requirement_provenance_document_id_solicitation_document_id_fk" FOREIGN KEY ("provenance_document_id") REFERENCES "public"."solicitation_document"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "scenario_quantity" ADD CONSTRAINT "scenario_quantity_scenario_id_evaluation_scenario_id_fk" FOREIGN KEY ("scenario_id") REFERENCES "public"."evaluation_scenario"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "scenario_quantity" ADD CONSTRAINT "scenario_quantity_price_line_id_price_line_id_fk" FOREIGN KEY ("price_line_id") REFERENCES "public"."price_line"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "solicitation" ADD CONSTRAINT "solicitation_issuer_org_id_organization_id_fk" FOREIGN KEY ("issuer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "solicitation" ADD CONSTRAINT "solicitation_local_category_id_local_category_id_fk" FOREIGN KEY ("local_category_id") REFERENCES "public"."local_category"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "solicitation_amendment" ADD CONSTRAINT "solicitation_amendment_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "solicitation_document" ADD CONSTRAINT "solicitation_document_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "answer" ADD CONSTRAINT "answer_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "answer" ADD CONSTRAINT "answer_requirement_id_requirement_id_fk" FOREIGN KEY ("requirement_id") REFERENCES "public"."requirement"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "answer" ADD CONSTRAINT "answer_provenance_document_id_response_document_id_fk" FOREIGN KEY ("provenance_document_id") REFERENCES "public"."response_document"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "derived_cost" ADD CONSTRAINT "derived_cost_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "derived_cost" ADD CONSTRAINT "derived_cost_scenario_id_evaluation_scenario_id_fk" FOREIGN KEY ("scenario_id") REFERENCES "public"."evaluation_scenario"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "exception" ADD CONSTRAINT "exception_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "exception" ADD CONSTRAINT "exception_requirement_id_requirement_id_fk" FOREIGN KEY ("requirement_id") REFERENCES "public"."requirement"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "participation" ADD CONSTRAINT "participation_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "participation" ADD CONSTRAINT "participation_vendor_org_id_organization_id_fk" FOREIGN KEY ("vendor_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "price_quote" ADD CONSTRAINT "price_quote_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "price_quote" ADD CONSTRAINT "price_quote_price_line_id_price_line_id_fk" FOREIGN KEY ("price_line_id") REFERENCES "public"."price_line"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "response" ADD CONSTRAINT "response_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "response" ADD CONSTRAINT "response_vendor_org_id_organization_id_fk" FOREIGN KEY ("vendor_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "response_document" ADD CONSTRAINT "response_document_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "gate_result" ADD CONSTRAINT "gate_result_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "gate_result" ADD CONSTRAINT "gate_result_requirement_id_requirement_id_fk" FOREIGN KEY ("requirement_id") REFERENCES "public"."requirement"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "score" ADD CONSTRAINT "score_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "score" ADD CONSTRAINT "score_criterion_id_criterion_id_fk" FOREIGN KEY ("criterion_id") REFERENCES "public"."criterion"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "score" ADD CONSTRAINT "score_derived_from_suggestion_id_suggestion_id_fk" FOREIGN KEY ("derived_from_suggestion_id") REFERENCES "public"."suggestion"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "suggestion" ADD CONSTRAINT "suggestion_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "suggestion" ADD CONSTRAINT "suggestion_criterion_id_criterion_id_fk" FOREIGN KEY ("criterion_id") REFERENCES "public"."criterion"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "retailer_acceptance" ADD CONSTRAINT "retailer_acceptance_document_id_vault_document_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."vault_document"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "retailer_acceptance" ADD CONSTRAINT "retailer_acceptance_retailer_org_id_organization_id_fk" FOREIGN KEY ("retailer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "vault_document" ADD CONSTRAINT "vault_document_vendor_org_id_organization_id_fk" FOREIGN KEY ("vendor_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "vault_document" ADD CONSTRAINT "vault_document_type_id_document_type_id_fk" FOREIGN KEY ("type_id") REFERENCES "public"."document_type"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "entitlement" ADD CONSTRAINT "entitlement_beneficiary_org_id_organization_id_fk" FOREIGN KEY ("beneficiary_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "entitlement" ADD CONSTRAINT "entitlement_payer_org_id_organization_id_fk" FOREIGN KEY ("payer_org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "confirmation_event" ADD CONSTRAINT "confirmation_event_requirement_id_requirement_id_fk" FOREIGN KEY ("requirement_id") REFERENCES "public"."requirement"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sweep_finding" ADD CONSTRAINT "sweep_finding_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sweep_finding" ADD CONSTRAINT "sweep_finding_document_id_solicitation_document_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."solicitation_document"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sweep_finding" ADD CONSTRAINT "sweep_finding_resolved_by_requirement_id_requirement_id_fk" FOREIGN KEY ("resolved_by_requirement_id") REFERENCES "public"."requirement"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "vendor_omission_report" ADD CONSTRAINT "vendor_omission_report_solicitation_id_solicitation_id_fk" FOREIGN KEY ("solicitation_id") REFERENCES "public"."solicitation"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE POLICY "scenario_quantity_read" ON "scenario_quantity" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_solicitation(
|
||||||
|
(SELECT solicitation_id FROM evaluation_scenario WHERE id = scenario_id)
|
||||||
|
), 'scenario_quantity'));--> statement-breakpoint
|
||||||
|
CREATE POLICY "answer_read" ON "answer" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_response(response_id), 'answer_text'));--> statement-breakpoint
|
||||||
|
CREATE POLICY "derived_cost_read" ON "derived_cost" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_response(response_id), 'derived_cost'));--> statement-breakpoint
|
||||||
|
CREATE POLICY "exception_read" ON "exception" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_response(response_id), 'exception_text'));--> statement-breakpoint
|
||||||
|
CREATE POLICY "price_quote_read" ON "price_quote" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_response(response_id), 'price_quote'));--> statement-breakpoint
|
||||||
|
CREATE POLICY "score_read" ON "score" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_response(response_id), 'own_scoring'));
|
||||||
35
migrations/0002_seed_release_matrix.sql
Normal file
35
migrations/0002_seed_release_matrix.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Release matrix, version 1. Fixed by #14.
|
||||||
|
--
|
||||||
|
-- A rule change is a NEW VERSION, never an UPDATE. Without the version history
|
||||||
|
-- a past release cannot be reconstructed when an award is protested — the one
|
||||||
|
-- thing the function form of these rules costs.
|
||||||
|
--
|
||||||
|
-- Mirrored in src/release/matrix.ts, which the evaluation surface renders its
|
||||||
|
-- dimmed cells from. The screen and the policy must not be able to disagree.
|
||||||
|
|
||||||
|
INSERT INTO release_matrix (version, class, level, released) VALUES
|
||||||
|
-- Non-content pre-bid signal: who opened, who acknowledged, who intends to
|
||||||
|
-- bid. This is what #12 actually promised the retailer, and it is the reason
|
||||||
|
-- sealing response content costs them nothing they were counting on.
|
||||||
|
(1, 'vendor_identity', 'invited', 'true'),
|
||||||
|
(1, 'participation_signal', 'invited', 'true'),
|
||||||
|
|
||||||
|
-- Exact quantities behind the acknowledgement gate. The coarse volume band
|
||||||
|
-- stays in the teaser (#14 decision 4).
|
||||||
|
(1, 'scenario_quantity', 'acknowledged', 'true'),
|
||||||
|
|
||||||
|
-- Sealed until the deadline, then the whole field at once.
|
||||||
|
(1, 'answer_text', 'open', 'true'),
|
||||||
|
(1, 'exception_text', 'open', 'true'),
|
||||||
|
(1, 'vault_document', 'open', 'true'),
|
||||||
|
(1, 'price_quote', 'open', 'true'),
|
||||||
|
(1, 'derived_cost', 'open', 'true'),
|
||||||
|
|
||||||
|
-- A vendor's own scoring, released to them at award. They never see anything
|
||||||
|
-- of the field — no count, no identities, no rank including their own.
|
||||||
|
(1, 'own_scoring', 'awarded', 'true');
|
||||||
|
|
||||||
|
-- TWO-ENVELOPE, deferred not declined: moving price_quote and derived_cost to a
|
||||||
|
-- level reached only after scoring locks is a version bump here plus an
|
||||||
|
-- irreversible lock transition. That is the point of choosing a matrix over
|
||||||
|
-- lifecycle logic written inline in each policy.
|
||||||
18
migrations/meta/0000_snapshot.json
Normal file
18
migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "7df8b799-6a58-4fb1-9143-d9a3530a8672",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"views": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
2987
migrations/meta/0001_snapshot.json
Normal file
2987
migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2987
migrations/meta/0002_snapshot.json
Normal file
2987
migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
migrations/meta/_journal.json
Normal file
27
migrations/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785791975838,
|
||||||
|
"tag": "0000_release_level_functions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785792002292,
|
||||||
|
"tag": "0001_initial",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785792003081,
|
||||||
|
"tag": "0002_seed_release_matrix",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1703
package-lock.json
generated
Normal file
1703
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@helmdocs/proposal-system",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Retail RFP transmission system — v1 code layout",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"generate": "drizzle-kit generate",
|
||||||
|
"generate:custom": "drizzle-kit generate --custom",
|
||||||
|
"migrate": "drizzle-kit migrate",
|
||||||
|
"seams": "grep -rn 'new Seam(' src/ --include=*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/audit/index.ts
Normal file
31
src/audit/index.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit trail and evidentiary record. SEAM — owned by #21.
|
||||||
|
*
|
||||||
|
* Four consecutive resolutions have handed protest surface here, and the
|
||||||
|
* inputs are already fixed even though the retention and query design is not:
|
||||||
|
*
|
||||||
|
* #14 the release matrix must be VERSIONED, and release_level's inputs
|
||||||
|
* reconstructable, or a past release cannot be explained. export_event.
|
||||||
|
* #15 score commits, rubric amendments with their voided-score set,
|
||||||
|
* gate results, and model_version per suggestion.
|
||||||
|
* #16 confirmation events with a confirmer per requirement,
|
||||||
|
* "not an obligation" dismissals with their author, amendment
|
||||||
|
* classification, and vendor omission reports WITH whether they were
|
||||||
|
* acted on — the strongest protest artifact on the map.
|
||||||
|
* #12 the invited set at issue, and the stage timeline.
|
||||||
|
*/
|
||||||
|
export interface AuditWriter {
|
||||||
|
recordScoreCommit(e: { responseId: string; criterionId: string; by: string }): Promise<void>
|
||||||
|
recordRubricAmendment(e: { solicitationId: string; voided: string[]; by: string }): Promise<void>
|
||||||
|
recordSweepDismissal(e: { findingId: string; by: string }): Promise<void>
|
||||||
|
recordExport(e: { orgId: string; classes: string[]; rowCount: number }): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditWriter: AuditWriter = {
|
||||||
|
async recordScoreCommit() { throw new Seam('#21', 'the audit trail') },
|
||||||
|
async recordRubricAmendment() { throw new Seam('#21', 'the audit trail') },
|
||||||
|
async recordSweepDismissal() { throw new Seam('#21', 'the audit trail') },
|
||||||
|
async recordExport() { throw new Seam('#21', 'the audit trail') },
|
||||||
|
}
|
||||||
71
src/auth/entitlement.ts
Normal file
71
src/auth/entitlement.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
import { type ActorContext } from '../db/actor.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entitlement assertion helper. Required by #20 decision 3.
|
||||||
|
*
|
||||||
|
* Entitlement is resolved once into actor context and asserted per feature at
|
||||||
|
* each feature's own entry — not as row visibility, and not at one chokepoint.
|
||||||
|
* Writing a document is free; writing it FOR REUSE is paid. The same INSERT
|
||||||
|
* serves both, so an operation deny-list would have to encode intent it
|
||||||
|
* cannot see.
|
||||||
|
*
|
||||||
|
* ── THE GOVERNING PRINCIPLE ─────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Entitlement gates what a vendor may newly undertake,
|
||||||
|
* never what a retailer has already come to rely on.
|
||||||
|
*
|
||||||
|
* Restated by #34 when the free tier was removed. Concretely: expiry state is
|
||||||
|
* computed for every vendor regardless of billing; eligibility never depends
|
||||||
|
* on subscription state; and on lapse, live participations run to completion.
|
||||||
|
* ────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Useful side effect: the call sites of `require` ARE the paid-feature list.
|
||||||
|
* The pricing page is derived from the code rather than maintained alongside it.
|
||||||
|
*/
|
||||||
|
export type Feature =
|
||||||
|
| 'ai.draft'
|
||||||
|
| 'ai.matching'
|
||||||
|
| 'ai.go_no_go'
|
||||||
|
| 'ai.response_completeness'
|
||||||
|
| 'integration.api'
|
||||||
|
| 'sso'
|
||||||
|
|
||||||
|
export const FEATURE_TIER: Record<Feature, 'base' | 'pro' | 'enterprise'> = {
|
||||||
|
'ai.draft': 'pro',
|
||||||
|
'ai.matching': 'pro',
|
||||||
|
'ai.go_no_go': 'pro',
|
||||||
|
'ai.response_completeness': 'pro',
|
||||||
|
'integration.api': 'enterprise',
|
||||||
|
sso: 'enterprise',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Entitled extends ActorContext {
|
||||||
|
tier: 'base' | 'pro' | 'enterprise'
|
||||||
|
lapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotEntitled extends Error {
|
||||||
|
constructor(readonly feature: Feature, readonly required: string) {
|
||||||
|
super(`${feature} requires ${required}`)
|
||||||
|
this.name = 'NotEntitled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert at a feature entry. Throwing is the API — a missing entitlement is
|
||||||
|
* never a silent empty result, because that is what RLS denial looks like and
|
||||||
|
* the two must stay distinguishable.
|
||||||
|
*/
|
||||||
|
export function require(ctx: Entitled, feature: Feature): void {
|
||||||
|
throw new Seam('#35', `entitlement resolution for ${feature} (${ctx.tier})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible-but-locked is the DEFAULT rendering for a missing entitlement.
|
||||||
|
* Absent is a bug — Pro has no upgrade moment if the affordance is invisible
|
||||||
|
* (#34, carried into #23).
|
||||||
|
*/
|
||||||
|
export function affordance(ctx: Entitled, feature: Feature): 'enabled' | 'locked' {
|
||||||
|
return ctx.tier === FEATURE_TIER[feature] || ctx.tier === 'enterprise' ? 'enabled' : 'locked'
|
||||||
|
}
|
||||||
18
src/claiming/index.ts
Normal file
18
src/claiming/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roster-entry claiming and its verification. SEAM — owned by #24.
|
||||||
|
*
|
||||||
|
* Sits BETWEEN the invitation wall and the workspace (#23): the wall addresses
|
||||||
|
* a roster entry by name, and claiming is what turns it into a vendor org.
|
||||||
|
*
|
||||||
|
* #11 decision 1 left the hard part open — "Acme Foods Inc" on one roster and
|
||||||
|
* "Acme Foods" on another must resolve to one org.
|
||||||
|
*/
|
||||||
|
export interface Claiming {
|
||||||
|
claim(rosterEntryId: string, actorId: string): Promise<{ vendorOrgId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claiming: Claiming = {
|
||||||
|
async claim() { throw new Seam('#24', 'roster-entry claiming and its verification') },
|
||||||
|
}
|
||||||
32
src/db/actor.ts
Normal file
32
src/db/actor.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Actor context. Fixed by #11 decision 4.
|
||||||
|
*
|
||||||
|
* Every data path runs inside a transaction that has issued
|
||||||
|
* SET LOCAL app.actor_id / app.org_id / app.capability
|
||||||
|
* and policies read them through current_setting().
|
||||||
|
*
|
||||||
|
* SET LOCAL is transaction-scoped, so it cannot leak across a pooled
|
||||||
|
* connection to the next request — the specific failure that would be
|
||||||
|
* catastrophic here. It also keeps PgBouncer in transaction pooling viable.
|
||||||
|
*/
|
||||||
|
export type Capability = 'issuer' | 'vendor'
|
||||||
|
|
||||||
|
export interface ActorContext {
|
||||||
|
actorId: string
|
||||||
|
orgId: string
|
||||||
|
/** Permissions never resolve against an org alone (#11 decision 2). */
|
||||||
|
capability: Capability
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A job runs with no request behind it. The scheduler still establishes an
|
||||||
|
* actor context — it is simply a system one (#13 decision 4).
|
||||||
|
*/
|
||||||
|
export interface SystemContext {
|
||||||
|
kind: 'system'
|
||||||
|
job: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Context = ActorContext | SystemContext
|
||||||
|
|
||||||
|
export const isSystem = (c: Context): c is SystemContext => 'kind' in c
|
||||||
45
src/db/index.ts
Normal file
45
src/db/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import postgres from 'postgres'
|
||||||
|
import * as schema from './schema/index.js'
|
||||||
|
import { type Context, isSystem } from './actor.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE single database entry point. Required by #11 decision 4:
|
||||||
|
*
|
||||||
|
* "Every data path must run inside a transaction, and any code touching the
|
||||||
|
* pool outside that discipline silently runs with no actor set. The code
|
||||||
|
* layout needs a single database entry point that makes bypassing it
|
||||||
|
* awkward — this is a structural requirement on the layout, not a
|
||||||
|
* convention to remember."
|
||||||
|
*
|
||||||
|
* The pool is not exported. `withActor` is the only way in.
|
||||||
|
*/
|
||||||
|
const pool = postgres(process.env.DATABASE_URL!, { max: 10 })
|
||||||
|
const db = drizzle(pool, { schema })
|
||||||
|
|
||||||
|
export type Db = typeof db
|
||||||
|
export type Tx = Parameters<Parameters<Db['transaction']>[0]>[0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a transaction with actor context established. Everything else in the
|
||||||
|
* codebase takes a `Tx`, never the pool.
|
||||||
|
*
|
||||||
|
* Empty results are the failure mode for authorization bugs here, which are
|
||||||
|
* harder to diagnose than explicit denials — #11 flagged observability around
|
||||||
|
* policy denial as not optional. That belongs at this boundary.
|
||||||
|
*/
|
||||||
|
export async function withActor<T>(ctx: Context, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
if (isSystem(ctx)) {
|
||||||
|
await tx.execute(sql`SELECT set_config('app.system_job', ${ctx.job}, true)`)
|
||||||
|
} else {
|
||||||
|
await tx.execute(sql`SELECT set_config('app.actor_id', ${ctx.actorId}, true)`)
|
||||||
|
await tx.execute(sql`SELECT set_config('app.org_id', ${ctx.orgId}, true)`)
|
||||||
|
await tx.execute(sql`SELECT set_config('app.capability', ${ctx.capability}, true)`)
|
||||||
|
}
|
||||||
|
return fn(tx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema }
|
||||||
60
src/db/privileged.ts
Normal file
60
src/db/privileged.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import { type Tx } from './index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ENUMERATED PRIVILEGED PATHS.
|
||||||
|
*
|
||||||
|
* RLS has sanctioned bypasses. #33 found they were not written down anywhere,
|
||||||
|
* and required "an explicit reviewable list of privileged paths, not just a
|
||||||
|
* hard-to-bypass entry point." This file is that list. It should stay short,
|
||||||
|
* and adding to it should feel like a decision.
|
||||||
|
*
|
||||||
|
* 1. anonymised acceptance count reads rows owned by other retailers
|
||||||
|
* 2. expiry recompute runs with no actor at all
|
||||||
|
* 3. category grant propagation writes eligibility for split/merge
|
||||||
|
*
|
||||||
|
* Nothing else may use SECURITY DEFINER or bypass a policy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. The anonymised acceptance count (#13 decision 2, k-floor from #14).
|
||||||
|
*
|
||||||
|
* The floor is on OTHERS, not on the total: a retailer who has themselves
|
||||||
|
* accepted the document knows they are one of the accepters and would
|
||||||
|
* otherwise subtract themselves. So the count shown to viewer R is
|
||||||
|
* |accepters \ {R}|, displayed only when that reaches K_FLOOR.
|
||||||
|
*
|
||||||
|
* The number is tunable data. The subtraction rule is structure.
|
||||||
|
*/
|
||||||
|
export const K_FLOOR = 3
|
||||||
|
|
||||||
|
export async function acceptanceCountForViewer(
|
||||||
|
tx: Tx,
|
||||||
|
documentId: string,
|
||||||
|
viewerOrgId: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const rows = await tx.execute<{ others: number }>(sql`
|
||||||
|
SELECT count(*)::int AS others
|
||||||
|
FROM retailer_acceptance
|
||||||
|
WHERE document_id = ${documentId}
|
||||||
|
AND retailer_org_id <> ${viewerOrgId}
|
||||||
|
`)
|
||||||
|
const others = rows[0]?.others ?? 0
|
||||||
|
return others >= K_FLOOR ? others : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. Expiry recompute. Called only from the scheduler (src/scheduler).
|
||||||
|
*
|
||||||
|
* Never entitlement-gated. #20 decision 3: if this were gated, a lapsed vendor
|
||||||
|
* would stay qualified forever on stale certificates — a retailer awarding
|
||||||
|
* against an expired policy because a third party stopped paying us.
|
||||||
|
*/
|
||||||
|
export const EXPIRY_RECOMPUTE = 'privileged:expiry-recompute' as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. Category grant propagation (#31 decision 3). A split propagates to every
|
||||||
|
* successor; a merge only to the intersection. Event-stamped writes, never
|
||||||
|
* read-time derivation, and never a silent revocation.
|
||||||
|
*/
|
||||||
|
export const GRANT_PROPAGATION = 'privileged:grant-propagation' as const
|
||||||
84
src/db/schema/category.ts
Normal file
84
src/db/schema/category.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { pgTable, integer, text, timestamp, uuid, primaryKey, pgEnum } from 'drizzle-orm/pg-core'
|
||||||
|
import { organization } from './org.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category. Fixed by #26, amended by #31.
|
||||||
|
*
|
||||||
|
* A curated global spine with retailer-local categories mapped onto it.
|
||||||
|
* The split is load-bearing:
|
||||||
|
*
|
||||||
|
* eligibility -> local_category and stops there. No traversal.
|
||||||
|
* matching -> local_category -> spine_node -> supersession
|
||||||
|
*
|
||||||
|
* #31 decision 1: eligibility references the local category ONLY, so spine
|
||||||
|
* churn can never reach an authorization decision.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Two levels. Eligibility attaches only to leaves; nothing inherits (#26 decision 3). */
|
||||||
|
export const spineNode = pgTable('spine_node', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
/** Null for a group; set for a leaf. */
|
||||||
|
groupId: uuid('group_id'),
|
||||||
|
/** Interop annotation only, never identity (#26 decision 2). */
|
||||||
|
unspscFamilies: text('unspsc_families').array().notNull().default([]),
|
||||||
|
|
||||||
|
// Nodes are immutable. Labels may be corrected; a label is not an identity.
|
||||||
|
validFrom: timestamp('valid_from', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
/** Null = live. New RFPs and grants select only from live nodes. */
|
||||||
|
validTo: timestamp('valid_to', { withTimezone: true }),
|
||||||
|
supersededBy: uuid('superseded_by').array().notNull().default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retailer's own vocabulary. Gets the spine's mutability model per #31
|
||||||
|
* decision 2: immutable identity, editable label, retire via validTo,
|
||||||
|
* structural change is an insert, delete is not offered.
|
||||||
|
*/
|
||||||
|
export const localCategory = pgTable('local_category', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
retailerOrgId: uuid('retailer_org_id').notNull().references(() => organization.id),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
/** Unmapped still works locally — it is simply invisible to matching. */
|
||||||
|
spineLeafId: uuid('spine_leaf_id').references(() => spineNode.id),
|
||||||
|
validFrom: timestamp('valid_from', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
validTo: timestamp('valid_to', { withTimezone: true }),
|
||||||
|
supersededBy: uuid('superseded_by').array().notNull().default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const grantProvenance = pgEnum('grant_provenance', ['manual', 'split_event', 'merge_event'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable state on the (retailer, vendor, local category) triple. Exact and
|
||||||
|
* non-derived — policies never compute a closure over it (#26 decision 3).
|
||||||
|
*/
|
||||||
|
export const eligibility = pgTable('eligibility', {
|
||||||
|
retailerOrgId: uuid('retailer_org_id').notNull().references(() => organization.id),
|
||||||
|
vendorOrgId: uuid('vendor_org_id').notNull().references(() => organization.id),
|
||||||
|
localCategoryId: uuid('local_category_id').notNull().references(() => localCategory.id),
|
||||||
|
provenance: grantProvenance('provenance').notNull().default('manual'),
|
||||||
|
/** Set when provenance is a split or merge — points at the event that wrote this row. */
|
||||||
|
provenanceEventId: uuid('provenance_event_id'),
|
||||||
|
grantedAt: timestamp('granted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.retailerOrgId, t.vendorOrgId, t.localCategoryId] })])
|
||||||
|
|
||||||
|
export const categoryChangeKind = pgEnum('category_change_kind', [
|
||||||
|
'split', 'merge', 'retire', 'remap',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits, merges, retirements and remaps. Grant propagation is an explicit,
|
||||||
|
* event-stamped write — never read-time derivation (#31 decision 3).
|
||||||
|
* A split propagates to every successor; a merge only to the intersection.
|
||||||
|
*/
|
||||||
|
export const categoryChangeEvent = pgTable('category_change_event', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
kind: categoryChangeKind('kind').notNull(),
|
||||||
|
retailerOrgId: uuid('retailer_org_id').references(() => organization.id),
|
||||||
|
fromIds: uuid('from_ids').array().notNull(),
|
||||||
|
toIds: uuid('to_ids').array().notNull(),
|
||||||
|
rowsWritten: integer('rows_written').notNull().default(0),
|
||||||
|
performedBy: uuid('performed_by').notNull(),
|
||||||
|
performedAt: timestamp('performed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
54
src/db/schema/confirmation.ts
Normal file
54
src/db/schema/confirmation.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||||
|
import { requirement, solicitation, solicitationDocument } from './solicitation.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extraction confirmation and the completeness sweep. Fixed by #16.
|
||||||
|
*
|
||||||
|
* The ledger is ordered by extraction uncertainty rather than document order,
|
||||||
|
* so the least reliable items get the freshest attention. Rubber-stamping is
|
||||||
|
* not preventable, only attributable — hence a confirmer per requirement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const confirmationEvent = pgTable('confirmation_event', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
requirementId: uuid('requirement_id').notNull().references(() => requirement.id),
|
||||||
|
confirmedBy: uuid('confirmed_by').notNull(),
|
||||||
|
corrected: text('corrected'),
|
||||||
|
at: timestamp('at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const sweepFindingKind = pgEnum('sweep_finding_kind', [
|
||||||
|
/** Second pass asserts an obligation that no requirement covers. BLOCKS issue. */
|
||||||
|
'asserted_obligation',
|
||||||
|
/** Uncovered span with no obligation asserted. Reported, never gating. */
|
||||||
|
'uncovered_span',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const sweepFinding = pgTable('sweep_finding', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
documentId: uuid('document_id').notNull().references(() => solicitationDocument.id),
|
||||||
|
kind: sweepFindingKind('kind').notNull(),
|
||||||
|
page: integer('page').notNull(),
|
||||||
|
span: jsonb('span').$type<{ start: number; end: number }>().notNull(),
|
||||||
|
quotedText: text('quoted_text').notNull(),
|
||||||
|
/** Resolved by adding a requirement, or by a named human call of "not an obligation". */
|
||||||
|
resolvedByRequirementId: uuid('resolved_by_requirement_id').references(() => requirement.id),
|
||||||
|
dismissedBy: uuid('dismissed_by'),
|
||||||
|
dismissedAt: timestamp('dismissed_at', { withTimezone: true }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The free but biased secondary detector (#33). Vendors report omissions that
|
||||||
|
* help them and stay quiet on ones that hurt them. Recording it is the point:
|
||||||
|
* a vendor who flagged an omission and was ignored is a witness at protest.
|
||||||
|
*/
|
||||||
|
export const vendorOmissionReport = pgTable('vendor_omission_report', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
vendorOrgId: uuid('vendor_org_id').notNull(),
|
||||||
|
body: text('body').notNull(),
|
||||||
|
reportedAt: timestamp('reported_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
actedOnAt: timestamp('acted_on_at', { withTimezone: true }),
|
||||||
|
actedOnBy: uuid('acted_on_by'),
|
||||||
|
})
|
||||||
36
src/db/schema/entitlement.ts
Normal file
36
src/db/schema/entitlement.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { pgEnum, pgTable, text, timestamp, uuid, integer } from 'drizzle-orm/pg-core'
|
||||||
|
import { organization } from './org.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entitlement. Fixed by #20, reversed in part by #34.
|
||||||
|
*
|
||||||
|
* There is no free vendor tier. A $100/mo floor gates participation entirely.
|
||||||
|
* Tiers scale on seats and AI capability — never on volume, never per-retailer.
|
||||||
|
*
|
||||||
|
* Entitlement is NEVER expressed as row visibility. RLS answers "whose row is
|
||||||
|
* this"; a lapsed vendor still owns every row they owned yesterday. Policies
|
||||||
|
* must not reference entitlement at all (#20 decision 3).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const tier = pgEnum('tier', ['base', 'pro', 'enterprise'])
|
||||||
|
|
||||||
|
export const SEATS: Record<'base' | 'pro' | 'enterprise', number | null> = {
|
||||||
|
base: 2,
|
||||||
|
pro: 10,
|
||||||
|
enterprise: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const entitlement = pgTable('entitlement', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
beneficiaryOrgId: uuid('beneficiary_org_id').notNull().references(() => organization.id),
|
||||||
|
/**
|
||||||
|
* In v1 always equals the beneficiary. One column now so retailer sponsorship
|
||||||
|
* can return without a migration (#20 decision 4, deferred rather than declined).
|
||||||
|
*/
|
||||||
|
payerOrgId: uuid('payer_org_id').notNull().references(() => organization.id),
|
||||||
|
tier: tier('tier').notNull(),
|
||||||
|
seats: integer('seats'),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
/** On lapse, live participations run to completion (#34 decision 4). */
|
||||||
|
lapsedAt: timestamp('lapsed_at', { withTimezone: true }),
|
||||||
|
})
|
||||||
9
src/db/schema/index.ts
Normal file
9
src/db/schema/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './org.js'
|
||||||
|
export * from './category.js'
|
||||||
|
export * from './solicitation.js'
|
||||||
|
export * from './response.js'
|
||||||
|
export * from './scoring.js'
|
||||||
|
export * from './release.js'
|
||||||
|
export * from './vault.js'
|
||||||
|
export * from './entitlement.js'
|
||||||
|
export * from './confirmation.js'
|
||||||
63
src/db/schema/org.ts
Normal file
63
src/db/schema/org.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { relations } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
boolean, pgEnum, pgTable, primaryKey, text, timestamp, uuid, unique,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenancy and identity. Fixed by #11.
|
||||||
|
*
|
||||||
|
* Two entity types, deliberately: a roster entry is retailer-scoped and has
|
||||||
|
* no account; a vendor org is global and exists only once a vendor has a
|
||||||
|
* reason to care. Claiming links them — see #24.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions never resolve against an org alone, always against
|
||||||
|
* (org, capability, resource). An org may hold both.
|
||||||
|
*/
|
||||||
|
export const capability = pgEnum('capability', ['issuer', 'vendor'])
|
||||||
|
|
||||||
|
export const organization = pgTable('organization', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
legalName: text('legal_name').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** An org holds each capability explicitly. Holding both is the dual-role case. */
|
||||||
|
export const orgCapability = pgTable('org_capability', {
|
||||||
|
orgId: uuid('org_id').notNull().references(() => organization.id),
|
||||||
|
capability: capability('capability').notNull(),
|
||||||
|
grantedAt: timestamp('granted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.orgId, t.capability] })])
|
||||||
|
|
||||||
|
export const actor = pgTable('actor', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
displayName: text('display_name').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const membership = pgTable('membership', {
|
||||||
|
actorId: uuid('actor_id').notNull().references(() => actor.id),
|
||||||
|
orgId: uuid('org_id').notNull().references(() => organization.id),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.actorId, t.orgId] })])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A retailer's import creates roster entries. They are owned by the retailer,
|
||||||
|
* carry no account, and most are never claimed. #11 decision 1.
|
||||||
|
*/
|
||||||
|
export const rosterEntry = pgTable('roster_entry', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
retailerOrgId: uuid('retailer_org_id').notNull().references(() => organization.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
contactEmail: text('contact_email'),
|
||||||
|
duns: text('duns'),
|
||||||
|
/** Null until claimed. Claiming is #24. */
|
||||||
|
claimedVendorOrgId: uuid('claimed_vendor_org_id').references(() => organization.id),
|
||||||
|
claimedAt: timestamp('claimed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (t) => [unique().on(t.retailerOrgId, t.name)])
|
||||||
|
|
||||||
|
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||||
|
capabilities: many(orgCapability),
|
||||||
|
members: many(membership),
|
||||||
|
}))
|
||||||
70
src/db/schema/release.ts
Normal file
70
src/db/schema/release.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { integer, pgEnum, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-party data release. Fixed by #14.
|
||||||
|
*
|
||||||
|
* One rule set, expressed once. `release_level` collapses the four inputs
|
||||||
|
* #12 named — audience membership, stage, participation state, acknowledgement —
|
||||||
|
* into a single enum. Every sensitive field belongs to a disclosure class.
|
||||||
|
* The matrix says which class opens at which level, and RLS policies compare
|
||||||
|
* the two. Nothing else encodes lifecycle.
|
||||||
|
*
|
||||||
|
* The functions live in migrations/custom because Drizzle cannot diff them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const releaseLevel = pgEnum('release_level', [
|
||||||
|
/** Not in the audience. Sees nothing at all. */
|
||||||
|
'none',
|
||||||
|
/** In the audience, pre-acknowledgement. Teaser only. */
|
||||||
|
'invited',
|
||||||
|
/** Acknowledgement passed. Full RFP body, exact quantities. */
|
||||||
|
'acknowledged',
|
||||||
|
/** Has submitted; the field is still sealed. */
|
||||||
|
'submitted',
|
||||||
|
/** Deadline passed — the whole field opened at once (#14 decision 2). */
|
||||||
|
'open',
|
||||||
|
'evaluating',
|
||||||
|
'finalist',
|
||||||
|
'awarded',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const disclosureClass = pgEnum('disclosure_class', [
|
||||||
|
'vendor_identity',
|
||||||
|
'participation_signal',
|
||||||
|
'scenario_quantity',
|
||||||
|
'answer_text',
|
||||||
|
'exception_text',
|
||||||
|
'price_quote',
|
||||||
|
'derived_cost',
|
||||||
|
'vault_document',
|
||||||
|
'own_scoring',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VERSIONED. Without a version history a past release cannot be reconstructed
|
||||||
|
* when an award is protested — the one thing the function form costs (#14).
|
||||||
|
* Seeded by a custom migration; a rule change is a new version, never an update.
|
||||||
|
*/
|
||||||
|
export const releaseMatrix = pgTable('release_matrix', {
|
||||||
|
version: integer('version').notNull(),
|
||||||
|
class: disclosureClass('class').notNull(),
|
||||||
|
level: releaseLevel('level').notNull(),
|
||||||
|
released: text('released').notNull(),
|
||||||
|
effectiveFrom: timestamp('effective_from', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.version, t.class, t.level] })])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export mirrors read exactly (#14 decision 5). The log is the accountability,
|
||||||
|
* not the restriction — a retailer who bulk-extracts every losing price has
|
||||||
|
* done so on the record.
|
||||||
|
*/
|
||||||
|
export const exportEvent = pgTable('export_event', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
actorId: uuid('actor_id').notNull(),
|
||||||
|
orgId: uuid('org_id').notNull(),
|
||||||
|
capability: text('capability').notNull(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull(),
|
||||||
|
classes: text('classes').array().notNull(),
|
||||||
|
rowCount: integer('row_count').notNull(),
|
||||||
|
at: timestamp('at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
115
src/db/schema/response.ts
Normal file
115
src/db/schema/response.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
boolean, integer, jsonb, numeric, pgEnum, pgPolicy, pgTable, text, timestamp, uuid,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
import { organization } from './org.js'
|
||||||
|
import { evaluationScenario, priceLine, requirement, solicitation } from './solicitation.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response side. #10 established the central inversion: the artifact worth
|
||||||
|
* standardizing is the response, not the RFP.
|
||||||
|
*
|
||||||
|
* Every sensitive table here carries an RLS policy keyed to a disclosure class.
|
||||||
|
* The lifecycle lives in `release_level`, written once — see src/db/schema/release.ts
|
||||||
|
* and migrations/custom.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const participationState = pgEnum('participation_state', [
|
||||||
|
'invited', 'opened', 'acknowledged', 'intends_to_bid', 'submitted', 'withdrawn',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** The audience, materialised as an explicit list at issue (#12 decision 3). */
|
||||||
|
export const participation = pgTable('participation', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
vendorOrgId: uuid('vendor_org_id').notNull().references(() => organization.id),
|
||||||
|
state: participationState('state').notNull().default('invited'),
|
||||||
|
acknowledgedAt: timestamp('acknowledged_at', { withTimezone: true }),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
export const response = pgTable('response', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
vendorOrgId: uuid('vendor_org_id').notNull().references(() => organization.id),
|
||||||
|
submittedAt: timestamp('submitted_at', { withTimezone: true }),
|
||||||
|
attestedBy: uuid('attested_by'),
|
||||||
|
attestedAt: timestamp('attested_at', { withTimezone: true }),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
export const responseDocument = pgTable('response_document', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
blobRef: text('blob_ref').notNull(),
|
||||||
|
sha256: text('sha256').notNull(),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/** Three-state, per #10. Absence is representable, which is the whole point. */
|
||||||
|
export const coverage = pgEnum('coverage', ['answered', 'not_answered', 'indeterminate'])
|
||||||
|
|
||||||
|
export const answer = pgTable('answer', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
requirementId: uuid('requirement_id').notNull().references(() => requirement.id),
|
||||||
|
coverage: coverage('coverage').notNull(),
|
||||||
|
text: text('text'),
|
||||||
|
provenanceDocumentId: uuid('provenance_document_id').references(() => responseDocument.id),
|
||||||
|
provenancePage: integer('provenance_page'),
|
||||||
|
provenanceSpan: jsonb('provenance_span').$type<{ start: number; end: number }>(),
|
||||||
|
/** The vendor attests to their own indeterminates at submit (#10, #16 decision 4). */
|
||||||
|
vendorAttested: boolean('vendor_attested').notNull().default(false),
|
||||||
|
/** Reopened by a substantive amendment, one answer at a time (#16 decision 3). */
|
||||||
|
reopenedByAmendmentId: uuid('reopened_by_amendment_id'),
|
||||||
|
}, () => [
|
||||||
|
pgPolicy('answer_read', {
|
||||||
|
for: 'select',
|
||||||
|
using: sql`class_released(release_level_for_response(response_id), 'answer_text')`,
|
||||||
|
}),
|
||||||
|
]).enableRLS()
|
||||||
|
|
||||||
|
/** Simultaneously a risk flag and a pricing line (#22). */
|
||||||
|
export const exception = pgTable('exception', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
requirementId: uuid('requirement_id').references(() => requirement.id),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
priceDelta: numeric('price_delta', { precision: 14, scale: 2 }),
|
||||||
|
}, () => [
|
||||||
|
pgPolicy('exception_read', {
|
||||||
|
for: 'select',
|
||||||
|
using: sql`class_released(release_level_for_response(response_id), 'exception_text')`,
|
||||||
|
}),
|
||||||
|
]).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price is a rate function, not a scalar (#22). The shape is closed by #29 —
|
||||||
|
* it must be computable, because #15 decision 2 bars any model fallback.
|
||||||
|
*/
|
||||||
|
export const priceQuote = pgTable('price_quote', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
priceLineId: uuid('price_line_id').notNull().references(() => priceLine.id),
|
||||||
|
rateStructure: jsonb('rate_structure').notNull(),
|
||||||
|
}, () => [
|
||||||
|
pgPolicy('price_quote_read', {
|
||||||
|
for: 'select',
|
||||||
|
using: sql`class_released(release_level_for_response(response_id), 'price_quote')`,
|
||||||
|
}),
|
||||||
|
]).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comparison figure against the declared scenario — NOT contract value.
|
||||||
|
* Requirements contracts carry estimate-only quantities (#22), so anything
|
||||||
|
* presenting this as contract value misrepresents comparability (#15).
|
||||||
|
*/
|
||||||
|
export const derivedCost = pgTable('derived_cost', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
scenarioId: uuid('scenario_id').notNull().references(() => evaluationScenario.id),
|
||||||
|
amount: numeric('amount', { precision: 16, scale: 2 }).notNull(),
|
||||||
|
computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, () => [
|
||||||
|
pgPolicy('derived_cost_read', {
|
||||||
|
for: 'select',
|
||||||
|
using: sql`class_released(release_level_for_response(response_id), 'derived_cost')`,
|
||||||
|
}),
|
||||||
|
]).enableRLS()
|
||||||
75
src/db/schema/scoring.ts
Normal file
75
src/db/schema/scoring.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
boolean, jsonb, numeric, pgEnum, pgPolicy, pgTable, text, timestamp, uuid,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
import { criterion, requirement } from './solicitation.js'
|
||||||
|
import { response } from './response.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoring. Fixed by #15.
|
||||||
|
*
|
||||||
|
* Two objects, deliberately separate: a suggestion is evidence with a proposal
|
||||||
|
* attached; a score is a human act. There is no configuration that promotes
|
||||||
|
* one to the other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What the model proposed. Never an award basis on its own. */
|
||||||
|
export const suggestion = pgTable('suggestion', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
criterionId: uuid('criterion_id').notNull().references(() => criterion.id),
|
||||||
|
proposedPoints: numeric('proposed_points', { precision: 8, scale: 2 }),
|
||||||
|
/** The scale anchor matched, not just the integer — the anchor is load-bearing (#22). */
|
||||||
|
anchorMatched: text('anchor_matched'),
|
||||||
|
citations: jsonb('citations').$type<
|
||||||
|
Array<{ documentId: string; page: number; span: { start: number; end: number } }>
|
||||||
|
>().notNull().default([]),
|
||||||
|
confidence: numeric('confidence', { precision: 4, scale: 3 }),
|
||||||
|
/** Ordering only. Never gates, never auto-accepts. */
|
||||||
|
modelVersion: text('model_version').notNull(),
|
||||||
|
/** True where every mapped requirement was unanswered — no points are proposed. */
|
||||||
|
noEvidenceFound: boolean('no_evidence_found').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score of record. ALWAYS human-attributed. Never re-derived, so a model
|
||||||
|
* upgrade mid-evaluation cannot move it.
|
||||||
|
*/
|
||||||
|
export const score = pgTable('score', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
criterionId: uuid('criterion_id').notNull().references(() => criterion.id),
|
||||||
|
points: numeric('points', { precision: 8, scale: 2 }).notNull(),
|
||||||
|
committedBy: uuid('committed_by').notNull(),
|
||||||
|
committedAt: timestamp('committed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
derivedFromSuggestionId: uuid('derived_from_suggestion_id').references(() => suggestion.id),
|
||||||
|
/** Set when a rubric amendment voids this score; it must be re-committed. */
|
||||||
|
voidedByAmendmentId: uuid('voided_by_amendment_id'),
|
||||||
|
}, () => [
|
||||||
|
pgPolicy('score_read', {
|
||||||
|
for: 'select',
|
||||||
|
using: sql`class_released(release_level_for_response(response_id), 'own_scoring')`,
|
||||||
|
}),
|
||||||
|
]).enableRLS()
|
||||||
|
|
||||||
|
/** Evaluated before scoring. A failure disqualifies regardless of points (#22, #15). */
|
||||||
|
export const gateResult = pgTable('gate_result', {
|
||||||
|
responseId: uuid('response_id').notNull().references(() => response.id),
|
||||||
|
requirementId: uuid('requirement_id').notNull().references(() => requirement.id),
|
||||||
|
passed: boolean('passed').notNull(),
|
||||||
|
computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amending the frozen criterion -> requirement mapping. Voids every committed
|
||||||
|
* score under the old mapping and names its author (#15 decision 4).
|
||||||
|
*/
|
||||||
|
export const rubricAmendment = pgTable('rubric_amendment', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull(),
|
||||||
|
reason: text('reason').notNull(),
|
||||||
|
voidedScoreIds: uuid('voided_score_ids').array().notNull().default([]),
|
||||||
|
authoredBy: uuid('authored_by').notNull(),
|
||||||
|
authoredAt: timestamp('authored_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
151
src/db/schema/solicitation.ts
Normal file
151
src/db/schema/solicitation.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
boolean, integer, jsonb, numeric, pgEnum, pgPolicy, pgTable, primaryKey,
|
||||||
|
text, timestamp, uuid,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
import { localCategory } from './category.js'
|
||||||
|
import { organization } from './org.js'
|
||||||
|
|
||||||
|
/** #12 decision 2: the audience never changes; what a participant can see does. */
|
||||||
|
export const solicitationStage = pgEnum('solicitation_stage', [
|
||||||
|
'draft', 'open', 'evaluating', 'finalists', 'awarded', 'cancelled',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six structurally distinct requirement kinds found in real solicitations
|
||||||
|
* (#22). Kind routes decisions at evaluation (#15 decision 2) and gates
|
||||||
|
* confirmation (#16 decision 1), so a miskind is a correctness bug.
|
||||||
|
*/
|
||||||
|
export const requirementKind = pgEnum('requirement_kind', [
|
||||||
|
'mandatory_gate',
|
||||||
|
'scored_criterion',
|
||||||
|
'certification_form',
|
||||||
|
'compliance_schedule',
|
||||||
|
'pricing',
|
||||||
|
'narrative',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const solicitation = pgTable('solicitation', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
issuerOrgId: uuid('issuer_org_id').notNull().references(() => organization.id),
|
||||||
|
/** #31 decision 1 — the retailer's own vocabulary, never the spine. */
|
||||||
|
localCategoryId: uuid('local_category_id').notNull().references(() => localCategory.id),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
stage: solicitationStage('stage').notNull().default('draft'),
|
||||||
|
issuedAt: timestamp('issued_at', { withTimezone: true }),
|
||||||
|
/** The moment every sealed response opens at once (#14 decision 2). */
|
||||||
|
responsesDueAt: timestamp('responses_due_at', { withTimezone: true }),
|
||||||
|
/** Per-RFP confidentiality gate (#12 decision 4). Optional by design. */
|
||||||
|
acknowledgementRequired: boolean('acknowledgement_required').notNull().default(false),
|
||||||
|
/** Coarse band shown pre-acknowledgement; exact quantities are gated (#14 decision 4). */
|
||||||
|
volumeBand: text('volume_band'),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/** Legally authoritative. The overlay below is derived from it and may never diverge (#23). */
|
||||||
|
export const solicitationDocument = pgTable('solicitation_document', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
kind: text('kind').notNull(),
|
||||||
|
blobRef: text('blob_ref').notNull(),
|
||||||
|
sha256: text('sha256').notNull(),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
export const confirmationState = pgEnum('confirmation_state', [
|
||||||
|
'unconfirmed', 'confirmed', 'corrected',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const requirement = pgTable('requirement', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
ordinal: integer('ordinal').notNull(),
|
||||||
|
kind: requirementKind('kind').notNull(),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
responseObligation: text('response_obligation'),
|
||||||
|
|
||||||
|
// Provenance into the authoritative document. Span coverage over these is
|
||||||
|
// how the completeness sweep computes what nothing points at (#16 decision 2).
|
||||||
|
provenanceDocumentId: uuid('provenance_document_id').references(() => solicitationDocument.id),
|
||||||
|
provenancePage: integer('provenance_page'),
|
||||||
|
provenanceSpan: jsonb('provenance_span').$type<{ start: number; end: number }>(),
|
||||||
|
|
||||||
|
confirmationState: confirmationState('confirmation_state').notNull().default('unconfirmed'),
|
||||||
|
confirmedBy: uuid('confirmed_by'),
|
||||||
|
confirmedAt: timestamp('confirmed_at', { withTimezone: true }),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scale is per-document and must never be normalised (#22, #10).
|
||||||
|
* The prose anchor is the load-bearing part, not the integer.
|
||||||
|
*/
|
||||||
|
export const criterion = pgTable('criterion', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
maxPoints: numeric('max_points', { precision: 8, scale: 2 }).notNull(),
|
||||||
|
weight: numeric('weight', { precision: 8, scale: 2 }).notNull(),
|
||||||
|
/** e.g. [{ points: 3, label: 'Average', anchor: 'Meets the stated need…' }] */
|
||||||
|
scaleAnchors: jsonb('scale_anchors').$type<
|
||||||
|
Array<{ points: number; label: string; anchor: string }>
|
||||||
|
>().notNull().default([]),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTHORED, NOT EXTRACTED — the one object with no span to point at (#33).
|
||||||
|
* Frozen at issue; amending it voids affected committed scores (#15 decision 4).
|
||||||
|
*/
|
||||||
|
export const criterionRequirement = pgTable('criterion_requirement', {
|
||||||
|
criterionId: uuid('criterion_id').notNull().references(() => criterion.id),
|
||||||
|
requirementId: uuid('requirement_id').notNull().references(() => requirement.id),
|
||||||
|
authoredBy: uuid('authored_by').notNull(),
|
||||||
|
frozenAt: timestamp('frozen_at', { withTimezone: true }),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.criterionId, t.requirementId] })]).enableRLS()
|
||||||
|
|
||||||
|
export const priceLine = pgTable('price_line', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
unit: text('unit').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Published with the RFP so price is bindable up front (#10). */
|
||||||
|
export const evaluationScenario = pgTable('evaluation_scenario', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
declaredAt: timestamp('declared_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact basket. Sits behind the acknowledgement gate (#14 decision 4) —
|
||||||
|
* this is the sensitive half of the scenario, and its own disclosure class.
|
||||||
|
*/
|
||||||
|
export const scenarioQuantity = pgTable('scenario_quantity', {
|
||||||
|
scenarioId: uuid('scenario_id').notNull().references(() => evaluationScenario.id),
|
||||||
|
priceLineId: uuid('price_line_id').notNull().references(() => priceLine.id),
|
||||||
|
quantity: numeric('quantity', { precision: 14, scale: 3 }).notNull(),
|
||||||
|
}, (t) => [
|
||||||
|
primaryKey({ columns: [t.scenarioId, t.priceLineId] }),
|
||||||
|
pgPolicy('scenario_quantity_read', {
|
||||||
|
for: 'select',
|
||||||
|
using: sql`class_released(release_level_for_solicitation(
|
||||||
|
(SELECT solicitation_id FROM evaluation_scenario WHERE id = scenario_id)
|
||||||
|
), 'scenario_quantity')`,
|
||||||
|
}),
|
||||||
|
]).enableRLS()
|
||||||
|
|
||||||
|
export const amendmentClass = pgEnum('amendment_class', ['substantive', 'clarification'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #16 decision 3. The test is whether a bidder who already answered would
|
||||||
|
* need to answer differently — not whether the text changed.
|
||||||
|
*/
|
||||||
|
export const solicitationAmendment = pgTable('solicitation_amendment', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
|
||||||
|
class: amendmentClass('class').notNull(),
|
||||||
|
summary: text('summary').notNull(),
|
||||||
|
requirementIds: uuid('requirement_ids').array().notNull().default([]),
|
||||||
|
/** Substantive amendments extend the deadline; clarifications do not. */
|
||||||
|
deadlineExtendedTo: timestamp('deadline_extended_to', { withTimezone: true }),
|
||||||
|
authoredBy: uuid('authored_by').notNull(),
|
||||||
|
authoredAt: timestamp('authored_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
51
src/db/schema/vault.ts
Normal file
51
src/db/schema/vault.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { pgEnum, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||||
|
import { organization } from './org.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cross-retailer document vault. Fixed by #13.
|
||||||
|
*
|
||||||
|
* Through-line: we establish facts, retailers make judgements. The vendor
|
||||||
|
* attests, we validate only machine-checkable structure, and verification is
|
||||||
|
* a graph of retailer acceptances rather than a platform assertion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** We curate a small typed catalogue. Contents are #27. */
|
||||||
|
export const documentType = pgTable('document_type', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
/** Each type carries a validator and an expiry rule — that is why it is typed. */
|
||||||
|
expiryRule: text('expiry_rule'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const expiryState = pgEnum('expiry_state', ['current', 'expiring_soon', 'expired'])
|
||||||
|
|
||||||
|
export const vaultDocument = pgTable('vault_document', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
vendorOrgId: uuid('vendor_org_id').notNull().references(() => organization.id),
|
||||||
|
typeId: text('type_id').notNull().references(() => documentType.id),
|
||||||
|
blobRef: text('blob_ref').notNull(),
|
||||||
|
sha256: text('sha256').notNull(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
/**
|
||||||
|
* Derived, recomputed on a clock rather than only on write — the reason the
|
||||||
|
* layout needs a scheduler at all (#13 decision 4). Never entitlement-gated:
|
||||||
|
* entitlement gates vendor convenience, never retailer-facing correctness.
|
||||||
|
*/
|
||||||
|
expiryState: expiryState('expiry_state').notNull().default('current'),
|
||||||
|
expiryComputedAt: timestamp('expiry_computed_at', { withTimezone: true }),
|
||||||
|
}).enableRLS()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acceptances accumulate into the reuse benefit. Cross-retailer signals are
|
||||||
|
* anonymous by default: a retailer sees an unattributed count, never which.
|
||||||
|
*
|
||||||
|
* Reading these rows across retailers is a PRIVILEGED PATH — see
|
||||||
|
* src/db/privileged.ts. The k-floor lives there, not in application code.
|
||||||
|
*/
|
||||||
|
export const retailerAcceptance = pgTable('retailer_acceptance', {
|
||||||
|
documentId: uuid('document_id').notNull().references(() => vaultDocument.id),
|
||||||
|
retailerOrgId: uuid('retailer_org_id').notNull().references(() => organization.id),
|
||||||
|
acceptedAt: timestamp('accepted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
/** Disclosure is the vendor's asset to trade — opt-in, per document per retailer. */
|
||||||
|
vendorDisclosedName: text('vendor_disclosed_name'),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.documentId, t.retailerOrgId] })]).enableRLS()
|
||||||
41
src/extraction/completeness.ts
Normal file
41
src/extraction/completeness.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The completeness sweep. Fixed by #16 decision 2.
|
||||||
|
*
|
||||||
|
* ── INDEPENDENCE IS THE MECHANISM ───────────────────────────────────────
|
||||||
|
*
|
||||||
|
* This module MUST NOT share a prompt or a code path with the extractor.
|
||||||
|
* It asks a DIFFERENT question — "is there an obligation in this span" —
|
||||||
|
* rather than re-running the same extraction. Two independent passes
|
||||||
|
* disagreeing is the entire signal; sharing anything destroys it.
|
||||||
|
*
|
||||||
|
* This is a constraint on the layout, not a style preference. If you find
|
||||||
|
* yourself importing from ./index.ts, stop.
|
||||||
|
* ────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Span coverage over requirement provenance is computed deterministically —
|
||||||
|
* the schema already carries the spans. Two treatments:
|
||||||
|
*
|
||||||
|
* asserted_obligation second pass says obligation, nothing covers -> BLOCKS issue
|
||||||
|
* uncovered_span uncovered, no obligation asserted -> report only
|
||||||
|
*
|
||||||
|
* A coverage percentage was rejected: RFPs are mostly non-requirement prose,
|
||||||
|
* so a threshold measures document style rather than extraction quality.
|
||||||
|
*
|
||||||
|
* Also runs over spans covered ONLY by informational requirements, so an
|
||||||
|
* obligation miskinded as informational — which would otherwise be
|
||||||
|
* bulk-accepted and never seen — surfaces as a blocking disagreement.
|
||||||
|
*
|
||||||
|
* Recorded as unfixed: a miss BOTH passes share is still invisible.
|
||||||
|
*/
|
||||||
|
export interface SweepFinding {
|
||||||
|
kind: 'asserted_obligation' | 'uncovered_span'
|
||||||
|
page: number
|
||||||
|
span: { start: number; end: number }
|
||||||
|
quotedText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sweep(): SweepFinding[] {
|
||||||
|
throw new Seam('#30', 'the sweep needs a corpus with labelled known-misses to have any recall')
|
||||||
|
}
|
||||||
41
src/extraction/index.ts
Normal file
41
src/extraction/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE MODULE, USED TWICE. The largest shared seam in the system (#23).
|
||||||
|
*
|
||||||
|
* retailer document -> requirement overlay
|
||||||
|
* vendor document -> answer set
|
||||||
|
*
|
||||||
|
* Same shape, same confirm step, one implementation. #4 settled that both
|
||||||
|
* sides submit documents and both get extracted, so the symmetry is in the
|
||||||
|
* domain, not a convenience.
|
||||||
|
*/
|
||||||
|
export interface ExtractedSpan {
|
||||||
|
documentId: string
|
||||||
|
page: number
|
||||||
|
span: { start: number; end: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedRequirement {
|
||||||
|
ordinal: number
|
||||||
|
kind: string
|
||||||
|
text: string
|
||||||
|
provenance: ExtractedSpan
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedAnswer {
|
||||||
|
requirementId: string
|
||||||
|
coverage: 'answered' | 'not_answered' | 'indeterminate'
|
||||||
|
text: string | null
|
||||||
|
provenance: ExtractedSpan | null
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRequirements(): ExtractedRequirement[] {
|
||||||
|
throw new Seam('#30', 'extraction needs the corpus before it can be built or measured')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAnswers(): ExtractedAnswer[] {
|
||||||
|
throw new Seam('#30', 'extraction needs the corpus before it can be built or measured')
|
||||||
|
}
|
||||||
17
src/helmdocs/index.ts
Normal file
17
src/helmdocs/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HelmDocs integration boundary. SEAM — owned by #19.
|
||||||
|
*
|
||||||
|
* #2 settled that this is a separate deployable and HelmDocs integrates later
|
||||||
|
* across an API/MCP boundary. The bidder-side engine in novelpad-desktop
|
||||||
|
* (packages/ai/src/agents/*, packages/core/src/solicitation-extraction/) is
|
||||||
|
* prior art for the THINKING and the prompts — not a runtime to import.
|
||||||
|
*/
|
||||||
|
export interface HelmdocsBridge {
|
||||||
|
publishSolicitation(id: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const helmdocsBridge: HelmdocsBridge = {
|
||||||
|
async publishSolicitation() { throw new Seam('#19', 'the HelmDocs integration boundary') },
|
||||||
|
}
|
||||||
22
src/http/context.ts
Normal file
22
src/http/context.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { type ActorContext, type Capability } from '../db/actor.js'
|
||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request -> actor context. The other half of #11 decision 4.
|
||||||
|
*
|
||||||
|
* Capability is a ROUTING concern, not a component concern (#23 decision 1):
|
||||||
|
* every route carries it, so a deep link cannot land in the wrong context.
|
||||||
|
* The UI boundary and the authorization boundary are drawn in the same place.
|
||||||
|
*/
|
||||||
|
export interface Resolved extends ActorContext {
|
||||||
|
tier: 'base' | 'pro' | 'enterprise'
|
||||||
|
lapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entitlement is resolved HERE, once, into the same context object — and then
|
||||||
|
* asserted per feature. It is never expressed as row visibility (#20).
|
||||||
|
*/
|
||||||
|
export function resolve(_req: Request, _capability: Capability): Resolved {
|
||||||
|
throw new Seam('#35', 'session resolution and entitlement lookup')
|
||||||
|
}
|
||||||
16
src/http/routes/index.ts
Normal file
16
src/http/routes/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Two nav trees under one shell. Fixed by #23 decision 1.
|
||||||
|
*
|
||||||
|
* issuing/ RFPs · Roster · Groups · Evaluations
|
||||||
|
* bidding/ Opportunities · Bids · Vault · Profile
|
||||||
|
* public/ the pre-authorization surface
|
||||||
|
*
|
||||||
|
* Switching context replaces the navigation wholesale. The two capability
|
||||||
|
* trees never share a screen — an org holding both is two authorization
|
||||||
|
* contexts that cannot see each other (#11 decision 2), and a dual-role org's
|
||||||
|
* vendor vault is therefore invisible to its own issuing side.
|
||||||
|
*/
|
||||||
|
export const NAV = {
|
||||||
|
issuing: ['rfps', 'roster', 'groups', 'evaluations'],
|
||||||
|
bidding: ['opportunities', 'bids', 'vault', 'profile'],
|
||||||
|
} as const
|
||||||
19
src/http/routes/public/index.ts
Normal file
19
src/http/routes/public/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* The pre-authorization surface. Fixed by #23 decision 4.
|
||||||
|
*
|
||||||
|
* Lives OUTSIDE the authenticated shell and needs its own minimal entry point:
|
||||||
|
* it renders for an actor with no org, no capability and no subscription.
|
||||||
|
*
|
||||||
|
* The invitation shows issuer, category and deadline — and nothing else.
|
||||||
|
* Scope, volume band and terms are RFP content and stay behind the wall.
|
||||||
|
*
|
||||||
|
* The full vendor sequence:
|
||||||
|
* invited -> subscribe -> teaser -> acknowledgement -> full RFP
|
||||||
|
* (a billing gate, then a confidentiality gate, doing different jobs)
|
||||||
|
*/
|
||||||
|
export interface InvitationView {
|
||||||
|
issuerName: string
|
||||||
|
categoryLabel: string
|
||||||
|
responsesDueAt: Date
|
||||||
|
invitedAs: string
|
||||||
|
}
|
||||||
20
src/integration/index.ts
Normal file
20
src/integration/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retailer integration and export paths. SEAM — owned by #18.
|
||||||
|
*
|
||||||
|
* Fixed already, so this ticket cannot widen it:
|
||||||
|
* - export MIRRORS READ EXACTLY. No second rule set (#14 decision 5).
|
||||||
|
* - every export writes an export_event.
|
||||||
|
* - an amendment is an OUTBOUND EVENT, not only an in-app state change (#16).
|
||||||
|
*
|
||||||
|
* #3 established we owe a hand-back to the retailer's existing system of
|
||||||
|
* record — Coupa, Bamboo Rose, Partners Online, Oracle-adjacent, or none at all.
|
||||||
|
*/
|
||||||
|
export interface Exporter {
|
||||||
|
export(req: { solicitationId: string; classes: string[] }): Promise<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exporter: Exporter = {
|
||||||
|
async export() { throw new Seam('#18', 'retailer integration and export paths') },
|
||||||
|
}
|
||||||
16
src/kickoff/index.ts
Normal file
16
src/kickoff/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-award kickoff and coordination. SEAM — owned by #17.
|
||||||
|
*
|
||||||
|
* Begins where the release matrix reaches 'awarded'. Note that release NEVER
|
||||||
|
* CONTRACTS (#14): everything opened stays open, so anything built here starts
|
||||||
|
* from a permanent disclosure state rather than a revocable one.
|
||||||
|
*/
|
||||||
|
export interface Kickoff {
|
||||||
|
begin(solicitationId: string, awardedResponseId: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kickoff: Kickoff = {
|
||||||
|
async begin() { throw new Seam('#17', 'post-award kickoff and coordination tooling') },
|
||||||
|
}
|
||||||
26
src/pricing/index.ts
Normal file
26
src/pricing/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate function evaluation. Shape closed by #29.
|
||||||
|
*
|
||||||
|
* Price is NOT a scalar (#22). Real structures look like:
|
||||||
|
* - unit cost x frequency x quantity, where frequency hides in a footnote
|
||||||
|
* - MAX(1/12 x minimum annual guarantee, % of gross receipts)
|
||||||
|
* - piecewise bands varying by category, threshold, or contract year
|
||||||
|
*
|
||||||
|
* This is deterministic by #15 decision 2 — a model may never evaluate it and
|
||||||
|
* there is no fallback path. So the v1 rate structure types must be CLOSED and
|
||||||
|
* computable: a shape the code cannot evaluate has nowhere to go.
|
||||||
|
*/
|
||||||
|
export interface RateStructure {
|
||||||
|
readonly kind: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioQuantities {
|
||||||
|
[priceLineId: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Produces derived_cost: a comparison figure against the declared scenario, never contract value. */
|
||||||
|
export function evaluate(_rate: RateStructure, _q: ScenarioQuantities): number {
|
||||||
|
throw new Seam('#29', 'the v1 rate structure types')
|
||||||
|
}
|
||||||
58
src/release/matrix.ts
Normal file
58
src/release/matrix.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* The v1 release matrix, version 1. Fixed by #14.
|
||||||
|
*
|
||||||
|
* This is the SAME data the SQL function reads — seeded by
|
||||||
|
* migrations/custom/0002_seed_release_matrix.sql. It lives here as well
|
||||||
|
* because the evaluation surface renders dimmed cells from it (#23), and the
|
||||||
|
* screen and the policy must not be able to disagree.
|
||||||
|
*
|
||||||
|
* A rule change is a NEW VERSION, never an update. Without the version
|
||||||
|
* history a past release cannot be reconstructed for a protest.
|
||||||
|
*/
|
||||||
|
export const MATRIX_VERSION = 1
|
||||||
|
|
||||||
|
export type ReleaseLevel =
|
||||||
|
| 'none' | 'invited' | 'acknowledged' | 'submitted'
|
||||||
|
| 'open' | 'evaluating' | 'finalist' | 'awarded'
|
||||||
|
|
||||||
|
export type DisclosureClass =
|
||||||
|
| 'vendor_identity' | 'participation_signal' | 'scenario_quantity'
|
||||||
|
| 'answer_text' | 'exception_text' | 'price_quote' | 'derived_cost'
|
||||||
|
| 'vault_document' | 'own_scoring'
|
||||||
|
|
||||||
|
const ORDER: ReleaseLevel[] = [
|
||||||
|
'none', 'invited', 'acknowledged', 'submitted', 'open', 'evaluating', 'finalist', 'awarded',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The level at which each class first opens. Anything below it is withheld. */
|
||||||
|
const OPENS_AT: Record<DisclosureClass, ReleaseLevel> = {
|
||||||
|
// The retailer's pre-bid signal — non-content, and what #12 actually promised them.
|
||||||
|
vendor_identity: 'invited',
|
||||||
|
participation_signal: 'invited',
|
||||||
|
|
||||||
|
// Exact quantities sit behind the acknowledgement gate (#14 decision 4).
|
||||||
|
scenario_quantity: 'acknowledged',
|
||||||
|
|
||||||
|
// Sealed until the deadline. `open` is the moment the whole field opens at
|
||||||
|
// once — the reason bid shopping is structurally impossible (#14 decision 2).
|
||||||
|
answer_text: 'open',
|
||||||
|
exception_text: 'open',
|
||||||
|
vault_document: 'open',
|
||||||
|
price_quote: 'open',
|
||||||
|
derived_cost: 'open',
|
||||||
|
|
||||||
|
// A vendor's own scoring, released to them at award (#14 decision 3).
|
||||||
|
own_scoring: 'awarded',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReleased(cls: DisclosureClass, level: ReleaseLevel): boolean {
|
||||||
|
return ORDER.indexOf(level) >= ORDER.indexOf(OPENS_AT[cls])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TWO-ENVELOPE, deferred not declined (#14 decision 2).
|
||||||
|
*
|
||||||
|
* Moving `price_quote` and `derived_cost` from 'open' to a level reached only
|
||||||
|
* after scoring locks is a one-row change here plus an irreversible lock
|
||||||
|
* transition. That is the point of choosing a matrix over inline policies.
|
||||||
|
*/
|
||||||
20
src/scheduler/index.ts
Normal file
20
src/scheduler/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { withActor } from '../db/index.js'
|
||||||
|
import { recomputeExpiry } from './jobs/expiry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheduled-job entry point. Required by #13 decision 4.
|
||||||
|
*
|
||||||
|
* "Eligibility is a derived value that must be recomputed on a clock, not only
|
||||||
|
* on write. Nothing else on the map has needed a scheduler; this does, and
|
||||||
|
* the layout must have a place for it."
|
||||||
|
*
|
||||||
|
* Jobs establish actor context WITHOUT a request. That is the whole reason
|
||||||
|
* this cannot live behind the HTTP middleware.
|
||||||
|
*/
|
||||||
|
export const JOBS = {
|
||||||
|
'expiry-recompute': recomputeExpiry,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function runJob(name: keyof typeof JOBS): Promise<void> {
|
||||||
|
await withActor({ kind: 'system', job: name }, JOBS[name])
|
||||||
|
}
|
||||||
18
src/scheduler/jobs/expiry.ts
Normal file
18
src/scheduler/jobs/expiry.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Seam } from '../../seam.js'
|
||||||
|
import type { Tx } from '../../db/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute document expiry state, and eligibility from it.
|
||||||
|
*
|
||||||
|
* NEVER ENTITLEMENT-GATED. #20 decision 3 found the leak: if this were gated,
|
||||||
|
* a lapsed vendor would stay qualified forever on stale certificates, and a
|
||||||
|
* retailer would award against an expired policy because a third party stopped
|
||||||
|
* paying us.
|
||||||
|
*
|
||||||
|
* Expiry removes a vendor from FUTURE audience evaluations only. In-flight
|
||||||
|
* participation is never silently revoked — a vendor does not lose a live bid
|
||||||
|
* to a clock (#13 decision 4).
|
||||||
|
*/
|
||||||
|
export async function recomputeExpiry(_tx: Tx): Promise<void> {
|
||||||
|
throw new Seam('#27', 'per-type expiry rules — the catalogue defines them')
|
||||||
|
}
|
||||||
41
src/scoring/rank.ts
Normal file
41
src/scoring/rank.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Seam } from '../seam.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranking. Fixed by #15 decision 3.
|
||||||
|
*
|
||||||
|
* Rank is computed ONLY from committed scores, using the document's own
|
||||||
|
* weights, and stored fractionally — the real tabulation in #22 separates the
|
||||||
|
* winner from the runner-up by 71.81 to 70.89, so rounding erases the result.
|
||||||
|
*
|
||||||
|
* There is NO ordering before commits exist. A published provisional ranking
|
||||||
|
* anchors the evaluator about to score, and leaves no trace doing it.
|
||||||
|
*
|
||||||
|
* Within-document totalling is not normalisation: #10 bans cross-RFP scores
|
||||||
|
* and global ratings, and summing by the RFP's own published weights is the
|
||||||
|
* retailer's own arithmetic.
|
||||||
|
*
|
||||||
|
* There is no win rate, no cross-RFP metric, and no vendor-visible rank —
|
||||||
|
* including their own (#14 decision 3, and the map's standing guardrail).
|
||||||
|
*/
|
||||||
|
export interface Ranked {
|
||||||
|
responseId: string
|
||||||
|
/** Null when the response failed a mandatory gate — disqualification is not a rank. */
|
||||||
|
total: number | null
|
||||||
|
disqualified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-commit triage. Every column here is deterministic; no model judged any of it. */
|
||||||
|
export interface Triage {
|
||||||
|
responseId: string
|
||||||
|
gatesPassed: boolean
|
||||||
|
mandatoryCoverage: { answered: number; total: number }
|
||||||
|
derivedCost: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rank(): Ranked[] {
|
||||||
|
throw new Seam('#35', 'ranking assembly over committed scores')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triage(): Triage[] {
|
||||||
|
throw new Seam('#35', 'pre-commit triage assembly')
|
||||||
|
}
|
||||||
52
src/scoring/routing.ts
Normal file
52
src/scoring/routing.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { requirementKind } from '../db/schema/index.js'
|
||||||
|
|
||||||
|
/** The six kinds, straight from the enum the schema declares. */
|
||||||
|
export type Kind = (typeof requirementKind.enumValues)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision-routing table. Fixed by #15 decision 2.
|
||||||
|
*
|
||||||
|
* ── THE BAR ─────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The model may LOCATE in every lane.
|
||||||
|
* It may DECIDE in only two.
|
||||||
|
*
|
||||||
|
* ────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Deliberately NOT the deterministic-first / LLM-fallback pattern used in
|
||||||
|
* novelpad-desktop's go-no-go agent. Two cases make routing-by-kind strictly
|
||||||
|
* better here:
|
||||||
|
*
|
||||||
|
* - Frequency and quantity multipliers hide in footnotes (#22), and dropping
|
||||||
|
* one silently converts an annual cost into a unit price. A fallback path
|
||||||
|
* hands exactly that arithmetic to a model at the moment it is ambiguous,
|
||||||
|
* and the error reads as high-confidence. Arithmetic is code, always.
|
||||||
|
*
|
||||||
|
* - A mandatory gate disqualifies regardless of score. An ambiguous gate must
|
||||||
|
* escalate to a HUMAN, not to judgement.
|
||||||
|
*
|
||||||
|
* Under fallback, which lane a decision took depends on runtime luck — not
|
||||||
|
* something you can explain in a protest.
|
||||||
|
*/
|
||||||
|
export type Decider = 'code' | 'model_proposes_human_commits'
|
||||||
|
|
||||||
|
export const DECIDED_BY: Record<Kind, Decider> = {
|
||||||
|
mandatory_gate: 'code',
|
||||||
|
certification_form: 'code',
|
||||||
|
compliance_schedule: 'code',
|
||||||
|
pricing: 'code',
|
||||||
|
scored_criterion: 'model_proposes_human_commits',
|
||||||
|
narrative: 'model_proposes_human_commits',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelMayDecide(kind: Kind): boolean {
|
||||||
|
return DECIDED_BY[kind] === 'model_proposes_human_commits'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirmation is required individually exactly where the outcome can move (#16 decision 1). */
|
||||||
|
export function requiresIndividualConfirmation(
|
||||||
|
kind: Kind,
|
||||||
|
isCriterionMapped: boolean,
|
||||||
|
): boolean {
|
||||||
|
return kind === 'mandatory_gate' || isCriterionMapped
|
||||||
|
}
|
||||||
19
src/seam.ts
Normal file
19
src/seam.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* A seam is a place the map has not yet decided.
|
||||||
|
*
|
||||||
|
* Every seam has real types — the signature is the handoff, and a ticket
|
||||||
|
* lands by replacing a body rather than by designing an interface. The
|
||||||
|
* implementation throws, so the skeleton wires up and runs end to end and
|
||||||
|
* fails only where a decision is genuinely missing.
|
||||||
|
*
|
||||||
|
* yarn seams # the todo list, from the code
|
||||||
|
*/
|
||||||
|
export class Seam extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly ticket: `#${number}`,
|
||||||
|
readonly what: string,
|
||||||
|
) {
|
||||||
|
super(`Seam ${ticket}: ${what} is not decided yet.`)
|
||||||
|
this.name = 'Seam'
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "#~/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user