LedgeX / ADU.X — Engineering Reference Spec v1.7

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.py from build/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)

0. How to use this file

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 index

§ 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.

Authority order

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.

0.2 One invariant source

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.

1. Invariants — I1 to I20

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

1.1 Why the licence gate covers internal facts

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.

1.2 Six make targets

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.

15. Architecture Addendum A-1

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.


Appendix — full technical body

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.

2. Repository layout

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).

3. Database schema

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.

3.1 Extensions and enums

-- 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.

3.2 Registries — jurisdiction, licence, source

-- 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

3.3 Canonical field vocabulary

-- 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);

3.4 Parcel identity

-- 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);

3.5 Snapshots — the reconstruction backbone (C2)

-- 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.

3.6 The fact ledger

-- 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 '~').

3.7 Fact triggers — I4 and I5

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 fact

FOR 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.

3.8 Current-fact resolution

-- 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.

3.9 Rules — review-mode contract

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.

3.10 Exceptions

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)
  );

3.11 Support requests — post-delivery, never pre-delivery

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).

3.12 Property files and jobs

-- 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);

3.13 Migration conventions

4. API endpoints

4.1 Conventions

4.2 Endpoint table

4.2 Endpoint table — subscription additions

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.

4.3 Core shapes

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.

4.4 Problem responses

{ "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": "…" }

5. Runtime workflow

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.

5.1 Three outcomes

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.

5.2 What replaces the human step

When a field cannot be machine-retrieved, the pipeline takes exactly one of three actions, in this order:

  1. Omit — record the field in unmet_fields with the reason. The file continues.
  2. Downgrade — where a weaker machine source exists, use it and lower confidence, naming the substitution in the file.
  3. Refuse — where the field is required for the channel, or where its absence would make a presented conclusion misleading, refuse the dependent conclusion (not necessarily the whole file).

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.

5.3 Source tier posture and Base-product dependency

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

  1. Add to field_definition (migration) and to §8 of this file.
  2. Map it in at least one jurisdiction’s field_map.yaml, or mark it phase1_deferred with a deferral_reason (§7.4).
  3. Decide stale_after_days and required_for_file deliberately.
  4. Add to the composer’s section layout with its claim treatment.
  5. Golden file expectations updated — including its appearance in unmet_fields if deferred.

C. Add a rule

  1. Locate the code text. Store a copy at jurisdictions//evidence/.
  2. Write the YAML with citation, effective_from, source_text_uri.
  3. A human other than the author sets reviewed_by/reviewed_at. Not optional, and now constraint-enforced (§3.9).
  4. Migration to register in rule.
  5. Golden parcels covering the boundary conditions.

D. Add a detector

  1. New module in core/exceptions/detectors/, versioned key.
  2. Must emit parcel_exception rows with evidence fact ids.
  3. Must be independently switchable (the six detectors ship separately).
  4. Tunables come from the pack, never from the detector.

6.1 Task shapes — corrected commerce and review steps

C. Add a rule

F. Add or amend a subscription 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.

6.2 Prohibited — replacements

□ make schema-dump produces no diff □ Migration is forward-only and numbered □ This spec is updated if a contract changed, with a §12 row

6.4 CI gates

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.

6.5 Reusable prompt preamble

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.

6.6 Golden-file normalisation

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 compose_ms, source_calls, compute_cost_micros, storage_cost_micros Stripped snapshot_id, payload_hash Retained — a changed snapshot should fail the test composer_version, pack_version, ruleset_version Retained — a version bump should fail the test and force a re-blessing as_of Pinned by the fixture, not by now()

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.

6.7 Annex — the human-review queue, considered and cut

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.

7. San José source list

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.

7.1 jurisdictions/ca_san_jose/sources.yaml

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:

---- City of San José ----

phase_status_reason: "Portal terms unconfirmed. Default deny. Plan App K: blocked." cadence_stated: "daily" supplies: [permits.under_inspection] url_verified_at: null

active: false supplies: [] # v1.2: supplies nothing. cost.city_fees

comes from the rule pack via L5, not L1.

url_verified_at: null

---- Santa Clara County ----

---- EXCLUDED: recorded so the exclusion is a decision, not an oversight ----

---- Federal / state, reusable across all future CA jurisdictions ----

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

---- DEFERRED for Phase 1 — real, known, out of scope. See §7.4. ----

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

7.2 jurisdictions/ca_san_jose/licences.yaml

This file is the sole runtime authority for channel eligibility. See §7.3.

licences:

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."

7.3 Runtime eligibility — single source of truth

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

7.4 Deferred sources — declared and refusal-safe

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.

{ "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_file

Requiredness rules

Engineering Reference Spec v1.7

9. Refusal and error codes

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.

9.1 Removed in v1.2 — CONFIDENCE_BELOW_THRESHOLD

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.

10. Track A / Track B measurement

Both tracks are queries against production tables, never a maintained spreadsheet. A dashboard maintained beside the data decays exactly when the evidence matters most.

10.1 Measurement views

-- 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.

10.3 Commercial evidence separation

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.

10.3 Management priors

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.

11. Environment and configuration

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.

11.1 Build-time context tooling — Graphify

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.

  1. No CI gate reads the graph. Every gate in §6.4 resolves against the filesystem. make check-boundary in particular is a literal grep -ri "san_jose" core/. A stale graph answering “no matches” would silently retire I1 — and Blueprint §16 names a core commit containing a city name as “the single most valuable early signal in the build.” Losing that signal to a caching artifact would be an expensive way to save tokens.

12. Change record

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.

13. Subscription commerce schema

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.

13.1 Core enums and customer

  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.

13.2 Plan version

  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.

13.3 Subscription, billing and entitlement

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

13.4 Disclosure and acceptance

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.

13.5 Property File request

  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.

13.6 Deterministic access and fulfilment

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

13.7 Track B measurement contract

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_minor

FROM 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.

14. Launch dependencies and Base Core

14.1 Launch-dependency register

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.

14.2 Base Core and launch-control close

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

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

A-1.1 Control recovery

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

A-1.2 Evaluation-to-permit outcome loop

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_validate

AFTER 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 DELETE

ON commerce.evaluation_outcome_observation FOR EACH ROW EXECUTE FUNCTION commerce.reject_outcome_observation_mutation();

Job and boundary

ARCHITECTURE ADDENDUM A-1

A-1.3 Validated footprint-provider slot

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_validate

AFTER 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

A-1.4 Edge infrastructure protection

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

Check Required result

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.