Story 2271: the land before the border was drawn — section foundation

WHY. The pipeline knows where ink sits (pages, blocks, bboxes) but not what it is. The evidence document's cause title is five layout blocks; "prayer" exists nowhere in the system. RFD-DOC-004 adds typed semantic sections — named parts of a document, drawn by Blake, named from a firm catalog. This Story is the first link of EPIC-2270's serial chain: ONLY the ground the rest stands on. Two tables, their firm-boundary topology, RLS, tests. No agent, no tools, no consumers — B2a/B2b/B3 own those.

The two tables

TableWhat it is
business.section_kinds The firm's section vocabulary ("Prayer", "Grounds"). Emergent (human ruling D26): the machine coins it from the first document onward; the ONLY seed is the structural kind annexure, because Collation (D25) needs a kind to degrade an unverifiable bundle cut into. Lifecycle clones business.document_types verbatim (ADR-128 §11.5): agent-coined kinds land availability='provisional' and are matter-scoped; firm-canonical kinds are live and firm-wide — both directions enforced by CHECK. Normalized-name dedupe = two partial unique indexes on lower(btrim(name)) (live per firm; provisional per firm + matter). Coinage-cap substrate: coined_from_document_id + created_at; the cap VALUES are config (section_kind_coinage_cap_per_segmentation_pass / ..._per_firm_per_day in get_settings()), enforced by B2b.
documents.document_sections One row per semantic part: kind + heading_path text[] + lexicographic block ranges as ordered (page_start, block_start, page_end, block_end) pairs (D3 — blocks have no ids; this mirrors context.chunks and SourceSpan). Versioned by extraction_run_id NOT NULL (schema-review S1) so a stale section from a superseded extraction is identifiable. summary (D15), source agent|human (SS-10), prompt_version/model_id pinning (D14, CHECK-required for agent rows), is_fallback (D13), parent_section_id tree (D3), order_index unique per (firm, document, run) for idempotent re-segmentation.

The border is the point

RECALL (schema-review B2). Postgres checks foreign keys with RLS bypassed — the referential-integrity check runs as the table owner. A single-column kind_id FK would happily accept another firm's kind and RLS would only hide the damage later. The proof is the composite FK: every FK out of document_sections is a (firm_id, …) pair against a (firm_id, id) unique target, so a cross-firm reference is rejected by the database itself.
documents.document_sections
  (firm_id, kind_id)            ──▶ business.section_kinds  (firm_id, id)   RESTRICT   ← the headline
  (firm_id, document_id)        ──▶ documents.documents     (firm_id, id)   CASCADE
  (firm_id, matter_id)          ──▶ business.matters        (firm_id, id)   RESTRICT
  (firm_id, parent_section_id)  ──▶ documents.document_sections (firm_id, id) CASCADE

business.section_kinds
  (firm_id, matter_id)                  ──▶ business.matters    (firm_id, id)  RESTRICT
  (firm_id, coined_from_document_id)    ──▶ documents.documents (firm_id, id)  RESTRICT

RLS rides on top as the read/write policy: section_kinds clones business.document_types (firm SELECT/INSERT/UPDATE + staff_metadata_policy); document_sections clones documents.document_annotations (firm FOR ALL + firm-scoped staff_with_grant_policy, migration-0167 shape). ENABLE + FORCE on both; app role never holds DELETE.

Proof, not hope

CheckWhereRed state it was observed against
Cross-firm kind reference rejected by Postgres tests/integration/test_document_sections_rls.py Planted negative: firm A inserts a section pointing at firm B's kind — asserts fk_document_sections_firm_kind in the IntegrityError. Same for the document leg.
Cross-firm read emptiness (SS-08) same file Firm B counts firm A's rows in both tables: zero.
Provisional kind must be matter-scoped; agent section must pin prompt/model same file Both inserts planted and rejected by their CHECKs.
Seed is 'annexure' alone, idempotent same file + tests/unit/test_section_foundation_migration.py Bare firm seeded twice → exactly one row named annexure; static guard bans any other seeded name in the migration source.
Support-scope classification tests/integration/test_rls_isolation.py The completeness assertion diffs live firm-scoped tables against the registered lists — an unregistered new table fails the suite.

D12 evidence gate

This migration is pure ADDs, so no expand/contract question exists (schema-review ruling condition 1). The destructive context.chunks reshape belongs to B3 — but the corpus proof was taken now, per the gate: staging (the only environment) holds 19 context.chunks rows (oldest 2026-07-10), 90 documents / 927 pages, all inside one firm, all test uploads. Human already ruled the corpus disposable; the honest greenfield plan at B3 is delete the staging documents and re-upload (truncating chunks alone re-drives nothing).

What changed where

FileChange
src/domains/section_kind/entity.py, service.py Catalog entity + the one-row seed service (mirrors DocumentTypeService seed).
src/domains/document_section/entity.py Sections entity, composite-FK topology.
alembic/versions/c7d9e1f3a5b2_0168_section_foundation.py Both tables + RLS + grants + triggers + annexure back-seed for existing firms. Round-trip and alembic check clean.
src/domains/firm/service.py create_firm seeds annexure alongside the other catalogs.
src/shared/settings.py The two coinage-cap config values (D19).
src/shared/db/entities.py, alembic/env.py Entity registration.
tests/… Boundary proof, static migration guards, RLS-registry entries, factories cleanup order (sections → kinds → documents → matters).
NAMING. Schema review B3 preferred section_types; the human-blessed glossary and the issue overruled it: section kind is the product word ("section type" collides with document type in counsel-facing surfaces), and layout segment_kind on document_pages is declared a different word family (page-layout segments ≠ semantic sections). The structural corrections of the review — composite FKs, availability lifecycle, extraction_run_id, envelope columns, business schema — are all adopted.

Next link: B2a (Blake segmentation mode, pramaan-agents) writes into this schema; B2b wires the fan-out sub-step and the coinage tools; B3 re-anchors chunks/graph/routes and executes the D12 delete + re-upload.