Current controlling engineering contract — Phase 1, Step 1 - City of San Jose, incorporated City of San José — August 2026.
This file is the contract and the file of record. Generated by
build/build_spec_v1_7.pyfrombuild/ledgex_source.py. Do not hand-edit. If an instruction in a chat message conflicts with this file, this file wins until amended by an explicit commit. Amendments require a version bump and a row in §12. (I17)
For Claude Code / any coding agent. This file lives at docs/LEDGEX_SPEC.md. Reference it from CLAUDE.md:
# CLAUDE.md
Read `docs/LEDGEX_SPEC.md` in full before making changes. It defines the
database schema, API contracts, invariants and workflow for this repository.
The invariants in §1 are non-negotiable and are enforced in CI.
Never write jurisdiction-specific logic into `core/`. See §1.I1 and §6.2.Reading order for a new task: §1 invariants → §2 layout → the section for your layer (§3 schema, §4 API, §5 runtime) → §6.1 for the matching task shape → §6.3 definition of done.
| § | Section | Use when |
|---|---|---|
| 0 | How to use this file | Always, before any change. |
| 1 | Invariants and internal-fact gate | Always. Every change is checked against these. |
| 2 | Repository layout | Deciding where a file goes. |
| 3 | Database schema | Any data-model change. |
| 4 | API endpoints | Any interface change. |
| 5 | Runtime workflow | Implementing or debugging a pipeline stage. |
| 6 | Coding workflow | Starting or finishing any task. |
| 7 | San José source list | Adding or fixing an ingestion. |
| 8 | Canonical field vocabulary and dependencies | Adding a field. |
| 9 | Refusal and error codes | Anywhere something can fail. |
| 10 | Track A / Track B measurement | Anything that touches evidence. |
| 11 | Environment and configuration | Setup and deployment. |
| 12 | Change record | Amending this spec. |
| 13 | Subscription commerce schema | Billing, entitlement or plan work. |
| 14 | Launch dependencies and Base Core | Scoping what must ship. |
| 15 | Architecture Addendum A-1 | A-1.1 to A-1.4 gates. |
| Rank | Document | Role |
|---|---|---|
| 1 | This Spec v1.7 | Machine-executed build contract. |
| 2 | Implementation Rules v1.4 | Operational restatement. |
| 3 | Business Plan 2.1.4 | Commercial master. |
| 4 | Municipal Data & API Audit v1.1 | Municipal evidence and rights. |
| 5 | San José Data Source Checklist v1.2 | Source planning. |
build/ledgex_source.py owns INVARIANTS and MAKE_TARGETS. build_spec_v1_7.py and build_rules_v1_4.py both import those objects. Neither the markdown nor the PDF is generated from a copied invariant table. Document QA fails if any invariant body or enforcement cell differs between artifacts.
NO CLAIM OF APPLICATION EXECUTION
This Spec defines schema, jobs and tests. It does not claim migrations or repository tests have run. The A-1 merge checks are mandatory before application-code merge and remain pending until recorded by CI evidence.
I1–I10 preserve evidence, rights, refusal and geometry-degraded Base Core behavior. I11–I20 govern citations, automation, commerce separation, review evidence and A-1 measurement/provider gates. Each has a CI check. A pull request that violates one does not merge.
| ID | Invariant | Required enforcement |
|---|---|---|
| I1 | core/ contains no jurisdiction name, local rule or local field name. | make check-boundary; import-linter |
| I2 | A Fact cannot exist without source_id + snapshot_id when retrieved, or method_version + complete lineage when derived. | DB CHECK; Pydantic model |
| I3 | Every fact carries a non-null licence_id. | DB NOT NULL + FK |
| I4 | Facts are immutable. Corrections supersede prior facts; they never overwrite or destructively update them. | fact_no_update trigger |
| I5 | A derived fact inherits the most restrictive licence of every input. Application code computes it at write time and the database validates it. | derive(); licence trigger; CI |
| I6 | The composer omits or refuses every fact whose licence forbids the output channel, including facts used only internally for resolution. Unknown rights block. Rights and confidence are independent gates. | rights gate; touched-fact test |
| I7 | Stated source cadence, source publication date and retrieval date are separate stored fields and are never conflated. | schema; cadence test |
| I8 | Refusal is a typed return value, not an exception. Every runtime stage can refuse deterministically. | Result[T]; refusal tests |
| I9 | A derived conclusion never renders in the visual or structural treatment reserved for a retrieved fact. | claim renderer; isolation test |
| I10 | Base Core remains composable when the geometry module is disabled, provided its own dependencies clear. Placement and every geometry-dependent conclusion refuse by name; no fallback geometry is inferred. | base-core / no-fallback tests |
| I11 | Every rule application records the exact ruleset_version and a human-readable citation. | schema NOT NULL; citation test |
| I12 | Every detected exception is a stored, measurable row with evidence and a closeable internal outcome. | schema; exception metrics |
| I13 | Only direct, bulk and deterministic derived facts may exist. No human observation, portal reading, manual lookup or request-time LLM judgment becomes a fact. | method CHECK; no-human-facts test |
| I14 | Customer delivery is automated end to end. No stage may block on, queue for, assign to, route to or be supplemented by a person. A machine-unavailable field is omitted, downgraded or refused. | no queue schema; delivery-path test |
| I15 | commerce may reference public; public may never reference commerce. A commerce row cannot create, alter or supply a property fact or conclusion. | catalogue query; import-linter |
| I16 | Subscription billing is independent of Property File outcome. Composed, partial and refused never create an individual-file charge, price adjustment, credit, settlement, refund or confidence exception. | billing-independence tests |
| I17 | The Spec and Rules are authoritative only when read verbatim from the filesystem. A graph, summary or derived context artifact cannot supply a rule, citation, licence term or CI gate. | check-boundary; no-graph test |
| I18 | Rule and disclosure review evidence is immutable and uses either independent review or same-identity solo-founder attestation with a non-null attestation URI. Review never enters customer delivery. | DB CHECKs; review-mode matrix |
| I19 | Evaluation-to-permit outcome observations exist only in commerce as Track B measurement. They never enter core/compose, never render in a Property File and never become public facts or provenance inputs. | schema separation; no-render test |
| I20 | A footprint provider cannot occupy the active geometry slot unless an immutable approved measured-error record exists for the same provider version, method and jurisdiction. Runtime substitution is forbidden. | constraint trigger; provider-slot tests |
A fact used to resolve jurisdiction participates in composition even if it is not rendered. Letting unknown-rights city_limits pass internally would authorize a conclusion the licence does not support. I6 therefore gates every touched fact, not only every rendered fact.
| Target | Execution surface | Pass condition |
|---|---|---|
| make check-boundary | Jurisdiction-name grep, import-linter, public-to-commerce catalogue query, filesystem authority, no-graph and Track B no-render checks. | I1, I15, I17 and I19 pass; zero forbidden imports, FKs or derived authority. |
| make schema | Apply every forward-only migration to an empty database. | Clean apply; constraints, functions and triggers compile. |
| make schema-dump | Regenerate db/schema.sql from the applied database and compare the committed dump. | No diff; missing or stale generated DDL fails. |
| make conformance | Parameterized pack suite for sources, mappings, rights, dependency cascades and endpoint liveness. | Every enabled pack passes; no rights broadening or silent missing dependency. |
| make test | Unit and integration suites, including review, entitlement, outcome observation, provider slot, edge guard and billing independence. | All required tests pass with zero skips and no external network dependency in CI. |
| make golden | Normalized composed, partial, refused and geometry-disabled Base Core fixtures. | Output matches approved fixtures; intended changes require reviewed fixture updates. |
| Item | Title | Scope |
|---|---|---|
| A-1.1 | Control recovery / canonical invariants | Canonical I1-I20, internal-fact licence-gate rationale and six make targets. |
| A-1.2 | Evaluation-to-permit outcome loop | Immutable Track B evaluation-to-permit observations in commerce only. |
| A-1.3 | Validated footprint-provider slot | One validated footprint-provider slot; switching is controlled, never runtime fallback. No activation without approved measured-error evidence. |
| A-1.4 | Edge request guard | Edge infrastructure protection before entitlement and core/compose; rejection is HTTP 429, not a file outcome and not a fourth outcome. |
STRUCTURAL DRIFT PREVENTION
Both builders import the invariant and make-target objects from
build/ledgex_source.py. A future revision changes the source object once and regenerates both artifacts; copying is prohibited.
Converted verbatim from the v1.7 source document: schema DDL, API contracts, runtime workflow, San José source list, field vocabulary, refusal codes, measurement, environment, change record, subscription commerce and launch dependencies. Section numbering follows the original.
ledgex/
├── CLAUDE.md
├── docs/
│ ├── LEDGEX_SPEC.md ← this file
│ ├── ARCHITECTURE_BLUEPRINT.pdf
│ └── SAN_JOSE_CHECKLIST.pdf
├── core/ ← NO city names. Enforced by CI.
│ ├── resolver/ L0 core/connectors/ L1 core/snapshots/ L2
│ ├── normalize/ L3 core/store/ L4 core/rules/ L5
│ ├── reconcile/ L6 core/exceptions/ L6 core/calc/ L7
│ ├── compose/ L8 core/deliver/ L8 core/rights/ X
│ └── model/ ← Fact, Parcel, Source, Licence, Exception, Refusal
│ (no queue, no task, no assignee types exist)
├── commerce/ ← orders, disclosure, payment. §13.
│ May import core/model. Never imported by core.
├── jurisdictions/
│ ├── _schema/ JSON Schema for every pack file
│ ├── ca_state/ adu.yaml, sb9.yaml
│ ├── ca_santa_clara_county/
│ └── ca_san_jose/ ← see §7
├── pipelines/ job definitions
├── geo/ 3DEP extraction — SEPARATE container
├── api/ FastAPI over core.compose and commerce
├── db/
│ ├── migrations/ NNNN_snake_case.sql, forward-only
│ └── schema.sql generated; never hand-edited
├── tests/{core,commerce,conformance,golden/ca_san_jose}/
├── ops/
└── .graphify/ ← generated knowledge graph. GITIGNORED.
Derived, disposable, never authoritative (I17, §11.1).
Import rules (enforced).
PostgreSQL 16 + PostGIS 3.4. Forward-only migrations, db/migrations/NNNN_name.sql, never edited after merge. db/schema.sql is generated by make schema-dump.
All tables in this section live in schema public. The commerce tables live in schema commerce and are specified in §13.
-- 0001_extensions_and_enums.sql
-- Serves: C1–C9, I2, I3, I13.
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid(), digest()
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- portal / manual classify a SOURCE that cannot be machine-read. They are never
-- a fact method: no fact may originate from a human reading a portal (I13).
CREATE TYPE access_method AS ENUM ('direct','bulk','portal','manual','derived');
CREATE TYPE use_restriction AS ENUM ('open','attribution','noncommercial','no_resale','unknown');
CREATE TYPE permission_state AS ENUM ('allowed','prohibited','unknown');
CREATE TYPE output_channel AS ENUM ('free_snapshot','paid_property_file','api','bulk_export');
CREATE TYPE claim_type AS ENUM ('public_record','third_party_record','estimate','user_assumption','derived_conclusion');
CREATE TYPE conflict_state AS ENUM ('agree','conflicts','stale','missing');
CREATE TYPE jurisdiction_tier AS ENUM ('tier_1','tier_2','tier_3','tier_4','blocked');
CREATE TYPE exception_type AS ENUM ('record_to_ground','cross_source','staleness',
'rule_boundary','coverage_gap','rights_gap');
CREATE TYPE exception_severity AS ENUM ('info','warning','blocking');
CREATE TYPE exception_outcome AS ENUM ('open','confirmed','false_positive','unresolved');
CREATE TYPE file_status AS ENUM ('composed','partial','refused');
CREATE TYPE job_status AS ENUM ('running','succeeded','failed','skipped_unchanged');
CREATE TYPE support_category AS ENUM ('data_dispute','missing_field','refusal_query',
'billing','usability','other');
-- v1.2: Phase 1 posture for a declared source. 'deferred' means the source is
-- real and known but out of scope for Phase 1; its fields are declared coverage
-- gaps rather than silent omissions. See §7.4.
CREATE TYPE source_phase_status AS ENUM ('active','blocked_rights','blocked_engineering',
'not_machine_readable','deferred','excluded');
Removed in v1.2: there is no confidence_floor concept and no CONFIDENCE_BELOW_THRESHOLD refusal. See §9.1.-- 0002_registries.sql
-- Serves: C6, I3, I6, I14.
CREATE TABLE jurisdiction (
id text PRIMARY KEY, -- 'ca_san_jose'
display_name text NOT NULL,
kind text NOT NULL CHECK (kind IN ('city','county','state')),
parent_id text REFERENCES jurisdiction(id),
state_code char(2) NOT NULL,
tier jurisdiction_tier NOT NULL DEFAULT 'blocked',
pack_version text NOT NULL,
boundary_source_id text, -- FK added after source exists
geometry_tier_enabled boolean NOT NULL DEFAULT false, -- I10 / C7
supported boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE licence (
id text PRIMARY KEY, -- 'cc0', 'cc_by_4_0', 'sj_portal_terms'
display_name text NOT NULL,
restriction use_restriction NOT NULL,
commercial_use permission_state NOT NULL DEFAULT 'unknown',
redistribution permission_state NOT NULL DEFAULT 'unknown',
attribution_text text,
terms_url text,
evidence_uri text, -- stored snapshot of the terms
observed_at timestamptz NOT NULL,
cleared_by text, -- counsel / written permission
cleared_at timestamptz,
notes text,
CONSTRAINT licence_attribution_present
CHECK (restriction <> 'attribution' OR attribution_text IS NOT NULL)
);
-- The channel matrix. Absence of a row = denied. Default deny (I6).
CREATE TABLE licence_channel (
licence_id text NOT NULL REFERENCES licence(id) ON DELETE CASCADE,
channel output_channel NOT NULL,
allowed boolean NOT NULL,
rationale text NOT NULL,
PRIMARY KEY (licence_id, channel)
);
CREATE TABLE source (
id text PRIMARY KEY, -- 'ca_san_jose.building_permits_active'
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
display_name text NOT NULL,
steward text NOT NULL,
method access_method NOT NULL,
phase_status source_phase_status NOT NULL DEFAULT 'blocked_rights',
phase_status_reason text NOT NULL, -- v1.2: always stated, never blank
endpoint_url text, -- null only for method='manual'
layer_item_id text,
query_params jsonb NOT NULL DEFAULT '{}'::jsonb,
licence_id text NOT NULL REFERENCES licence(id),
cadence_stated text, -- verbatim, e.g. 'weekly, Mondays'
cadence_observed_s integer, -- measured, seconds; null until measured
earliest_record_date date, -- measured depth of a history series
expected_fields jsonb NOT NULL DEFAULT '[]'::jsonb,
url_verified_at timestamptz, -- null = NOT yet verified; blocks prod
active boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT source_endpoint_required
CHECK (method = 'manual' OR endpoint_url IS NOT NULL),
CONSTRAINT source_active_requires_verification
CHECK (active = false OR url_verified_at IS NOT NULL),
-- I14: a source a machine cannot read can never be switched on. It is recorded
-- so its absence is a known coverage gap, not an oversight.
CONSTRAINT source_active_requires_machine_access
CHECK (active = false OR method IN ('direct','bulk')),
-- v1.2: active and phase_status must agree. Prevents the §7.3 class of drift
-- where a summary table said "Yes" while the licence said otherwise.
CONSTRAINT source_active_matches_phase
CHECK (active = false OR phase_status = 'active')
);
ALTER TABLE jurisdiction
ADD CONSTRAINT jurisdiction_boundary_source_fk
FOREIGN KEY (boundary_source_id) REFERENCES source(id);
-- Which steward wins for which field in which jurisdiction (§3.8 current_fact).
CREATE TABLE source_rank (
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
field_key text NOT NULL,
source_id text NOT NULL REFERENCES source(id),
rank smallint NOT NULL CHECK (rank > 0),
rationale text NOT NULL,
PRIMARY KEY (jurisdiction_id, field_key, source_id),
UNIQUE (jurisdiction_id, field_key, rank)
);source_rank.field_key gains an FK to field_definition in migration 0003, which creates that table.
phase_status is descriptive, not authoritative. Runtime channel eligibility is determined solely by licences.yaml → licence
-- 0003_fields.sql
-- Serves: I7 (stale_after_days), I9 (claim), §8.
CREATE TABLE field_definition (
field_key text PRIMARY KEY, -- 'zoning.district'
display_name text NOT NULL,
claim claim_type NOT NULL,
value_type text NOT NULL CHECK (value_type IN
('string','number','boolean','date','geometry','enum','object')),
unit text,
enum_values text[],
category text NOT NULL, -- checklist section, e.g. 'zoning'
stale_after_days integer, -- null = never stale (I7)
required_for_file boolean NOT NULL DEFAULT false,
-- v1.2: a field with no Phase 1 supplier. Declared, never silent (§7.4).
phase1_deferred boolean NOT NULL DEFAULT false,
deferral_reason text,
description text NOT NULL,
CONSTRAINT field_enum_values_present
CHECK (value_type <> 'enum' OR enum_values IS NOT NULL),
CONSTRAINT field_unit_for_number
CHECK (value_type <> 'number' OR unit IS NOT NULL),
CONSTRAINT field_deferral_reason_present
CHECK (phase1_deferred = false OR deferral_reason IS NOT NULL),
-- A deferred field cannot also be required. Requiring a field nothing supplies
-- would refuse every file.
CONSTRAINT field_deferred_not_required
CHECK (phase1_deferred = false OR required_for_file = false)
);
ALTER TABLE source_rank
ADD CONSTRAINT source_rank_field_fk
FOREIGN KEY (field_key) REFERENCES field_definition(field_key);-- 0004_parcel.sql
CREATE TABLE parcel (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
apn text NOT NULL,
situs_address text,
geom geometry(MultiPolygon, 4326),
centroid geometry(Point, 4326),
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (jurisdiction_id, apn) -- APNs collide across counties
);
CREATE INDEX parcel_geom_gix ON parcel USING gist (geom);
CREATE INDEX parcel_centroid_gix ON parcel USING gist (centroid);
CREATE INDEX parcel_apn_prefix ON parcel (apn text_pattern_ops);-- 0005_snapshot.sql
CREATE TABLE snapshot (
id text PRIMARY KEY, -- 'sha256:...' content address
source_id text NOT NULL REFERENCES source(id),
object_uri text NOT NULL, -- s3://bucket/sha256/...
content_hash text NOT NULL,
media_type text NOT NULL,
byte_size bigint NOT NULL CHECK (byte_size >= 0),
request jsonb NOT NULL, -- exact URL, params, headers sent
http_status integer,
fetched_at timestamptz NOT NULL,
licence_observed_id text NOT NULL REFERENCES licence(id), -- at the moment of fetch
UNIQUE (content_hash, source_id)
);
CREATE INDEX snapshot_source_time ON snapshot (source_id, fetched_at DESC);Change detection is free. Identical content_hash for a source means the source did not change, regardless of what cadence_stated claims. This is the mechanism behind I7.
-- 0006_fact.sql
-- Serves: C1–C5, C8, I2, I3, I13.
CREATE TABLE fact (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
parcel_id uuid NOT NULL REFERENCES parcel(id),
field_key text NOT NULL REFERENCES field_definition(field_key),
value jsonb NOT NULL,
unit text,
local_verbatim text, -- the source's own string. NEVER discard.
-- provenance (C1)
source_id text REFERENCES source(id),
source_url text,
layer_item_id text,
snapshot_id text REFERENCES snapshot(id),
method access_method NOT NULL,
-- time, three separate facts (C8 / I7)
retrieved_at timestamptz,
source_published_at timestamptz,
source_cadence_stated text,
effective_from timestamptz NOT NULL, -- valid time
effective_to timestamptz,
recorded_at timestamptz NOT NULL DEFAULT now(), -- transaction time
superseded_at timestamptz,
-- rights (C6 / I3)
licence_id text NOT NULL REFERENCES licence(id),
-- judgement (C4, C5)
confidence confidence_level NOT NULL,
confidence_rule_id text NOT NULL,
conflict conflict_state NOT NULL DEFAULT 'agree',
-- lineage (C3)
method_version text,
ruleset_version text,
pack_version text NOT NULL,
CONSTRAINT fact_valid_time CHECK (effective_to IS NULL OR effective_to > effective_from),
CONSTRAINT fact_txn_time CHECK (superseded_at IS NULL OR superseded_at >= recorded_at),
-- I2: retrieved facts need a source and a snapshot; derived facts need a method version
CONSTRAINT fact_provenance_complete CHECK (
(method = 'derived'
AND source_id IS NULL AND snapshot_id IS NULL
AND method_version IS NOT NULL)
OR
(method <> 'derived'
AND source_id IS NOT NULL AND snapshot_id IS NOT NULL
AND retrieved_at IS NOT NULL AND source_url IS NOT NULL)
),
-- I13: no human observation ever becomes a fact. Every fact in the system is
-- machine-retrieved or deterministically derived, so there is no "unverified"
-- class of fact to reason about, label, or accidentally price into a file.
CONSTRAINT fact_method_automated CHECK (
method IN ('direct','bulk','derived')
)
);
-- One current belief per (parcel, field, source). Corrections supersede.
CREATE UNIQUE INDEX fact_one_current_per_source
ON fact (parcel_id, field_key, COALESCE(source_id, '~derived'), COALESCE(method_version, '~'))
WHERE superseded_at IS NULL;
CREATE INDEX fact_lookup ON fact (parcel_id, field_key, recorded_at DESC);
CREATE INDEX fact_current ON fact (parcel_id, field_key) WHERE superseded_at IS NULL;
CREATE INDEX fact_by_source ON fact (source_id, retrieved_at DESC);
CREATE INDEX fact_conflicts ON fact (conflict) WHERE conflict <> 'agree';
-- Lineage as a junction table: FK integrity + clean recursive CTE.
CREATE TABLE fact_input (
fact_id uuid NOT NULL REFERENCES fact(id) ON DELETE CASCADE,
input_fact_id uuid NOT NULL REFERENCES fact(id),
ordinal smallint NOT NULL,
role text NOT NULL, -- 'lot_area', 'setback_rule', ...
PRIMARY KEY (fact_id, input_fact_id),
CONSTRAINT fact_input_not_self CHECK (fact_id <> input_fact_id)
);
v1.2 change to fact_one_current_per_source. The v1.1 index keyed derived facts on the literal '~derived', which made two
derived facts for the same (parcel, field) under different method_versions collide. method_version is now part of the key.
Retrieved facts are unaffected (method_version is null for them and coalesces to '~').v1.2 fixes a defect that made v1.1 unrunnable. In v1.1 the licence-inheritance trigger performed an UPDATE fact SET licence_id = …, which the immutability trigger rejects. The v1.1 text acknowledged the collision mid-sentence and left it unresolved. The inheritance trigger no longer mutates. Inheritance is computed in core/store.derive() before insert; the trigger now validates and raises if application code got it wrong. Both invariants hold, nothing fights, and no session_replication_role manipulation is required.
-- 0007_fact_triggers.sql
-- Serves: I4, I5.
-- Ordering of restriction severity, most restrictive first. Single source of
-- truth for both the trigger and core/rights.
CREATE OR REPLACE FUNCTION restriction_severity(r use_restriction)RETURNS smallint AS $$
SELECT CASE r
WHEN 'unknown' THEN 0
WHEN 'noncommercial' THEN 1
WHEN 'no_resale' THEN 2
WHEN 'attribution' THEN 3
WHEN 'open' THEN 4
END;$$ LANGUAGE sql IMMUTABLE;
CREATE TRIGGER fact_no_update BEFORE UPDATE ON factFOR EACH ROW EXECUTE FUNCTION fact_no_destructive_update();
This locks value, provenance, licence, confidence, conflict, effective time, URLs, method and versions. Supersession is the only permitted mutation and is one-way. A correction is a new fact row with lineage to the replaced belief; the old row receives only superseded_at.
-- I5: VALIDATE that a derived fact already carries the most restrictive licence
-- among its inputs. Does not mutate. core/store.derive() must compute it.
CREATE OR REPLACE FUNCTION fact_licence_validate() RETURNS trigger AS $$DECLARE required_licence text; actual_licence text; BEGIN
SELECT f.licence_id INTO required_licence
FROM fact_input fi
JOIN fact f ON f.id = fi.input_fact_id
JOIN licence l ON l.id = f.licence_id
WHERE fi.fact_id = NEW.fact_id
ORDER BY restriction_severity(l.restriction) ASC, f.licence_id ASC
LIMIT 1;
IF required_licence IS NULL THEN
RETURN NEW; -- no inputs recorded yet
END IF;
SELECT licence_id INTO actual_licence FROM fact WHERE id = NEW.fact_id;
IF (SELECT restriction_severity(l.restriction) FROM licence l WHERE l.id = actual_licence)
> (SELECT restriction_severity(l.restriction) FROM licence l WHERE l.id = required_licence)
THEN
RAISE EXCEPTION
'I5 violated: derived fact % carries licence %, but its inputs require % '
'(or something at least as restrictive). Compute inheritance in '
'core/store.derive() before insert.',
NEW.fact_id, actual_licence, required_licence;
END IF;
RETURN NEW;END; $$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER fact_licence_inheritance
AFTER INSERT ON fact_input
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION fact_licence_validate();DEFERRABLE INITIALLY DEFERRED lets a transaction insert the derived fact and all its fact_input rows in any order; validation runs once at commit, when the full input set is visible. Inserting inputs one at a time under a non-deferred trigger would fire on a partial set and raise spuriously.
-- 0008_current_fact.sql
CREATE MATERIALIZED VIEW current_fact AS
SELECT DISTINCT ON (f.parcel_id, f.field_key)
f.*FROM fact f JOIN parcel p ON p.id = f.parcel_id LEFT JOIN source_rank sr ON sr.jurisdiction_id = p.jurisdiction_id AND sr.field_key = f.field_key AND sr.source_id = f.source_id WHERE f.superseded_at IS NULL AND (f.effective_to IS NULL OR f.effective_to > now()) ORDER BY f.parcel_id, f.field_key, COALESCE(sr.rank, 999) ASC, f.confidence ASC, -- enum order: high < medium < low f.retrieved_at DESC NULLS LAST;
CREATE UNIQUE INDEX current_fact_pk ON current_fact (parcel_id, field_key);
CREATE INDEX current_fact_field ON current_fact (field_key);Refresh with REFRESH MATERIALIZED VIEW CONCURRENTLY current_fact; at the end of each ingest job. Application queries read current_fact. The composer, the audit path and any reconstruction read fact directly.
Independent review remains preferred. Phase 1 may use a controlled solo-founder attestation only when the same identity authors and reviews and an immutable evidence URI is stored.
-- 0009_rules.sql (v1.6 replacement for review columns)
CREATE TYPE public.review_mode AS ENUM ('independent','solo_founder_attestation');
CREATE TABLE rule (
id text PRIMARY KEY,
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
rule_key text NOT NULL, version integer NOT NULL CHECK(version > 0),
effective_from date NOT NULL, effective_to date,
citation text NOT NULL, source_text_uri text NOT NULL,
params jsonb NOT NULL, pack_version text NOT NULL,
authored_by text NOT NULL, reviewed_by text NOT NULL,
review_mode public.review_mode NOT NULL DEFAULT 'independent',
reviewed_at timestamptz NOT NULL, attestation_uri text,
UNIQUE(jurisdiction_id, rule_key, version),
CHECK (effective_to IS NULL OR effective_to > effective_from),
CHECK (
(review_mode='independent' AND reviewed_by<>authored_by
AND attestation_uri IS NULL)
OR
(review_mode='solo_founder_attestation' AND reviewed_by=authored_by
AND attestation_uri IS NOT NULL AND length(trim(attestation_uri))>0)
)
);
-- authored/reviewed identities, mode, time and URI are immutable.
-- A correction creates a new rule version; it never updates review evidence.
Deployment policy may allow solo-founder mode only during the documented bootstrap period. Existing attested rows remain historical when independent
review becomes available.Track A outcomes are recorded internally after delivery. They never gate, delay or supplement a customer file and never create a human delivery queue.
CREATE TABLE parcel_exception (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
parcel_id uuid NOT NULL REFERENCES parcel(id),
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
type exception_severity NOT NULL, severity exception_severity NOT NULL,
detector_key text NOT NULL, detector_version text NOT NULL,
ruleset_version text, detail jsonb NOT NULL,
detected_at timestamptz NOT NULL DEFAULT now(),
outcome exception_outcome NOT NULL DEFAULT 'open',
resolved_at timestamptz, resolved_by text, resolution_notes text,
CHECK(outcome='open' OR (resolved_at IS NOT NULL AND resolved_by IS NOT NULL))
);
CREATE TABLE exception_evidence (
exception_id uuid NOT NULL REFERENCES parcel_exception(id) ON DELETE CASCADE,
fact_id uuid NOT NULL REFERENCES fact(id), role text NOT NULL,
PRIMARY KEY(exception_id,fact_id)
);There is no review queue, no task table and no assignee anywhere in this schema. A human-review queue was designed in v1.0 and removed in v1.1 (see §12 and the annex in §6.7). It must not be reintroduced without amending I14.
The only human-facing table that remains records contact that happens after a file has already been delivered. It measures the product; it never produces it.
-- 0011_support.sql
CREATE TABLE support_request (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
property_file_id uuid, -- FK added in 0012
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
category support_category NOT NULL,
field_key text REFERENCES field_definition(field_key),
opened_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
caused_correction boolean NOT NULL DEFAULT false,
correcting_fact_id uuid REFERENCES fact(id), -- must be direct/bulk/derived
detail text,
CONSTRAINT support_correction_consistent CHECK (
caused_correction = false OR correcting_fact_id IS NOT NULL
)
);
CREATE INDEX support_by_file ON support_request (property_file_id);
CREATE INDEX support_rate_idx ON support_request (jurisdiction_id, opened_at);A support request is not a back door for human facts. If contact reveals that a value is wrong, the fix is a source, crosswalk, rule or detector change that makes the pipeline produce the right answer — recorded as a normal direct/bulk/derived fact. correcting_fact_id is constrained by I13 like any other fact. Typing a corrected value in by hand is prohibited (§6.2 rule 12).
-- 0012_files_and_jobs.sql
CREATE TABLE property_file (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
parcel_id uuid NOT NULL REFERENCES parcel(id),
jurisdiction_id text NOT NULL REFERENCES jurisdiction(id),
channel output_channel NOT NULL,
status file_status NOT NULL,
composed_at timestamptz NOT NULL DEFAULT now(),
as_of timestamptz NOT NULL,
pack_version text NOT NULL,
ruleset_version text NOT NULL,
composer_version text NOT NULL,
geometry_tier_used boolean NOT NULL,
assumptions jsonb NOT NULL DEFAULT '{}'::jsonb,
refusals jsonb NOT NULL DEFAULT '[]'::jsonb,
omitted_for_rights jsonb NOT NULL DEFAULT '[]'::jsonb, -- I6, visible not silent
attribution text[] NOT NULL DEFAULT '{}',
payload jsonb NOT NULL,
payload_hash text NOT NULL, -- v1.2: sha256 of the delivered payload
delivered_at timestamptz,
-- automated unit economics; replaces v1.0 manual-hour instrumentation (§10)
compose_ms integer NOT NULL,
source_calls integer NOT NULL DEFAULT 0,
compute_cost_micros bigint NOT NULL DEFAULT 0, -- metered, not estimated
storage_cost_micros bigint NOT NULL DEFAULT 0, -- v1.2: Plan §15 measure 5
unmet_fields text[] NOT NULL DEFAULT '{}', -- required fields not retrieved
CONSTRAINT file_refusal_reason CHECK (
status <> 'refused' OR jsonb_array_length(refusals) > 0
),
-- a partial file must say what is missing; silence is not permitted
CONSTRAINT file_partial_declares_gap CHECK (
status <> 'partial'
OR cardinality(unmet_fields) > 0
OR jsonb_array_length(refusals) > 0
),
-- v1.2: a refused file is never delivered as a product artifact
CONSTRAINT file_refused_not_delivered CHECK (
status <> 'refused' OR delivered_at IS NULL
)
);
v1.6: Property File rows carry delivery outcome and cost telemetry only. Commercial access lives in commerce.access_entitlement and
commerce.subscription; no accepted price or per-file settlement field exists.
CREATE TABLE property_file_fact (
property_file_id uuid NOT NULL REFERENCES property_file(id) ON DELETE CASCADE,
fact_id uuid NOT NULL REFERENCES fact(id),
-- v1.2: I6 applies to every fact TOUCHED, not only every fact rendered.
-- 'gate' facts resolve jurisdiction and never appear in the payload; they are
-- still recorded here and still pass through the licence gate.
use text NOT NULL DEFAULT 'rendered'
CHECK (use IN ('rendered','gate','input')),
PRIMARY KEY (property_file_id, fact_id)
);
CREATE TABLE job_run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_key text NOT NULL,
jurisdiction_id text REFERENCES jurisdiction(id),
source_id text REFERENCES source(id),
status job_status NOT NULL DEFAULT 'running',
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
snapshot_id text REFERENCES snapshot(id),
rows_in integer,
rows_out integer,
schema_drift jsonb, -- fields expected but missing
error text,
CONSTRAINT job_terminal CHECK (
status = 'running' OR finished_at IS NOT NULL
)
);
CREATE INDEX job_run_recent ON job_run (job_key, started_at DESC);
-- deferred FK: support_request was created in 0011, before property_file existed
ALTER TABLE support_request
ADD CONSTRAINT support_property_file_fk
FOREIGN KEY (property_file_id) REFERENCES property_file(id);Method Path Purpose
GET /v1/access/plans Limited-free and current Base plan/version disclosure.
POST /v1/disclosures/{id}/accept Hash customer acceptance; no price acceptance.
POST /v1/subscriptions/checkout Create provider checkout for a Base subscription.
POST /v1/commerce/provider-events Idempotent webhook; captured billing is paid evidence.
GET /v1/subscriptions/current Current status and entitlement; never a file allowance.
Method Path Purpose Refusals possible
GET /v1/jurisdictions Supported jurisdictions, tiers, pack — versions GET /v1/jurisdictions/{id}/sources Source and licence ledger — GET /v1/snapshots/{id} Raw snapshot metadata + signed RIGHTS_BLOCKED object URL POST /v1/admin/ingest/{source_id} Trigger an ingest — GET /v1/admin/job-runs Job history and schema drift — GET /v1/metrics/track-a Hit rate, false-positive rate by — detector GET /v1/metrics/track-b v1.2 — accepted price, disclosure — decline rate, repeat rate
Commerce endpoints (v1.6, §13.6): GET /v1/access/plans; POST /v1/disclosures/{id}/accept; POST /v1/subscriptions/checkout; POST /v1/commerce/provider-events; GET /v1/subscriptions/current; POST /v1/property-file-requests; GET /v1/metrics/track-b. There is no accepted-price or outcome-settlement endpoint.
There is no endpoint that assigns work to a person, and none may be added. A POST that creates a task, claims a queue item or records a human observation violates I14. /v1/support-requests records that a customer got in touch after delivery; it cannot alter a file or produce a fact.
FactEnvelope — the atom of every response. Provenance is not optional and not summarised.
{ "fact_id": "0f2c…", "field_key": "zoning.district", "claim": "public_record", "value": "R-1-8", "unit": null, "local_verbatim": "R-1-8", "provenance": { "source_id": "ca_san_jose.zoning_districts", "steward": "City of San José", "source_url": "https://…/FeatureServer/0/query?…", "layer_item_id": "0", "snapshot_id": "sha256:9ab…", "method": "direct", "retrieved_at": "2026-08-03T09:14:22Z", "source_published_at": "2026-08-03T00:00:00Z", "source_cadence_stated": "weekly, Mondays" }, "rights": { "licence_id": "cc_by_4_0", "restriction": "attribution", "attribution_text": "Contains data from the City of San José." }, "judgement": { "confidence": "high", "confidence_rule_id": "conf.direct_fresh_ranked_1", "conflict": "agree" }, "versions": { "pack_version": "ca_san_jose@0.4.1", "ruleset_version": null } }
POST /v1/resolve
// request { "address": "123 Example St, San Jose, CA 95112" }
// response — resolved { "status": "ok", "data": { "parcel_id": "8b1e…", "apn": "000-00-000", "jurisdiction": { "id": "ca_san_jose", "tier": "tier_1", "pack_version": "ca_san_jose@0.4.1", "geometry_tier_enabled": false }, "resolution": { "method": "address_point_then_containment", "confidence": "high", "city_layer_agrees": true, "county_layer_agrees": true }
}, "attribution": ["Contains data from the City of San José."], "omitted_for_rights": [] }
// response — refused (an unincorporated pocket) { "status": "refused", "refusals": [{ "code": "JURISDICTION_UNSUPPORTED", "stage": "L0", "message": "Parcel resolves to unincorporated Santa Clara County, not the City of San José.", "detail": { "city_layer_agrees": false, "county_layer_agrees": true }, "remedy": "County rules apply. No jurisdiction pack is loaded for ca_santa_clara_county." }], "attribution": [], "omitted_for_rights": [] }
// response — refused (LD-1: the gate source's licence is unconfirmed). v1.2. // This is the CURRENT expected response for every request until LD-1 clears. { "status": "refused", "refusals": [{ "code": "LICENCE_UNKNOWN", "stage": "L0", "message": "The jurisdiction boundary source has no confirmed licence, so it cannot be used in any output channel.", "detail": { "source_id": "ca_san_jose.city_limits", "licence_id": "unknown", "channel": "paid_property_file", "launch_dependency": "LD-1" }, "remedy": "Per-resource licence confirmation is required before any file can compose."
POST /v1/property-files — request
{ "parcel_id": "8b1e…", "channel": "paid_property_file", "entitlement_id": "ent_7f0a…", "disclosure_acceptance_id": "da_91c…", "assumptions": { "construction_cost_per_sqft": 425, "target_adu_sqft": 750 }, "as_of": null }
"interest_rate_pct": 6.75, "monthly_rent": 2800 }, "as_of": null }
// response (abridged) { "status": "ok", "data": { "property_file_id": "c31a…", "status": "partial", "geometry_tier_used": false, "sections": { "jurisdiction": { "facts": ["…FactEnvelope…"] }, "zoning": { "facts": ["…"] }, "permits": { "facts": ["…"] }, "hazards": { "facts": ["…"] }, "ordinances": { "facts": ["…"] }, "economics": { "derived": ["…FactEnvelope with claim=derived_conclusion…"] } }, "exceptions": [{ "exception_id": "44de…", "type": "coverage_gap", "severity": "warning", "detail": { "field_key": "geometry.building_footprint", "reason": "Geometry tier disabled pending 3DEP validation." }, "evidence_fact_ids": [] }], "unmet_fields": ["property.year_built", "property.sqft", "ownership.current",
"legal.recorded_maps", "ordinance.code_enforcement", "hazard.creek_jurisdiction", "overlay.airport_influence", "utility.water_provider", "school.attendance_boundary"], "refusals": [ { "code": "GEOMETRY_TIER_DISABLED", "stage": "L7", "message": "Buildable envelope and ADU placement are unavailable.", "remedy": "Blocked pending 3DEP coverage and accuracy validation." }, { "code": "SOURCE_NOT_MACHINE_READABLE", "stage": "L1", "message": "Recorded maps and subdivision history have no machine-readable source.", "remedy": "SB 9 eligibility is refused rather than assuming no prior lot split.", "cascades_to": ["derived.sb9_eligibility"] }, { "code": "SOURCE_DEFERRED", "stage": "L1", "message": "Valley Water creek and riparian jurisdiction is not integrated in Phase 1.", "remedy": "Rear-yard siting conclusions carry a stated caveat. See §7.4.", "cascades_to": [] } ], "economics": { "compose_ms": 840, "source_calls": 11 }, "versions": { "pack_version": "ca_san_jose@0.4.1", "ruleset_version": "ca_san_jose.rules@12", "composer_version": "compose@1.2.0" } }, "attribution": ["Contains data from the City of San José."], "omitted_for_rights": [ { "field_key": "planning.permit_history", "licence_id": "sj_portal_terms", "reason": "Per-resource licence not yet confirmed for channel paid_property_file." } ] }
omitted_for_rights is the visible face of I6. A rights-blocked field is reported, never silently dropped, and it reads differently from a data gap.
PATCH /v1/exceptions/{id} — this endpoint is the Track A measurement.
{ "outcome": "false_positive", "resolved_by": "dt", "resolution_notes": "Structure predates the permit series start (2004).", "suggest_suppression": { "rule": "permit_series_floor", "value": "2004-01-01" } }
POST /v1/support-requests — records post-delivery contact. Feeds support_rate. Cannot alter a file and cannot create a fact. { "property_file_id": "c31a…", "category": "missing_field", "field_key": "planning.permit_history", "detail": "Customer asked why permit history was unavailable." }
If contact reveals a genuine data error, the remedy is a source, crosswalk, rule or detector change — shipped through the normal task shapes in §6.1 — so the pipeline produces the right answer for every future file. It is never a manual correction to this one.
{ "type": "https://ledgex.dev/problems/schema-drift", "title": "Source schema drift", "status": 502, "detail": "ca_san_jose.zoning_districts no longer returns field ZONINGABBREV.", "instance": "/v1/parcels/8b1e…/facts", "job_run_id": "…" }
Address in → property file out. Every stage may refuse (I8). No stage reaches back upward.
Stage In Out Refuses when Writes
L0 Resolve address / APN parcel_id, jurisdiction, pack Boundary layers disagree; — version unincorporated pocket; unsupported jurisdiction; parcel not found; gate source licence unknown for the channel (I6) L1 Connect source declaration raw bytes + fetch manifest Source unavailable; — url_verified_at null; source inactive; source is portal/manual/deferred — no fetch is attempted, the field is omitted L2 Snapshot raw bytes snapshot_id (content Object store unavailable snapshot, job_run address) L3 Normalise snapshot + field map + candidate facts Unmapped crosswalk — crosswalk value; expected field absent (schema drift) L4 Store candidate facts persisted facts, Constraint violation (I2/I3) fact, fact_input superseding prior beliefs — a bug, not a refusal L5 Rules jurisdiction + as-of date applicable rules with No rule effective at the reads rule citations as-of date L6 Reconcile competing facts + rules + conflict states, confidence, — (emits, never refuses) fact.conflict, geometry exceptions parcel_exception L7 Calculate facts + rule params + derived facts with lineage Geometry tier disabled; fact (method='derived'), assumptions required input missing fact_input L8 Compose & Deliver everything above property file see §5.1 property_file, (composed/partial) or refusal property_file_fact
Ingest loop (scheduled). job_run opens → L1 fetch → L2 content-address → if hash unchanged, sta- tus='skipped_unchanged' and stop → L3 normalise → detect drift → L4 store → refresh current_fact → L6 re-detect for touched parcels → job_run closes.
Compose loop (on demand, end to end automated). L0 → read current_fact → L5 rules as-of → L7 calculate → L6 exception pass → L8 rights gate over every touched fact → decide composed/partial/refused → persist property_file → return. There is no fourth outcome and no queue. Median path is sub-second; there is no state
Compose loop — access is checked, outcome is not billed
Validate entitlement and disclosure acceptance → L0 resolve → facts → rules → calculations → rights gate → composed / partial / refused → persist → deliver. There is no per-file payment step and no human queue. Provider billing events occur on the subscription lifecycle, not inside composition.
Outcome determines content only. A refused conclusion never becomes lower-confidence because the account is paid; a refused file never creates a file-level charge or refund.
partial Some required fields could not be retrieved, or a A file that names every gap in unmet_fields and tier of conclusions is unavailable. refusals, and withholds every conclusion that depends on a missing input. refused The jurisdiction is unsupported, the parcel is A refusal with a code, a plain-language reason unresolvable, a required source’s licence does and a remedy. No charge. not permit the channel, or so little is available that a file would mislead.
When a field cannot be machine-retrieved, the pipeline takes exactly one of three actions, in this order:
Refusal cascades to dependents, never silently. If permits.active is unavailable, derived.record_to_ground is refused with PERMIT_LAYER_UNAVAILABLE — the file does not quietly omit the conclusion, and it never presents a permit- dependent answer computed without permits.
Source tier determines coverage and outcome, not access price. Base Core and conditional modules are evaluated separately under declared dependencies.
Tier Machine access Base Core Conditional modules
1 Direct queryable core + permit feed May compose if rights and minimum core clear. Placement only if LD-4 and siting inputs clear.
2 Direct core; permit depth incomplete Partial; useful integrated screening may remain. Permit/record-to-ground conclusions refuse.
3 Partial or bulk machine access Partial; named gaps and dependent refusals. Unavailable modules refuse; no inferred fallback.
4 Portal/document only Refused where minimum core cannot be Refused. obtained.
A* Technically strong; rights blocked Refused or omitted under I6. Refused regardless of tier until rights clear.
E Rule review: independent mode is preferred. Solo-founder attestation is allowed only with same-identity review, non-null evidence URI and the v1.6 CI matrix. This never adds a person to customer delivery.
stated reason. 2. Fetch once by hand; confirm the endpoint, the field names and the licence text. Snapshot the terms. 3. Set url_verified_at; add the licence_channel rows with a rationale per channel. 4. Add field_map entries and any crosswalk values. 5. Add source_rank rows for every field it supplies. 6. Migration to seed source / licence / licence_channel. 7. Conformance suite must pass, including endpoint liveness.
B. Add a field
C. Add a rule
D. Add a detector
C. Add a rule
Store the cited source text and author the versioned rule pack.
Choose review_mode. independent requires a different reviewer and no attestation URI.
solo_founder_attestation requires reviewer = author and a non-null immutable attestation_uri.
Run the four review-mode tests and golden boundary cases; never edit an accepted rule version.
F. Add or amend a subscription disclosure
Version the exact customer-facing tier scope, usage-policy status and known unavailable sections.
Apply the same review-mode and immutability contract as a rule.
Hash acceptance against plan version and disclosure body—not a full/partial price schedule.
Golden test: Base request fails without active entitlement and accepted current disclosure.
sources searched as of [date].” 10. Describe a scheduled bulk download as “real time.” 11. Add a dependency, a service, or a datastore not listed in §11 without amending this spec first. 12. Hand-enter a value, correct a fact manually, or create a fact from a human reading a portal. (I13) 13. Introduce any queue, task, assignment, claim or escalation mechanism into the delivery path. (I14) This includes a “temporary” one. 14. Add a foreign key from public to commerce, or let a commerce row supply a fact. (I15) 15. Charge for a refused file, or create a payment intent before a file composes. (I16) 16. Invent a numeric threshold. Plan 2.1.4 ets none and forbids manufacturing one. If a gate genuinely needs a number, stop and ask. 17. Read this spec or CLAUDE.md through Graphify, a summariser or any other derived context artifact. They are read verbatim. (I17) 18. Let a CI gate, a citation, a licence term or a rule’s source text originate from a knowledge graph. The filesystem and the stored evidence file win. (I17) 19. Mark a task complete with a failing or skipped conformance test.
Do not charge, refund, price, credit or settle an individual file based on composed, partial or refused.
Do not invent a fixed report allowance, credit allocation, overage price or API-use limit; do not call Base unlimited.
Do not infer geometry, placement or a cost input to preserve paid value. Refuse the dependent conclusion.
□ make schema-dump produces no diff □ Migration is forward-only and numbered □ This spec is updated if a contract changed, with a §12 row
make check-boundary # I1: no city names in core/, import-linter rules make schema # migrations apply cleanly to an empty DB make conformance # the parameterised per-pack suite make test # unit tests make golden # golden property files compare (normalised — see §6.6)
make liveness # every active source responds with expected fields
make liveness is also production monitoring. A city breaking is a failing test, not a support ticket.
No CI gate reads a knowledge graph. (I17) Every gate above resolves against the filesystem. This matters most for make check-boundary, which is a literal grep -ri "san_jose" core/ — a stale or partial graph would answer “no matches” for a violation that is sitting in the working tree, and I1 would silently stop being enforced. make graph (§11.1) is a developer convenience target and is not part of make check or the definition of done.
Paste at the start of any coding session. This block must list every invariant.
Read docs/LEDGEX_SPEC.md before proposing changes.
Constraints for this task:
Task shape: <A add a source | B add a field | C add a rule | D add a detector | F add a disclosure> Follow the numbered steps for that shape in §6.1, then §6.3 definition of done. If a step requires information you do not have — an endpoint URL, a licence term, a code citation — stop and ask. Do not invent it and do not use a plausible placeholder without marking it TODO-VERIFY.
v1.2 fix. The v1.1 preamble said “Invariants I1–I12”, silently excluding I13 and I14 — the two invariants that encode the entire automated-delivery pivot — from the exact block pasted into every coding session. Any generated code that predates this fix should be re-checked against I13/I14 before merge.
make golden compares composed files against stored expectations. A raw byte-compare fails on every run because the payload legitimately varies. Before comparison, strip or canonicalise:
Field Treatment
property_file.id, any uuid Replaced with a stable positional token composed_at, delivered_at, retrieved_at, fetched_at Replaced with
Ordering of unmet_fields, refusals, attribution and omitted_for_rights is sorted lexically before comparison. Refusals are asserted as positively as values — a golden file that lost a refusal is a regression.
v1.0 of this spec specified a review_task table, three queue endpoints, timed task instrumentation and a portal connector that produced human tasks. v1.1 removed all of it. It is recorded here so it is not reinvented by accident, and so the trade-off is legible to whoever reads this next.
What was given up. The audit found that 4 of 35 cities expose a production-usable public permit feed. Human verification was the mechanism by which the other 31 could be served, and the corpus of manual resolution steps was the audit’s candidate for a durable, non-purchasable asset. Removing it narrows coverage and forfeits that corpus.
What was gained. No queue means no delivery latency, no per-file labour cost, no staffing constraint on volume, and no class of fact that is unverified-but-in-the-file. The coverage boundary becomes explicit and measurable (refusal_rate, partial_rate) rather than being absorbed invisibly by a person.
If it is ever reintroduced, it must be a separate, clearly-labelled advisory product with its own delivery path — not a fallback inside the automated one. Mixing them reintroduces exactly the unverified-fact class that I13 exists to eliminate. Plan 2.1.4 orbids it as a Phase 1 build.
Verification status: every entry below is a research lead from the audit’s evidence register, not a verified production contract. url_verified_at is null for all of them. Task shape A step 2 must be completed for each before active is set true. Licence positions marked CONFIRMED are those the audit explicitly confirmed and Plan 2.1.4 Appendix K records as active; all others require per-resource confirmation on download.
jurisdiction: ca_san_jose pack_version: "ca_san_jose@0.1.0"
portals: gis_open_data: "https://gisdata-csj.opendata.arcgis.com/" # TODO-VERIFY data_portal: "https://data.sanjoseca.gov/" # TODO-VERIFY
sources:
id: ca_san_jose.parcels display_name: Parcels steward: City of San José method: direct licence: cc_by_4_0 # CONFIRMED by audit; Plan App K active phase_status: active phase_status_reason: "CC BY confirmed; attribution attached automatically." cadence_stated: "weekly, Mondays" supplies: [parcel.apn, parcel.geometry, parcel.lot_area_gis, parcel.situs_address] url_verified_at: null
id: ca_san_jose.zoning_districts display_name: Zoning Districts steward: City of San José method: direct licence: cc_by_4_0 # CONFIRMED by audit; Plan App K active phase_status: active phase_status_reason: "CC BY confirmed; attribution attached automatically." cadence_stated: "weekly, Mondays" supplies: [zoning.district, zoning.district_verbatim] url_verified_at: null
id: ca_san_jose.building_permits_active display_name: Active Building Permits steward: City of San José method: direct licence: cc0 # CONFIRMED — strongest position in the stack phase_status: active phase_status_reason: "CC0 confirmed; no restriction. Basis of the tier 1 posture." cadence_stated: "daily" earliest_record_date: null # MUST BE MEASURED — gates record_to_ground supplies: [permits.active, permits.series_earliest] url_verified_at: null
id: ca_san_jose.building_permits_under_inspection display_name: Building Permits Under Inspection steward: City of San José method: direct licence: sj_portal_terms # CONFIRM PER RESOURCE — LD-2 phase_status: blocked_rights
phase_status_reason: "Portal terms unconfirmed. Default deny. Plan App K: blocked." cadence_stated: "daily" supplies: [permits.under_inspection] url_verified_at: null
id: ca_san_jose.planning_permits display_name: Planning Permits (30-day and 60–180-day series) steward: City of San José method: direct licence: sj_portal_terms # CONFIRM PER RESOURCE — LD-2 phase_status: blocked_rights phase_status_reason: "Portal terms unconfirmed. Default deny. Plan App K: blocked." cadence_stated: "rolling window" notes: "A rolling window is not a history. Accumulate from first ingest; show the gap." supplies: [planning.permit_history] url_verified_at: null
id: ca_san_jose.city_limits display_name: City limits / jurisdiction boundary steward: City of San José method: direct licence: unknown # LD-1 — BLOCKS EVERYTHING. See §1.1. phase_status: blocked_rights phase_status_reason: > Licence not observed. Supplies the L0 gate, so under I6 this blocks ALL channels including free_snapshot. Launch dependency LD-1. Plan App K: "Blocked for paid output — required for the jurisdiction gate; confirm licence." notes: "L0 gate. Cross-checked against county boundary; disagreement refuses." supplies: [jurisdiction.incorporated] url_verified_at: null
id: ca_san_jose.general_plan display_name: General Plan Land Use (Envision San José 2040) steward: City of San José method: direct licence: unknown # CONFIRM PER RESOURCE — LD-3 phase_status: blocked_rights phase_status_reason: > The audit's CC BY confirmation covers Parcels and Zoning Districts specifically. This resource is unconfirmed. Plan App K: blocked, confirm. supplies: [zoning.general_plan_designation] url_verified_at: null
id: ca_san_jose.urban_villages display_name: Urban Village / plan-area boundaries steward: City of San José method: direct licence: unknown # LD-3 phase_status: blocked_rights phase_status_reason: "Unconfirmed. Plan App K: blocked, confirm; may override zoning." notes: "San José-specific. Village plan areas can override base zoning standards." supplies: [overlay.urban_village] url_verified_at: null
id: ca_san_jose.overlays display_name: Specific plans, planned development, historic, conservation overlays steward: City of San José method: direct licence: unknown # LD-3 phase_status: blocked_rights phase_status_reason: "Unconfirmed. Plan App K: blocked, confirm." supplies: [overlay.specific_plan, overlay.planned_development, overlay.historic] url_verified_at: null
id: ca_san_jose.code_enforcement display_name: Code enforcement cases steward: City of San José method: portal licence: unknown phase_status: not_machine_readable phase_status_reason: > No documented machine-readable series found. Field is OMITTED with reason SOURCE_NOT_MACHINE_READABLE. It is never looked up by hand (I13). Consequence: one corroborating signal for record-to-ground is unavailable. active: false # I14: portal sources can never be active supplies: [ordinance.code_enforcement] url_verified_at: null
id: ca_san_jose.fee_schedules display_name: Development, impact and permit fee schedules steward: City of San José method: manual licence: unknown phase_status: not_machine_readable phase_status_reason: > Published as documents, not a feed. Fee VALUES live in the rule pack (rules/fees.yaml) as dated, cited parameters authored and reviewed at build time — NOT fetched per request. This is a RULE, not a source. Re-authoring on a fee change is a pack commit, not a runtime human step.
active: false supplies: [] # v1.2: supplies nothing. cost.city_fees
url_verified_at: null
id: ca_santa_clara_county.parcels display_name: County parcels steward: Santa Clara County method: direct licence: unknown phase_status: blocked_rights phase_status_reason: > Plan App K: "Internal cross-check only; paid output blocked pending licence confirmation." Export cadence is not factual freshness (Audit §B, M-04). cadence_stated: "annual, around August" supplies: [parcel.apn_county, parcel.geometry_county] url_verified_at: null
id: ca_santa_clara_county.hazards display_name: Geologic, flood, fire and habitat hazard layers steward: Santa Clara County method: direct licence: unknown # LD-3 phase_status: blocked_rights phase_status_reason: "Plan App K: 'County hazard layers — Blocked, confirm licence.'" supplies: [hazard.liquefaction, hazard.landslide, hazard.fault, hazard.habitat_plan] url_verified_at: null
id: ca_santa_clara_county.assessor display_name: Assessor characteristics and assessed value steward: Santa Clara County Assessor method: portal licence: unknown phase_status: not_machine_readable phase_status_reason: > Portal lookup only. Assessor characteristics are OMITTED unless a machine-readable route is confirmed. The ~$52,535 FY25 paid combination is EXCLUDED from the Phase 1 stack (M-06, Plan §16) and is a static file, not an API, so it would not solve this either. active: false supplies: [property.year_built, property.sqft, property.beds, property.baths, tax.assessed_value, tax.annual_tax] url_verified_at: null
id: ca_santa_clara_county.recorder display_name: Clerk-Recorder — ownership, sales, recorded maps steward: Santa Clara County Clerk-Recorder method: manual licence: unknown phase_status: not_machine_readable phase_status_reason: > Per-parcel manual lookup. Ownership, sale history and recorded maps are OMITTED. Consequence: derived.sb9_eligibility must REFUSE on the prior-lot-split disqualifier rather than assume no prior split. Recorded in refusals.yaml. active: false supplies: [ownership.current, ownership.sale_history, legal.recorded_maps] url_verified_at: null
id: us_fema.nfhl display_name: FEMA National Flood Hazard Layer steward: FEMA method: direct licence: us_gov_open phase_status: active phase_status_reason: "Federal open data. Plan App K: open sources active." supplies: [hazard.flood_zone] url_verified_at: null
id: us_nrcs.soil_survey display_name: NRCS soil survey steward: USDA NRCS method: direct
licence: us_gov_open phase_status: active phase_status_reason: "Federal open data. Plan App K: open sources active." supplies: [hazard.soil_type] url_verified_at: null
id: us_usgs.3dep display_name: USGS 3DEP lidar steward: USGS method: bulk licence: usgs_3dep_open # free, no use restrictions (M-14) phase_status: blocked_engineering phase_status_reason: > Rights are clear; ACCURACY IS NOT. Coverage, extraction repeatability and measured error are pending. The rights gate and the engineering gate are DIFFERENT GATES and must not be conflated. Launch dependency LD-4. active: false supplies: [geometry.building_footprint, geometry.slope, geometry.elevation] url_verified_at: null
id: ca_dtsc.envirostor display_name: DTSC EnviroStor / Cortese List steward: California DTSC method: direct licence: unknown # LD-3 phase_status: blocked_rights phase_status_reason: > Plan App K: "unconfirmed sources blocked from paid output." Spec v1.1's §7.3 summary said 'Yes' — that table is deleted in v1.2 as a second normative source. supplies: [hazard.contamination_sites] url_verified_at: null
id: ca_cslb.licence_lookup display_name: CSLB contractor licence lookup steward: California CSLB method: direct licence: unknown # LD-3 phase_status: blocked_rights phase_status_reason: "Unconfirmed. Plan App K: blocked from paid output." supplies: [contractor.licence_status] url_verified_at: null
id: ca_valley_water.creek_jurisdiction display_name: Valley Water creek, riparian and flood-protection jurisdiction steward: Santa Clara Valley Water District method: direct licence: unknown phase_status: deferred phase_status_reason: > Checklist §3 names this San José-specific and "frequently decisive": creek setbacks and encroachment permits can eliminate a rear-yard ADU site that zoning would otherwise allow. Access mode and terms unconfirmed. DEFERRED. Field is a declared coverage gap; rear-yard conclusions carry a stated caveat. active: false supplies: [hazard.creek_jurisdiction] url_verified_at: null
id: ca_scc_aluc.airport_influence display_name: Airport influence and safety areas — Mineta San José, Reid-Hillview steward: Santa Clara County Airport Land Use Commission method: direct licence: unknown phase_status: deferred phase_status_reason: > Blueprint §08 names this one of the five San José irregularities the pack format exists to absorb; Checklist §15 calls it a genuine differentiator. Two airports with influence areas over dense residential neighbourhoods; height and in some zones residential intensity are constrained. DEFERRED — declared coverage gap, and height-dependent conclusions carry a stated caveat. active: false supplies: [overlay.airport_influence] url_verified_at: null
id: ca_calfire.fhsz display_name: CAL FIRE Fire Hazard Severity Zones (SRA / LRA) and WUI steward: CAL FIRE method: direct licence: unknown phase_status: deferred phase_status_reason: > San José is predominantly LRA with VHFHSZ and WUI exposure in the eastern and southern foothills. Checklist §3 requires confirming the currently ADOPTED CITY map, not only the state layer — that confirmation is not done. DEFERRED. active: false supplies: [hazard.fire_severity] url_verified_at: null
id: ca_san_jose.water_retailers
display_name: Retail water service areas and sanitary sewer authority steward: Multiple — municipal, investor-owned and mutual utilities method: direct licence: unknown phase_status: deferred phase_status_reason: > Blueprint §08: "utility provider is not a city-level constant." Which retailer serves a parcel is a SPATIAL LOOKUP, and connection fees, meter rules and will-serve processes differ by retailer. A single-provider assumption produces wrong cost numbers on a meaningful share of parcels. No consolidated machine-readable service-area layer identified. DEFERRED. active: false supplies: [utility.water_provider, utility.sewer_authority, utility.separate_meter_required] url_verified_at: null
This file is the sole runtime authority for channel eligibility. See §7.3.
licences:
id: cc0 display_name: CC0 1.0 restriction: open commercial_use: allowed redistribution: allowed channels: { free_snapshot: true, paid_property_file: true, api: true, bulk_export: true }
id: cc_by_4_0 display_name: CC BY 4.0 restriction: attribution commercial_use: allowed redistribution: allowed attribution_text: "Contains data from the City of San José." channels: { free_snapshot: true, paid_property_file: true, api: true, bulk_export: true }
id: sj_portal_terms display_name: San José per-resource portal terms restriction: unknown commercial_use: unknown redistribution: unknown channels: { free_snapshot: false, paid_property_file: false, api: false, bulk_export: false } note: "DEFAULT DENY until per-resource confirmation is recorded. Pending evidence artifact."
id: sanborn_asserted display_name: Third-party Sanborn rights asserted restriction: noncommercial commercial_use: prohibited redistribution: prohibited channels: { free_snapshot: false, paid_property_file: false, api: false, bulk_export: false } note: "Source excluded from the stack entirely, not merely gated."
id: usgs_3dep_open display_name: USGS 3DEP — free, no use restrictions restriction: open commercial_use: allowed redistribution: allowed channels: { free_snapshot: true, paid_property_file: true, api: true, bulk_export: true } note: "Rights clear. Accuracy is a SEPARATE engineering gate (LD-4)."
id: us_gov_open display_name: US federal open data restriction: open commercial_use: allowed redistribution: allowed channels: { free_snapshot: true, paid_property_file: true, api: true, bulk_export: true }
id: unknown display_name: Licence not yet observed restriction: unknown
commercial_use: unknown redistribution: unknown channels: { free_snapshot: false, paid_property_file: false, api: false, bulk_export: false } note: "The default. Silence is not permission."
Spec v1.1 §7.3 was a “Source summary” table with a Phase 1 column. It is deleted. It was a second normative statement of channel eligibility and it had already drifted: it marked City Limits, SCC Hazards, EnviroStor and CSLB as Phase 1 “Yes” while their licence was unknown (all channels denied) and Plan 2.1.4 ndix K recorded them as blocked. Two normative sources always drift, and this one drifted toward permissiveness — the exact direction the Sanborn finding warns about.
Channel eligibility for any fact is determined solely by licences.yaml, seeded into licence + licence_channel and enforced by the composer (I6). Nothing else grants a channel. Specifically:
test_licences_not_broader_than_appendix_k For every source, assert that licences.yaml does NOT grant a channel that Appendix K records as blocked. The reverse is permitted — licences.yaml may be stricter than the commercial record, never looser.
To see current posture, query the ledger — do not read a table in a document:
GET /v1/jurisdictions/ca_san_jose/sources
Five known sources are deferred for Phase 1: Valley Water creek jurisdiction, airport influence, CAL FIRE FHSZ, water/sewer service areas and school attendance boundaries. Deferred means not integrated; it never means safe to assume away.
The source and field rows remain declared with a reason and cannot be runtime-active.
Every non-refused file names each deferred field in unmet_fields and emits SOURCE_DEFERRED.
If a deferred field is a required input to a conclusion's versioned dependency manifest, that conclusion is refused by name and identifies the missing input.
The remaining Property File may still be partial. A dependent-conclusion refusal does not automatically refuse the whole file unless a minimum core field is also missing.
A caveat may accompany a valid weaker conclusion only when a declared substitution supplies every required input. A caveat alone never replaces evidence.
{ "code": "SOURCE_DEFERRED", "field_key": "hazard.creek_jurisdiction", "source_id": "ca_valley_water.creek_jurisdiction", "cascades_to": ["derived.adu_max_size", "derived.buildable_envelope"], "behavior": "refuse_each_dependent_conclusion", "remedy": "Confirm with Valley Water before relying on siting conclusions." }
S Core-required is categorical product structure, not a numeric performance threshold. Missing any core-required field refuses the complete file. Every derived conclusion also has a versioned required-input manifest; a missing required input refuses that conclusion while the remaining file may be partial.
field_key claim type unit stale core def. notes jurisdiction.incorporated public_record boolean — 180 Y Minimum core; LD-1 blocks all channels.
parcel.apn public_record string — — Y Minimum parcel identity.
parcel.situs_address public_record string — 400 Display identity.
parcel.geometry public_record geometry — 400 Y Minimum spatial core.
parcel.apn_county public_record string — — Cross-check only.
parcel.geometry_county public_record geometry — 400 Cross-check only.
parcel.lot_area_gis derived_conclusion number sqft 400 GIS-measured; never average with record.
parcel.lot_area_recorded public_record number sqft — Store separately.
property.year_built public_record number year — Assessor omitted.
property.sqft public_record number sqft — Assessor omitted.
property.beds public_record number count — Assessor omitted.
property.baths public_record number count — Assessor omitted.
tax.assessed_value public_record number usd 400 Assessor omitted.
tax.annual_tax public_record number usd 400 Assessor omitted.
zoning.district public_record string — 30 Y Minimum zoning core.
zoning.district_verbatim public_record string — 30 Never discard.
zoning.general_plan_designation public_record string — 90 Required only by declared conclusions.
overlay.urban_village public_record string — 90 May override base zoning.
overlay.specific_plan public_record string — 90 Declared conclusion input.
overlay.planned_development public_record boolean — 90 PD permit may control.
overlay.historic public_record enum — 90 SB 9 conclusion input.
overlay.airport_influence public_record enum — 180 Y Height-dependent conclusions refuse if absent.
permits.active public_record object — 2 Required for permit-dependent conclusions.
permits.under_inspection public_record object — 2 Rights-blocked LD-2.
permits.series_earliest public_record date — — Required for record-to-ground.
planning.permit_history public_record object — 7 Rolling window; rights-blocked.
ordinance.code_enforcement public_record object — 30 Portal-only; omitted.
ordinance.tree_protection public_record object — 365 Rule data. Unknown tree locations refuse full siting conclusions.
ordinance.rent_restriction public_record object — 365 Confirm coverage boundary.
hazard.flood_zone public_record string — 365 FEMA.
Engineering Reference Spec v1.7
S The second half completes the same normative vocabulary. The def. column marks a declared deferred source; deferral never weakens a required-input rule.
field_key claim type unit stale core def. notes hazard.creek_jurisdiction public_record boolean — 365 Y Creek-dependent siting conclusions refuse.
hazard.liquefaction public_record enum — 730 Tune tolerance.
hazard.landslide public_record enum — 730 hazard.fault public_record boolean — 730 hazard.fire_severity public_record enum — 365 Y Fire-dependent conclusions refuse.
hazard.habitat_plan public_record boolean — 365 hazard.soil_type public_record string — — NRCS.
hazard.contamination_sites public_record object — 90 Proximity is not contamination.
ownership.current public_record string — 180 Recorder omitted; minimise retention.
ownership.sale_history public_record object — 180 Recorder omitted.
legal.recorded_maps public_record object — — Required for SB 9 prior-split test.
utility.water_provider public_record string — 365 Y Provider-dependent conclusions refuse.
utility.sewer_authority public_record string — 365 Y Provider-dependent conclusions refuse.
utility.separate_meter_required public_record boolean — 365 Y Provider-dependent conclusion.
school.attendance_boundary public_record object — 180 Y School-fee conclusion input.
contractor.licence_status public_record object — 30 CSLB.
listing.active_status third_party_record object — 1 Y MLS/vendor only; not a public record.
cost.city_fees public_record object usd 365 Rule pack via L5.
geometry.building_footprint derived_conclusion geometry — — 3DEP gate LD-4.
geometry.slope derived_conclusion number pct — 3DEP.
geometry.elevation derived_conclusion number ft — 3DEP.
derived.sb9_eligibility derived_conclusion object — — Refuses when any declared required input is missing.
derived.adu_max_size derived_conclusion number sqft — Refuses when geometry/height/hazard inputs required by method are missing.
derived.buildable_envelope derived_conclusion geometry — — Full envelope refuses on missing siting input.
derived.record_to_ground derived_conclusion object — — Requires geometry, permit depth and permit series.
derived.economics derived_conclusion object — — May use explicitly accepted, labelled assumptions.
assumption.construction_cost_psf user_assumption number usd — Request-scoped; never fact ledger.
assumption.monthly_rent user_assumption number usd — Request-scoped; never fact ledger.
condition.roof_hvac_foundation user_assumption object — — Separate non-fact input.
Engineering Reference Spec v1.7
C Migration 0003a and jurisdictions/ca_san_jose/conclusions.yaml are part of the build contract. Required inputs are declared before code runs; no detector or calculator may silently weaken them at request time.
CREATE TABLE conclusion_dependency (
conclusion_field_key text NOT NULL REFERENCES field_definition(field_key),
input_field_key text NOT NULL REFERENCES field_definition(field_key),
method_version text NOT NULL,
required boolean NOT NULL DEFAULT true,
substitution_field_key text REFERENCES field_definition(field_key),
PRIMARY KEY (conclusion_field_key, input_field_key, method_version)
);
-- CI: every derived field has a versioned manifest; every missing required input
-- produces a named dependent-conclusion refusal. A substitution is valid only
-- when declared here and shown to the customer with its confidence effect.
-- test_conclusion_dependencies_declared
-- test_missing_input_refuses_dependents
-- test_core_required_refuses_fileRequiredness rules
Minimum file core: jurisdiction.incorporated, parcel.apn, parcel.geometry and zoning.district. Missing any one refuses the complete file.
Permit-, SB 9-, geometry-, utility-, hazard-, school- and title-dependent conclusions use their declared required-input manifests. Missing a required input refuses only those conclusions unless the minimum core is also lost.
A weaker machine source may substitute only when conclusion_dependency names it. A user assumption may substitute only for scenario/economic inputs expressly designed for assumptions; it never becomes a retrieved fact.
Deferred is a source phase status, not permission to weaken a conclusion. Deferred required inputs still cascade a named refusal.
Engineering Reference Spec v1.7
Refusals (HTTP 200, status: "refused"):
Code Stage Meaning
JURISDICTION_UNRESOLVED L0 Address could not be resolved to a parcel JURISDICTION_UNSUPPORTED L0 Resolved outside a loaded pack (e.g. unincorporated pocket) JURISDICTION_BOUNDARY_CONFLICT L0 City and county layers disagree — never pick a winner PARCEL_NOT_FOUND L0 APN not present in any parcel layer SOURCE_UNVERIFIED L1 url_verified_at is null; source may not run in production SOURCE_UNAVAILABLE L1 Endpoint failed after retries SOURCE_NOT_MACHINE_READABLE L1 Source is classified portal or manual. No fetch attempted; field omitted. SOURCE_DEFERRED L1 v1.2 — source is real and known but out of Phase 1 scope. Field omitted as a declared gap (§7.4). CROSSWALK_UNMAPPED L3 Source value has no canonical mapping. Fails the build. RULE_UNAVAILABLE L5 No rule effective at the as-of date PERMIT_SERIES_TOO_SHALLOW L6 Structure predates the measured series start GEOMETRY_TIER_DISABLED L7 3DEP gate not cleared COVERAGE_GAP L7/L8 A required field could not be retrieved PERMIT_LAYER_UNAVAILABLE L1/L8 No machine-readable permit source in this jurisdiction. Cascades: every permit-dependent conclusion is refused. Tier 3 default. RIGHTS_BLOCKED L0/L8 Licence forbids this field in this channel LICENCE_UNKNOWN L0/L8 Default deny. Applies at L0 when a gate source is unconfirmed (§1.1). INSUFFICIENT_COVERAGE L8 Too many required fields unmet for the file to be meaningful. Refuse rather than deliver a shell. DISCLOSURE_NOT_ACCEPTED ACCESS_NOT_ENTITLED L8 L8 v1.2 — Base request lacks an active paid channel entitlement requested or current withoutacceptance; disclosure an no composition and no accepted per-file payment state. order (§13).
Errors (application/problem+json): schema-drift (502), source-timeout (504), invalid-request (400), not-found (404), conflict (409), internal (500). A constraint violation on fact is always a 500 and always a bug — never surface it as a refusal.
Spec v1.1 §9 listed CONFIDENCE_BELOW_THRESHOLD — "Below the channel's floor." No channel confidence floor is defined in any of the four governing documents, and none may be invented here. Plan 2.1.4 tates plainly:
“No pass/fail threshold is set for any measure above, because setting one without evidence would manufacture a false gate.”
Implementing this code would have required a coding agent to pick a number. The code is removed. If a floor is genuinely warranted after the first 50 attempted compositions, it must be set in a revision of the governing plan first, with the evidence that justified it, and then reflected here — never the other way round.
Both tracks are queries against production tables, never a maintained spreadsheet. A dashboard maintained beside the data decays exactly when the evidence matters most.
-- 0013_measurement_views.sql
CREATE VIEW v_track_a AS
SELECT e.jurisdiction_id,
e.type,
e.detector_version,
count(*) AS detected,
count(*) FILTER (WHERE e.outcome = 'confirmed') AS true_positive,
count(*) FILTER (WHERE e.outcome = 'false_positive') AS false_positive,
count(*) FILTER (WHERE e.outcome = 'unresolved') AS unresolved,
count(*) FILTER (WHERE e.outcome = 'open') AS still_open,
round(
count(*) FILTER (WHERE e.outcome = 'false_positive')::numeric
/ NULLIF(count(*) FILTER (WHERE e.outcome <> 'open'), 0), 4
) AS false_positive_rate
FROM parcel_exception e
GROUP BY 1,2,3;
-- Exception frequency needs a PARCEL denominator, not an exception denominator.
-- "Hit rate" = share of examined parcels producing at least one exception.
CREATE VIEW v_track_a_hitrate AS
WITH examined AS (
SELECT DISTINCT jurisdiction_id, parcel_id
FROM property_file
WHERE status <> 'refused'), hit AS (
SELECT DISTINCT jurisdiction_id, parcel_id
FROM parcel_exception
WHERE severity <> 'info'
)
SELECT e.jurisdiction_id,
count(*) AS parcels_examined,
count(h.parcel_id) AS parcels_with_exception,
round(count(h.parcel_id)::numeric / NULLIF(count(*), 0), 4) AS hit_rate
FROM examined e
LEFT JOIN hit h USING (jurisdiction_id, parcel_id)
GROUP BY 1;
-- Automated delivery economics. Replaces the v1.0 manual-hours view.
-- Support requests are PRE-AGGREGATED per file so the join cannot multiply rows.
CREATE VIEW v_delivery_economics AS
WITH support AS (
SELECT property_file_id,
count(*) AS requests,
count(*) FILTER (WHERE caused_correction) AS corrections
FROM support_request
WHERE property_file_id IS NOT NULL
GROUP BY 1
)
SELECT pf.jurisdiction_id,
date_trunc('week', pf.composed_at) AS week,
count(*) AS files,
count(*) FILTER (WHERE pf.status = 'composed') AS full_files,
count(*) FILTER (WHERE pf.status = 'partial') AS partial_files,
10.2 Track B — subscription and product-use evidence
Track B uses provider-captured subscription payment, entitlement and product events. Accepted prices, authorizations, deposits, commitments
and unpaid intentions are excluded from paid conversion.
Measure Authoritative record Rule
Free-to-Base conversion captured billing + active Base entitlement Actual capture only.
Additional parcels distinct parcel events after first Property File Same customer/account.
Repeat use / early retention return and continued-active events Window disclosed; no invented threshold.
Repeated sections section_viewed events Count repeated use, not mere rendering.
Placement / cost value module engagement + later parcel events Separate from payment evidence.
Requested capabilities capability_requested events Interest; not paid evidence or a promised tier.Captured subscription billing is paid-access evidence. It is never divided by file count or grouped as a captured price per Property File.
Property File outcome, delivery cost, support, correction and module use remain separate measurements.
An authorization, accepted checkout, deposit, commitment, stated willingness or unpaid intention is not a captured subscription payment.
Initial success thresholds, if used, are management priors under the existing threshold-reset and evidence-record rules.
separately for full and partial 11 Repeat-purchase rate commerce.v_track_b_repeat.repeat_rate
Measures 4, 10 and 11 had no schema at all before v1.2. See §13.
Measure Query Management prior
Exception hit rate v_track_a_hitrate.hit_rate Below ~5% → reprice, narrow, or stop the wedge False-positive rate v_track_a.false_positive_rate Above buyer tolerance → do not ship structure-status conclusions Automated cost per file v_delivery_economics.avg_compute_usd Must fall with volume. Flat cost means the pipeline is not amortising and the automation thesis is wrong. Support rate v_delivery_economics.support_rate No prior set. Rising support rate means the automated file is not self-explanatory. Correction rate v_delivery_economics.correction_rate No prior set. The sharper of the two signals. Refusal rate v_delivery_economics.refusal_rate No prior set. High and stable is honest; high and rising means source decay. Partial rate v_delivery_economics.partial_rate No prior set. The tier-3 tax, made visible. Accepted price commerce.v_track_b avg_paid_price No prior set. Measured output of Track B, never an input. Repeat purchase commerce.v_track_b_repeat.repeat_rate Free alternative sufficient → move up-market or discontinue
These are pre-evidence management priors, not validated facts. All measures with “no prior set” are recorded as open management decisions in Plan 2.1.4 to be reviewed against the first 50 attempted compositions — including refusals and partials, not the first 50 sales. A threshold change requires a new revision of the governing plan and a cited reason. Retroactive movement is prohibited.
What changed and why it matters. v1.0 measured manual hours per file against the plan’s “>4 hours and <~$500” prior. With no manual step, that prior is inapplicable — not passed, inapplicable, and must never be reported as satisfied Plan 2.1.4 §18). The automated equivalents are not a like-for-like substitute: cost per file will be trivially low from day one, so it cannot function as a gating threshold the way manual hours did. The load moves to support rate, correction rate and refusal rate, which measure whether an unassisted file is actually good enough to sell. Until those priors are set, this phase has a weaker economic gate than v1.0 had. That is a real cost of the pivot and should not be papered over.
Allowed dependencies. Adding anything not listed requires amending this spec.
Concern Choice
Runtime Python 3.12 DB PostgreSQL 16 + PostGIS 3.4 (managed) API FastAPI + Pydantic v2 + uvicorn Migrations Plain SQL, forward-only Object store S3-compatible (R2 preferred) Scheduling cron + job_run + FOR UPDATE SKIP LOCKED Geo (separate image) PDAL, GDAL, rasterio, Shapely 2, GeoPandas, pyproj
Concern Choice
Local batch DuckDB over exported Parquet Payments Stripe (stripe-python) — v1.2, §13 Test pytest, pytest-postgresql Lint ruff, import-linter
Deliverables: three containers (api, worker, geo), one managed Postgres, one bucket. No Kubernetes, no Redis, no message broker, no second datastore.
Config keys (env): DATABASE_URL, OBJECT_STORE_URL, OBJECT_STORE_KEY, PACKS_DIR, DEFAULT_CHANNEL, LOG_LEVEL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, EMAIL_HASH_PEPPER.
v1.2 removes GEOMETRY_TIER_ENABLED. v1.1 had both an env var and jurisdiction.geometry_tier_enabled, which is two sources of truth for one switch. The column is authoritative. The geometry tier is a per-jurisdiction property, not a per-deployment one, and a global env var would silently enable it for a jurisdiction whose gate had not cleared.
New in v1.3. Phase 1 is built by a solo founder with AI-assisted tooling (Blueprint §06, §13). Token cost per coding session is a real constraint on that capacity, and it grows with repository size rather than with product scope.
Graphify is adopted to reduce it. It parses the repository with tree-sitter into a queryable knowledge graph, so a coding agent retrieves a relevant subgraph — the files, symbols and relationships that bear on the task — instead of reading raw files. Reported reductions run from roughly 40% on small repositories to one or two orders of magnitude on large ones; the saving scales with repository size, so it is modest today and grows as jurisdictions/ accumulates packs.
Status Optional accelerator. Not required to build, test or ship. Layer Developer tooling. Runs on the contributor’s machine against the working tree. Ships in Nothing. It is in none of the three containers. Runtime role None. It is not in the request path, the ingest path, or the composer. Target make graph — rebuild the graph. make graph-clean — delete it. Artifact .graphify/ — generated, gitignored, disposable.
Why it is not in the §11 dependency table §11 lists what runs in the api, worker and geo containers. Graphify runs in none of them, and adding it there would imply a runtime dependency the product does not have. A contributor without Graphify installed must never be blocked: no CI gate, no task shape and no definition-of-done item depends on it.
What it may index
Path Indexed Note
core/, api/, pipelines/, geo/, commerce/, tests/ Yes The primary win. jurisdictions/** including evidence/ Yes Graphify is multimodal, so the dated PDFs and screenshots in evidence/ are genuinely useful for navigation — finding which ordinance backs which rule. docs/LEDGEX_SPEC.md, CLAUDE.md Never served from the graph See below. db/migrations/ Yes Read-only navigation. Migrations remain forward-only and hand-written.
The three rules that make this safe (I17) 1. The spec and the implementation rules are read verbatim, always. Graphify returns a compressed view. An agent that reads the invariant list through a summariser can act on an invariant it never saw in full. This is not hypothetical: spec v1.1’s own session preamble said “Invariants I1– I12” and thereby dropped I13 and I14 — the two invariants encoding the entire automated-delivery pivot — from every coding session until v1.2 caught it. That was a lossy summary written by a human. A machine-generated one carries the same risk with less visibility. The invariants are the only thing standing between a vibe-coded pipeline and a rights breach; they are never compressed.
Historical entries remain below. Current engineering corrections and additions are recorded without rewriting predecessor rows.
Date Ver Change Reason
Aug 2026 1.5 Review modes for rules/disclosures; immutable attestation evidence. Solo-founder bootstrap control.
Aug 2026 1.6 Subscription commerce and Base Core independent of LD-4. Implements Plan 2.1.4.
Aug 2026 1.6 correction Restored I1-I18 and corrected duplicate top-level §14. Closed generated-document defects.
Aug 2026 1.7 Folded Architecture Addendum A-1: I19/I20, outcome loop, provider slot, edge Creates measurement and infrastructure gates guard and six make targets. without commercial or human-path changes.
vocabulary. Aug 2026 1.1 L8 renamed “Composition & Delivery is automated; there is no Review” → “Composition & review stage. Delivery”; core/review/ → core/deliver/. Aug 2026 1.1 Added I13 and I14. Enforces the pivot in code and CI rather than in prose. Aug 2026 1.1 IRREVERSIBLE: dropped Human-review queue removed from review_task, review_status, both Phase 1. Design retained in annex review FKs and the three §6.7. /v1/review-tasks/* endpoints. Aug 2026 1.1 IRREVERSIBLE: removed escalated escalated has no meaning without a from file_status; removed queue; the other two are unverified from conflict_state; unreachable once I13 holds. dropped fact.machine_verified. Aug 2026 1.1 Removed HUMAN_REVIEW_REQUIRED. Refusal replaces escalation as the Added PERMIT_LAYER_UNAVAILABLE, terminal state for an unmet field. SOURCE_NOT_MACHINE_READABLE, INSUFFICIENT_COVERAGE. Aug 2026 1.1 Replaced v_track_b with Manual hours replaced by v_delivery_economics; added automated cost, support rate and compose_ms, source_calls, refusal rate. compute_cost_micros, unmet_fields; added support_request. Aug 2026 1.2 Governing document repointed Plan 2.1.1 (Aug 2026) superseded from Business Plan 2.1 to 2.1.1. 2.1 and formally adopted this spec, Status changed from “draft for Blueprint v1.1 and Checklist v1.1 as internal review” to “current controlling engineering. controlling engineering”. Added §0.1 authority order and §0.2 reference remap. Aug 2026 1.2 Struck the “DIVERGENCE FROM The divergence is closed. Plan 2.1.1 BUSINESS PLAN 2.1” box. change-log rows 2–3 adopted automated delivery and withdrew Concierge. Asserting a live conflict that no longer exists misdirects any reader. Aug 2026 1.2 Added §0.3 recording three drifts in The Blueprint is design rationale; Blueprint v1.1 §05 (duplicate this spec’s DDL is the contract. The conflict_state, stale unverified drift had to be adjudicated value, derived_from array vs somewhere. fact_input).
Historical rows on this page describe superseded v1.2 commerce and independent-review controls. They are preserved as change evidence, not current implementation instructions. Sections 3.9 and 13 of v1.6 control.
facts. Added property_file_fact.use, confirmed. Intended. test_gate_covers_internal_facts, and §1.1. Aug 2026 1.2 Added I15 (commerce/public Plan 2.1.1 §05: “Refused files are separation) and I16 (a refused file not charged.” Previously prose-only is never charged). and unenforceable. Aug 2026 1.2 Fixed §6.5 preamble: I1–I12 → v1.1’s preamble silently excluded I1–I16. I13 and I14 — the two invariants encoding the entire pivot — from the block pasted into every coding session. Aug 2026 1.2 Fixed the licence-inheritance v1.1’s trigger collided with the I4 trigger. It no longer performs immutability trigger, and v1.1’s UPDATE fact; it validates and raises. own text broke off mid-sentence Inheritance is computed in acknowledging it. As written, v1.1 core/store.derive(). Made the threw on the first derived fact with trigger DEFERRABLE INITIALLY lineage. DEFERRED. Added restriction_severity(). Aug 2026 1.2 Deleted §7.3 “Source summary” It was a second normative table; replaced with a pointer to statement of channel eligibility and licences.yaml as the sole runtime had already drifted permissively — authority, plus marking City Limits, SCC Hazards, EnviroStor test_licences_not_broader_than_appendix_k . and CSLB “Yes” while their licence was unknown and Plan App K recorded them blocked. Aug 2026 1.2 Added source.phase_status + Makes the reason a source is off phase_status_reason and the machine-readable and prevents the source_active_matches_phase §7.3 class of drift recurring. constraint. Aug 2026 1.2 Fixed §5.3 tier-2 contradiction. v1.1 said tier 2 is “marked partial” Only tier 1 can yield composed. and, two lines later, that “full files are limited to tier 1 and tier 2.” Aug 2026 1.2 IRREVERSIBLE: removed No channel confidence floor is CONFIDENCE_BELOW_THRESHOLD. defined in any governing document, and Plan 2.1.1 §15/§18 forbid inventing one. Implementing it would have forced a coding agent to pick a number. Aug 2026 1.2 Added SOURCE_DEFERRED refusal code, The Checklist names all five as source_phase_status.deferred, material and San José-specific; the field_definition.phase1_deferred, v1.1 source list had none of them, §7.4. Deferred five named sources so the fields would have been (Valley Water, airport influence, CAL silently unsupplied. Declared gaps, FIRE FHSZ, water/sewer retailers, not silence. school boundaries). Aug 2026 1.2 Added missing field_definition These were referenced by rows: parcel.situs_address, sources.yaml supplies: but absent parcel.apn_county, from §8, which would have failed parcel.geometry_county, the source_rank.field_key FK at seed geometry.elevation, time. legal.recorded_maps, cost.city_fees, property.beds, property.baths, tax.annual_tax. Aug 2026 1.2 Added §13 — the commerce Plan 2.1.1 §05/§10/§14 make schema: customer, disclosure, pre-purchase disclosure a order, payment, plus v_track_b and mandatory product requirement v_track_b_repeat. and Track B control. v1.1 had no customer, order, disclosure or payment entity at all, so measures 4, 10 and 11 were unmeasurable. Aug 2026 1.2 IRREVERSIBLE: removed Price is a fact about an order, not property_file.price_cents. Added about a composed file. Keeping it payload_hash, storage_cost_micros, on property_file made I16 file_refused_not_delivered. unenforceable across the two concerns. Aug 2026 1.2 rule_as_of returns SETOF rule, not A scalar-composite return with no rule. Added match yields a row of nulls rather rule_reviewer_independent. than no row, silently defeating RULE_UNAVAILABLE. Independent review was mandated in prose but unenforced. Aug 2026 1.2 fact_one_current_per_source now The v1.1 index collided two derived includes method_version. facts for the same (parcel, field) under different method versions. Aug 2026 1.2 Removed the GEOMETRY_TIER_ENABLED Two sources of truth for one switch; env var; a global env var could enable the jurisdiction.geometry_tier_enabled tier for a jurisdiction whose gate is authoritative. had not cleared. Aug 2026 1.2 Added §6.6 golden-file make golden was specified as a normalisation rules. “byte-compare (normalised)” with no definition of the normalisation, so it could not be implemented deterministically.
Commerce measures access and recurring billing without supplying or altering a property fact. Public may never reference commerce. Property File outcome never sets an individual-file charge.
CREATE TYPE commerce.access_tier AS ENUM('validation_cohort','limited_free','base');
CREATE TYPE commerce.subscription_status AS ENUM('incomplete','active','past_due','canceled');
CREATE TYPE commerce.billing_status AS ENUM('pending','captured','failed','refunded');
CREATE TYPE commerce.entitlement_status AS ENUM('active','expired','revoked');
CREATE TYPE commerce.usage_policy_status AS ENUM('observing','established');
CREATE TABLE commerce.customer (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email_hash text NOT NULL UNIQUE,
customer_type commerce.customer_type NOT NULL,
first_seen_at timestamptz NOT NULL DEFAULT now()
);
COMMERCIAL BOUNDARY
The first approximately 25 users are validation_cohort. limited_free demonstrates parcel and zoning information but cannot request the
complete integrated Property File. base grants access to Base Core. Base is not unlimited; numerical usage limits are not yet established. CREATE TABLE commerce.plan_version (
id text PRIMARY KEY, tier commerce.access_tier NOT NULL,
version integer NOT NULL CHECK(version > 0),
display_name text NOT NULL, monthly_price_minor integer NOT NULL
CHECK(monthly_price_minor >= 0), currency char(3) NOT NULL DEFAULT 'USD',
scope_uri text NOT NULL, scope_hash text NOT NULL,
usage_policy_status commerce.usage_policy_status NOT NULL,
usage_policy_uri text NOT NULL, usage_policy_hash text NOT NULL,
effective_from timestamptz NOT NULL, effective_to timestamptz,
UNIQUE(tier,version),
CHECK((tier='base' AND monthly_price_minor>0)
OR (tier<>'base' AND monthly_price_minor=0))
);
-- Initial controlled Base seed uses 2000 USD minor units (approximately $20/month).
-- A price change creates a new plan version.
-- No report_allowance, credit, overage or api_limit column exists.Provider-captured subscription billing is the only paid-conversion evidence. A billing event belongs to a subscription, never to a Property File.
CREATE TABLE commerce.subscription (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id uuid NOT NULL REFERENCES commerce.customer(id),
plan_version_id text NOT NULL REFERENCES commerce.plan_version(id),
provider text NOT NULL, provider_customer_ref text NOT NULL,
provider_subscription_ref text NOT NULL UNIQUE,
status commerce.subscription_status NOT NULL,
current_period_start timestamptz, current_period_end timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE commerce.billing_event (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id uuid NOT NULL REFERENCES commerce.subscription(id),
provider_event_ref text NOT NULL UNIQUE,
status commerce.billing_status NOT NULL,
amount_minor integer NOT NULL CHECK(amount_minor >= 0),
fee_minor integer CHECK(fee_minor >= 0), currency char(3) NOT NULL,
occurred_at timestamptz NOT NULL, captured_at timestamptz,
CHECK((status='captured') = (captured_at IS NOT NULL))
);
CREATE TABLE commerce.access_entitlement (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id uuid NOT NULL REFERENCES commerce.customer(id),
tier commerce.access_tier NOT NULL,
plan_version_id text NOT NULL REFERENCES commerce.plan_version(id),
subscription_id uuid REFERENCES commerce.subscription(id),
status commerce.entitlement_status NOT NULL,
starts_at timestamptz NOT NULL, ends_at timestamptz,
CHECK((tier='base') = (subscription_id IS NOT NULL))
);State and evidence rules
Base access requires an active entitlement tied to an active subscription; webhook handling is idempotent.
checkout created, authorization, pending or past_due is not captured-payment evidence.
Billing failure changes subscription/entitlement state. It does not create a fourth file outcome or a human queue.
A later plan or usage policy is a new version. Historical subscriptions and events remain immutable.
The customer accepts tier scope, current usage-policy status and known unavailable sections. No price schedule for composed/partial outcomes exists.
CREATE TABLE commerce.disclosure (
id text PRIMARY KEY, disclosure_key text NOT NULL, version integer NOT NULL,
plan_version_id text NOT NULL REFERENCES commerce.plan_version(id),
jurisdiction_id text NOT NULL REFERENCES public.jurisdiction(id),
channel public.output_channel NOT NULL,
body_uri text NOT NULL, body_hash text NOT NULL,
authored_by text NOT NULL, reviewed_by text NOT NULL,
review_mode public.review_mode NOT NULL DEFAULT 'independent',
reviewed_at timestamptz NOT NULL, attestation_uri text,
effective_from timestamptz NOT NULL, effective_to timestamptz,
UNIQUE(disclosure_key,version),
CHECK(
(review_mode='independent' AND reviewed_by<>authored_by
AND attestation_uri IS NULL)
OR
(review_mode='solo_founder_attestation' AND reviewed_by=authored_by
AND attestation_uri IS NOT NULL AND length(trim(attestation_uri))>0)
)
);
CREATE TABLE commerce.disclosure_acceptance (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id uuid NOT NULL REFERENCES commerce.customer(id),
disclosure_id text NOT NULL REFERENCES commerce.disclosure(id),
plan_version_id text NOT NULL REFERENCES commerce.plan_version(id),
disclosure_hash text NOT NULL, plan_scope_hash text NOT NULL,
usage_policy_hash text NOT NULL,
shown_at timestamptz NOT NULL, accepted_at timestamptz NOT NULL,
CHECK(accepted_at >= shown_at)
);
-- Review evidence and accepted hashes are immutable. New text = new version.
REVIEW-MODE CI MATRIX
Independent/different identity passes; independent/same identity fails; solo/same identity + URI passes; solo without URI fails; solo/different
identity fails. Rule and disclosure rows share this contract. CREATE TABLE commerce.property_file_request (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id uuid NOT NULL REFERENCES commerce.customer(id),
entitlement_id uuid NOT NULL REFERENCES commerce.access_entitlement(id),
disclosure_acceptance_id uuid NOT NULL REFERENCES commerce.disclosure_acceptance(id),
parcel_id uuid NOT NULL REFERENCES public.parcel(id),
property_file_id uuid REFERENCES public.property_file(id),
created_at timestamptz NOT NULL DEFAULT now()
);
-- No accepted price, file price, credit, allowance, overage or payment FK.Access validation occurs before composition. Subscription billing occurs independently. The Property File still terminates composed, partial or refused.
Automated state flow
limited_free -> preview only (not an integrated Property File) validation_cohort entitlement -> disclosure accepted -> compose active Base entitlement -> disclosure accepted -> compose
compose -> composed -> deliver compose -> partial -> deliver with named gaps and dependent refusals compose -> refused -> deliver refusal
billing webhook -> subscription/entitlement state only file outcome -X-> billing amount, refund, credit or settlement
Endpoints
Method Path Contract
GET /v1/access/plans Returns active plan/version, scope and usage-policy status.
POST /v1/disclosures/{id}/accept Stores exact hashes and acceptance time.
POST /v1/subscriptions/checkout Base checkout; success is not capture evidence.
POST /v1/commerce/provider-events Idempotent captured/failed/refunded lifecycle event.
GET /v1/subscriptions/current Status and entitlement only; no file balance.
POST /v1/property-file-requests Checks entitlement + acceptance, then composes automatically.
GET /v1/metrics/track-b Captured conversion and product-use views.
Required CI
test_no_per_file_price_fields · test_base_requires_active_entitlement · test_free_cannot_request_integrated_file · test_outcome_does_not_set_billing · test_refusal_not_relaxed · test_webhook_idempotent · test_captured_only_paid_evidence · test_schema_separation
Track B tests conversion, repeat use, retention and feature value. It never computes average price per Property File.
CREATE TYPE commerce.product_event_type AS ENUM ('property_file_requested','parcel_evaluated','section_viewed', 'placement_viewed','cost_viewed','returned','capability_requested');
CREATE TABLE commerce.product_event (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id uuid NOT NULL REFERENCES commerce.customer(id),
event_type commerce.product_event_type NOT NULL,
parcel_id uuid REFERENCES public.parcel(id),
property_file_id uuid REFERENCES public.property_file(id),
section_key text, capability_key text,
occurred_at timestamptz NOT NULL,
properties jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE VIEW commerce.v_track_b_captured_subscription AS
SELECT s.plan_version_id, count(DISTINCT s.customer_id) captured_customers,
sum(b.amount_minor) captured_amount_minor,
sum(coalesce(b.fee_minor,0)) processing_fee_minorFROM commerce.subscription s JOIN commerce.billing_event b ON b.subscription_id=s.id WHERE b.status='captured' AND b.captured_at IS NOT NULL GROUP BY s.plan_version_id;
-- Companion views calculate:
-- free/cohort eligible -> first captured Base conversion;
-- distinct additional parcels after the first Property File;
-- disclosed return/retention windows; repeated section use;
-- placement/cost engagement; capability requests.
-- Never divide subscription revenue by files or infer per-file value.
EVIDENCE DISCIPLINE
Actual provider capture is paid evidence. Accepted checkout, authorization, deposit, commitment, stated willingness and unpaid intention
remain separately classed and do not enter the conversion numerator.ID Dependency Blocks
LD-1 City-limits licence confirmation All file channels at L0.
LD-2 Inspection and planning-permit rights Those facts and dependents.
LD-3 Other named launch-source rights Affected facts and conclusions.
LD-4 3DEP coverage, repeatability and accuracy Placement and geometry-dependent conclusions only.
Base Core is commercially meaningful only when its own sources, rights and minimum dependencies clear. LD-4 is not a Base Core launch dependency.
ID Dependency Blocks
LD-5 Measured earliest permit-series date Record-to-ground conclusion.
LD-6 Golden parcels including refusals Quality floor for affected launch.
LD-7 Customer terms, disclaimers, privacy and tier disclosure Signup, entitlement and Property File requests.
LD-8 E&O quote, exclusions and required controls Paid launch under management/legal decision.
LD-9 Per-resource public-records counsel guidance Affected sources and dependent conclusions.
Base dependency rules
Base Core: parcel identity/boundary, zoning, permit information, known constraints, provenance, confidence, rights and explicit unavailable-information treatment in one coherent file.
Conceptual placement: requires validated structure/parcel geometry and all declared siting inputs. LD-4 unresolved means refuse placement; never substitute an inferred footprint.
Cost scenario: may use valid geometry-derived quantities or clearly disclosed user-supplied assumptions. If neither supplies a required input, refuse the cost conclusion.
Subscription status never changes confidence, rights, omission or dependency-refusal behavior and never proves each file's individual value.
CURRENT CONTROL CLOSE
Revision 1.7 st 2026. Current controlling engineering contract under Business Plan 2.1.4 and operationally restated by Rules v1.4 -priced order, accepted-price and per-file payment provisions in v1.4 and earlier. It carries forward v1.5 r evidence controls. Checklist v1.2 and Audit v1.1 remain unchanged. No source, licence, endpoint, ordinance or gate is asserted cleared. This document is not legal advice.
SECTION 15 / ARCHITECTURE ADDENDUM A-1
Spec §1 now contains I1-I20 with enforcement. I1-I18 are recovered from the prior controlling contract with v1.6's corrected I10, I16 and I18; A-1 adds I19 and I20. §1.1 explains the internal-fact rights gate. The first generated v1.6 artifact's duplicate top-level §14 is recorded as a historical defect, not carried forward.
Six make targets
Target Execution surface Pass condition
make check-boundary Jurisdiction-name grep, import-linter, public-to-commerce catalogue query, filesystem I1, I15, I17 and I19 pass; zero forbidden imports, authority, no-graph and Track B no-render checks. FKs or derived authority.
make schema Apply every forward-only migration to an empty database. Clean apply; constraints, functions and triggers compile.
make schema-dump Regenerate db/schema.sql from the applied database and compare the committed dump. No diff; missing or stale generated DDL fails.
make conformance Parameterized pack suite for sources, mappings, rights, dependency cascades and endpoint Every enabled pack passes; no rights broadening liveness. or silent missing dependency.
make test Unit and integration suites, including review, entitlement, outcome observation, provider slot, All required tests pass with zero skips and no edge guard and billing independence. external network dependency in CI.
make golden Normalized composed, partial, refused and geometry-disabled Base Core fixtures. Output matches approved fixtures; intended changes require reviewed fixture updates.
STRUCTURAL DRIFT PREVENTION
The Spec builder imports the invariant and make-target objects from the Rules builder. A future revision changes the source object once and regenerates both artifacts; copying is prohibited.
ARCHITECTURE ADDENDUM A-1
Base requests already preserve parcel and evaluation time. A scheduled commerce job later reads CC0 permit facts and records immutable Track B observations under a versioned lag-window method. The loop starts collecting intent in Phase 1 and may produce no match for an extended period.
CREATE TYPE commerce.outcome_observation_status AS ENUM('matched','no_match_as_of_run');
CREATE TABLE commerce.outcome_match_run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
method_version text NOT NULL,
method_config_uri text NOT NULL, method_config_hash text NOT NULL,
permit_source_id text NOT NULL REFERENCES public.source(id),
permit_snapshot_id text NOT NULL REFERENCES public.snapshot(id),
started_at timestamptz NOT NULL, completed_at timestamptz,
CHECK(completed_at IS NULL OR completed_at >= started_at)
);
CREATE TABLE commerce.evaluation_outcome_observation (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid NOT NULL REFERENCES commerce.outcome_match_run(id),
property_file_request_id uuid NOT NULL
REFERENCES commerce.property_file_request(id),
parcel_id uuid NOT NULL REFERENCES public.parcel(id),
evaluated_at timestamptz NOT NULL, observed_at timestamptz NOT NULL,
window_start timestamptz NOT NULL, window_end timestamptz NOT NULL,
method_version text NOT NULL,
status commerce.outcome_observation_status NOT NULL,
matched_permit_fact_id uuid REFERENCES public.fact(id),
match_basis jsonb NOT NULL,
UNIQUE(run_id,property_file_request_id),
CHECK(window_end > window_start),
CHECK((status='matched') = (matched_permit_fact_id IS NOT NULL))
);
CREATE FUNCTION commerce.validate_outcome_observation()RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE run_method text; run_licence text; fact_ok boolean; BEGIN
SELECT r.method_version, s.licence_id INTO run_method, run_licence
FROM commerce.outcome_match_run r
JOIN public.source s ON s.id=r.permit_source_id
JOIN public.snapshot sn ON sn.id=r.permit_snapshot_id
AND sn.source_id=r.permit_source_id
WHERE r.id=NEW.run_id;
IF run_method IS NULL OR run_method<>NEW.method_version OR run_licence<>'cc0'
THEN RAISE EXCEPTION 'invalid outcome run'; END IF;
IF NEW.matched_permit_fact_id IS NOT NULL THEN
SELECT EXISTS(SELECT 1 FROM public.fact f WHERE f.id=NEW.matched_permit_fact_id
AND f.parcel_id=NEW.parcel_id AND f.licence_id='cc0') INTO fact_ok;
IF NOT fact_ok THEN RAISE EXCEPTION 'match must be CC0 for same parcel'; END IF;
END IF;
RETURN NEW;END $$;
CREATE CONSTRAINT TRIGGER outcome_observation_validateAFTER INSERT ON commerce.evaluation_outcome_observation
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW
EXECUTE FUNCTION commerce.validate_outcome_observation();
CREATE FUNCTION commerce.reject_outcome_observation_mutation()RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'outcome observations are immutable'; END $$;
CREATE TRIGGER outcome_observation_immutable BEFORE UPDATE OR DELETEON commerce.evaluation_outcome_observation FOR EACH ROW EXECUTE FUNCTION commerce.reject_outcome_observation_mutation();
Job and boundary
commerce/jobs/match_evaluation_outcomes runs on a configured schedule; no cron interval is fixed here.
The job may read public; public never references commerce. core/compose and renderers cannot import observation modules.
A new matcher creates a new run and observations. No historical observation is rewritten.
The request row cannot be reconstructed if it was never captured; intent recording begins in Phase 1.
ARCHITECTURE ADDENDUM A-1
A provider can be registered without activating geometry. The jurisdiction's one provider slot can reference it only after an approved immutable measured-error record exists for the same provider version, method and jurisdiction.
CREATE TYPE public.provider_validation_decision AS ENUM('approved','rejected');
CREATE TABLE public.footprint_provider_version (
id text PRIMARY KEY,
jurisdiction_id text NOT NULL REFERENCES public.jurisdiction(id),
provider_key text NOT NULL, version integer NOT NULL CHECK(version>0),
source_id text NOT NULL REFERENCES public.source(id),
method_version text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(jurisdiction_id,provider_key,version)
);
CREATE TABLE public.footprint_provider_validation (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider_version_id text NOT NULL UNIQUE
REFERENCES public.footprint_provider_version(id),
jurisdiction_id text NOT NULL REFERENCES public.jurisdiction(id),
measurement_method_version text NOT NULL,
provider_method_version text NOT NULL,
benchmark_snapshot_id text NOT NULL REFERENCES public.snapshot(id),
measured_error_uri text NOT NULL, measured_error_hash text NOT NULL,
measured_at timestamptz NOT NULL,
decision public.provider_validation_decision NOT NULL
);
ALTER TABLE public.jurisdiction
ADD COLUMN active_footprint_provider_version_id text
REFERENCES public.footprint_provider_version(id);
CREATE FUNCTION public.validate_footprint_provider_slot()RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.geometry_tier_enabled AND NEW.active_footprint_provider_version_id IS NULL THEN RAISE EXCEPTION 'geometry requires active provider'; END IF; IF NEW.active_footprint_provider_version_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.footprint_provider_version p
JOIN public.footprint_provider_validation v ON v.provider_version_id=p.id
WHERE p.id=NEW.active_footprint_provider_version_id
AND p.jurisdiction_id=NEW.id AND v.jurisdiction_id=NEW.id
AND v.provider_method_version=p.method_version AND v.decision='approved'
) THEN RAISE EXCEPTION 'provider lacks approved measured-error record'; END IF;
RETURN NEW;END $$;
CREATE CONSTRAINT TRIGGER footprint_provider_slot_validateAFTER INSERT OR UPDATE ON public.jurisdiction
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW
EXECUTE FUNCTION public.validate_footprint_provider_slot();
-- Provider and validation rows are immutable; correction creates a new version.Runtime rule
The composer reads only active_footprint_provider_version_id. If that provider fails, placement refuses. It never scans, ranks or substitutes another provider. A second validated provider can occupy the slot only through a controlled configuration change.
No measured-error acceptance number is introduced. I20 enforces evidence before activation.
ARCHITECTURE ADDENDUM A-1
An edge request guard protects infrastructure independently from product entitlement. It runs before resolve, entitlement and core/compose. Rejection returns HTTP 429, creates no Property File request or file and is not composed, partial or refused.
Interface contract
EdgeGuard.check(request, route_policy) -> Allowed | RateLimited
PRECONDITIONS FOR A PROTECTED PRODUCTION ROUTE
EDGE_GUARD_POLICY_URI is present EDGE_GUARD_POLICY_HASH matches the retrieved policy policy version compiles for the configured edge adapter
RateLimited: return HTTP 429 at the edge do not call /v1/resolve, entitlement or core/compose do not insert commerce.property_file_request do not insert public.property_file emit infrastructure telemetry only
No numeric rate, window, entitlement allowance or credit is specified here.
Required before application-code merge
1 make schema A-1 migrations apply cleanly to an empty database.
2 test_schema_separation New references remain commerce -> public; public has no commerce dependency.
3 Geometry degraded-mode fixture test_base_core_without_ld4 and test_no_geometry_fallback pass with provider present but inactive.
EVIDENCE STATUS
These checks are required and not asserted complete by this PDF. Record CI output before merge. Reading the contract is not verification.
Generated 2026-08-03 by build/build_spec_v1_7.py. Source of record: build/ledgex_source.py.