Backporting Makers Map onto the packages

Makers Map is the origin. It still runs on kiln/, its own copy of the code this platform was extracted from, and until it moves the extraction is a claim rather than a fact.

Makers Map migrates first, and its test suite is the regression harness that proves the extraction changed nothing. Circuit is built on the packages from day one and therefore proves nothing about them: a green field cannot tell you whether a behaviour was lost, because it never had it.

The rule: no feature changes during the extraction. Every behaviour change lands before or after the move, never inside it. A move that also fixes something produces a diff where nobody can tell which half broke the tests.

The order is the build plan's (§7.4), and the phases are separated by how likely they are to bite:

PhaseDaysWhat moves
A — mechanical2–3core, geo, sources, metrics, joblog, migrate. Kiln imports them; kiln's tests stay green; Makers Map deploys from the packages
B — careful3–4scoring engine and taxonomy split; web app → factory plus base templates with makersmap as theme #1. This is where regressions live
C — the rest2outreach, content, deploy kits; refinery timers and manifest on the existing VM

1. Where each module goes

kiln/ becomes makersmap/, a thin site package. Everything else is an import.

kilnplacegraph homeNotes
db.pyconnect, session, init_db, record_fact, facts_for, find_entity, upsert_entity, attach_address, recompute_entity_lad, rebuild_fts, suppression, runsplacegraph.core.dbAdds facts_by_key, set_score/get_score/scores_for, record_signals/signals_for. LAD_ROLE_PRIORITY gains trading, office, rated_premises, service_address
db.pynormalise_name, normalise_postcode, postcode_district, postcode_area, normalise_domain, normalise_phone, clean_display_name, slugify, hash_ipplacegraph.core.normalisePure functions, own module. in_target_scopein_scope
db.pyhomepage_urlnowhere yetSee §5. This is a genuine gap and it has kiln tests
scoring.pylooks_personal_name, find_company_number, _COMPANY_TOKENplacegraph.core.normaliseNow looks_personal_name, has_company_token, has_legal_suffix, find_company_number
migrate.pyplacegraph.core.migrateAdds multi-directory discovery, the 1000 floor and baseline()
joblog.pyplacegraph.core.joblogUnchanged in substance
config.pymakersmap/config.py as a SiteConfig§4
geo.pyplacegraph.geoonspd.py, attribution.py, boundaries.pyload_onspd_csvload_onspd, now also reading the published .zip
sources/{ico,ch_bulk,companies_house,places,serper,discover,crawl,directories,seed,nndr}.pyplacegraph.sources.*Same module names. Imports are lazy via PEP 562
accounts/, audit/placegraph.sources.accounts, placegraph.sources.audit
taxonomy.pykeyword_pattern, _lookup, sic_to_craft, is_broad_sic, is_excluded_sic, is_creative_sicplacegraph.scoring.taxonomyThe framework. is_creative_sicTaxonomy.is_in_scope_sic
taxonomy.pyCRAFTS, BROAD_SIC, EXCLUDED_SICmakersmap/taxonomy.pyThe judgement. §3
scoring.pyclassify_craft, the squash, score_allplacegraph.scoring.engineclassify_craftTaxonomy.classify
scoring.pySIGNAL_WEIGHTS, score_entity's signal collectionmakersmap/scores.pyThe judgement. §3
scoring.pypublish()placegraph.scoring.publishBehaviour differences in §5
metrics.pycoverage, by_lad, by_craft, by_source, claims, venues, demand, precision, poc_dashboardplacegraph.metrics.reportby_craftby_category; poc_dashboarddashboard(conn, gateset)
metrics.py — the four hardcoded gatesmakersmap/gates.py on placegraph.metrics.gates
analysis.pyplacegraph.metrics.analysisMIN_GROUP disclosure control unchanged
a11y.pyplacegraph.web.a11y
web/app.pyplacegraph.web.app + routes/{directory,profile,claim,gdpr,content,admin,seo}.py + deps.py, links.py, counts.py, places.py, records.py, library.pyThe 1350-line module becomes a factory and seven registrars
web/auth.py, web/guard.pyplacegraph.web.auth, placegraph.web.guard
web/templates/*placegraph.web.templates (base) + makersmap/theme/ (overrides)Ship only what differs
web/static/style.cssplacegraph.web.static + makersmap/static/Custom-property overrides, not a fork
web/static/img/craft, img/areasite static/, pooled by placegraph.content.images
guides.py, blog.py, charts.py, images.pyplacegraph.content.*The engines. The guides themselves are makersmap/content/
posters.py, venues.py, domains.pyplacegraph.outreach.*The fifty Southampton venues become extra["venue_worklist"]
worker/, worker-outreach/placegraph.outreach.workers templatesRendered per site with their own D1
deploy/placegraph.deploy templates{{SLUG}} throughout; kiln.servicemakersmap.service
docs/{lia,dpia,article-14,...}.mdmakersmap/docs/, generated from placegraph.compliance.documentsInstances stay the site's
cli.py (1203 lines)makersmap/cli.pyStays a site file, and shrinks: most verbs become one call
tests/makersmap/tests/The harness. Does not move, does not change.

Nothing in the left column disappears. If something has no home in the right column, that is a finding, not a rounding error — see §5.


2. Phase A — the mechanical moves

A1. Install the packages beside kiln

python -m pip install -e placegraph/packages/placegraph-core
python -m pip install -e placegraph/packages/placegraph-geo
python -m pip install -e placegraph/packages/placegraph-sources
python -m pip install -e placegraph/packages/placegraph-metrics

Checkpoint: python -c "import placegraph.core, placegraph.geo" and python -m pytest in kiln/ — both still green, because nothing imports the new packages yet. This checkpoint exists to catch a src/placegraph/__init__.py having crept into a package, which breaks every other package's imports and produces an error message that names the wrong thing.

A2. makersmap/config.py, with KILN_* still working

Kiln reads os.environ["KILN_..."] at import time in module-level globals. That is fine for one site and impossible for two: the values are baked in at first import, there is no way to hold two of them, and the prefix is in the source.

The site's config becomes a SiteConfig, and the server keeps its existing env file through an aliasing shim:

"""Makers Map — site configuration.

The env prefix is MAKERSMAP, but the droplet's /srv/kiln/shared/kiln.env was
written by dozens of deploys and half of it is secrets that only exist there.
So every KILN_* name is aliased onto its MAKERSMAP_* equivalent before the
config is built, and the cutover is a rename we do when it suits us rather than
one the deploy forces.

The alias never overwrites: a MAKERSMAP_* value that is already set wins, so
the new name is always the one with authority and the old one is only a
fallback. That ordering is what lets the two coexist without anybody having to
know which is in play.
"""
from __future__ import annotations

import os
from pathlib import Path

from placegraph.core.settings import AffiliateLink, SiteConfig

from . import gates, scores, taxonomy

HERE = Path(__file__).resolve().parent

# Removed once /srv/makersmap/shared/makersmap.env is the only env file on the
# box. Until then this is the whole of the compatibility story.
LEGACY_PREFIX = "KILN"
NEW_PREFIX = "MAKERSMAP"


def _alias_legacy_env() -> list[str]:
    """Copy KILN_X to MAKERSMAP_X where the latter is unset. Returns what moved.

    Must run before SiteConfig.from_env, which is why it is a module-level call
    below rather than something the caller remembers to do.
    """
    moved = []
    for name, value in list(os.environ.items()):
        if not name.startswith(f"{LEGACY_PREFIX}_"):
            continue
        new = f"{NEW_PREFIX}_{name[len(LEGACY_PREFIX) + 1:]}"
        if not os.environ.get(new):
            os.environ[new] = value
            moved.append(f"{name} -> {new}")
    return moved


ALIASED = _alias_legacy_env()

CONFIG = SiteConfig.from_env(
    slug="makersmap",
    env_prefix=NEW_PREFIX,

    site_name="Makers Map",
    tagline="The directory of Southampton's creative businesses",
    base_url="https://makersmap.co.uk",
    contact_email="hello@makersmap.co.uk",
    founder_name="Rosie",
    subject_plural="makers and creative businesses",

    # Launch council, then the Solent, then the rest of Hampshire — which is
    # named but never listed, so a postcode resolving to Basingstoke is
    # labelled correctly rather than published as coverage we do not have.
    target_lads={"E06000045": "Southampton"},
    neighbour_lads={
        "E06000044": "Portsmouth", "E06000046": "Isle of Wight",
        "E07000086": "Eastleigh", "E07000087": "Fareham",
        "E07000091": "New Forest", "E07000094": "Winchester",
        "E07000090": "Havant", "E07000088": "Gosport",
        "E07000093": "Test Valley",
    },
    wider_lads={
        "E07000084": "Basingstoke and Deane", "E07000085": "East Hampshire",
        "E07000089": "Hart", "E07000092": "Rushmoor",
    },
    region="South East",
    postcode_areas=["SO"],
    towns=("Southampton", "Eastleigh", "Winchester", "Romsey", "Totton"),

    taxonomy=taxonomy.TAXONOMY,
    score_profile=scores.SCORE_PROFILE,
    headline_score=scores.SOLE_TRADER,
    # KILN_PUBLIC_MIN_ST defaulted to 0.0 and KILN_PUBLIC_MIN_CRAFT to 0.45.
    publish_thresholds={scores.SOLE_TRADER: 0.0},
    require_category_to_publish=True,
    min_category_confidence=0.45,

    # 0.5s, not the platform default of 2s. These are small sites, almost all
    # behind a CDN that serves our two requests from an edge cache without
    # troubling the origin, and a site wanting more room says so in robots.txt.
    crawl_delay_seconds=0.5,

    theme_dir=HERE / "theme",
    static_dir=HERE / "static",
    migrations_dirs=(HERE / "migrations",),

    affiliates=(
        AffiliateLink("insurance",
                      "Public liability & product insurance for makers",
                      os.environ.get("KILN_AFF_INSURANCE",
                                     "https://www.a-n.co.uk/artists-insurance/"),
                      "Cover for market stalls, workshops and commissions."),
        AffiliateLink("banking", "Business banking for sole traders",
                      os.environ.get(
                          "KILN_AFF_BANKING",
                          "https://www.starlingbank.com/business-account/"),
                      "Free business current account, no monthly fee."),
    ),

    extra={"gates": gates.GATES,
           "venue_worklist": (),          # the fifty Southampton venues
           "sending_domains": ("mail.makersmap.co.uk",)},
)

Two wrinkles worth knowing rather than discovering:

  • missing_controller_fields() reports names under the new prefix, so a half-migrated box says MAKERSMAP_CONTROLLER_NAME is unset while the file on disk says KILN_CONTROLLER_NAME. Correct, and confusing for exactly one person for exactly one minute. Print ALIASED from makersmap init so the mapping is in the deploy log.
  • Shared unprefixed credentials — COMPANIES_HOUSE_KEY, GOOGLE_PLACES_KEY, SERPER_KEY, CLOUDFLARE_* — need no aliasing at all. from_env reads a prefixed name first and falls back to the bare one, which is how a laptop holds one Companies House key rather than one per directory.

A3. Point kiln's modules at the platform

Module by module, each its own commit, each ending green:

# kiln/db.py, during the move
from placegraph.core.db import *                      # noqa: F401,F403
from placegraph.core.normalise import *               # noqa: F401,F403

A shim module rather than a find-and-replace, because it keeps every existing import site working and makes the deletion of kiln/db.py a separate, boring commit. homepage_url stays defined locally in that file until §5 is resolved.

Checkpoint after each module: python -m pytest. test_invariants.py alone covers provenance, append-only facts, idempotent re-ingestion, suppression surviving re-ingestion on every key, deterministic merge keys, display-name cleaning, LAD attribution across a boundary, and scores moving in both directions. If those 28 tests pass, the mechanical move did not break the graph.

A4. Adopt the platform migration stream

This is the step that surprises people, so here it is measured rather than described. The live Makers Map database carries kiln's own migration history:

version  name                  checksum          baselined
      1  initial_schema        cbdf2e00ec6606c9          0
      2  search_discovery      dacc7e508d6f6ad6          0
      3  audit_outbound_links  e1c04b44a2273909          0
      4  company_accounts      94666da0fd3bd1d4          0
      5  removal_safety        dc79b9f18fd3b30c          0
      6  rated_premises        349dcf53b0d46f96          0
      7  images                a6e31e2bb058c7cb          0

The platform stream also occupies 1–4, with different files and different checksums. Run migrate.up against that database and you get, exactly:

migration 0001_graph has changed since it was applied.
  Applied migrations are history -- add a new migration instead of editing this one.

which is the checksum guard working correctly on a database it has never seen. baseline() does not help on its own — it skips versions already recorded — so the adoption is a one-off that rewrites those four rows and clears the three orphans:

"""makersmap adopt-schema — run once, on the box, before the first migrate.

Records the platform's 0001-0004 as *baselined* rather than applied: their
effects are already present, having been created by kiln's own versions 1-7, so
running them would fail on `table account already exists`. Baselining keeps the
history honest — `down` refuses to reverse something it never ran.

Versions 5-7 are kiln's alone and have no file in either stream. `up` ignores
recorded versions it cannot see, but `down` refuses to step past one, so they
are deleted: their effects survive in the baselined platform rows.
"""
from placegraph.core import db, migrate

with db.session() as conn:
    migrate.applied(conn)                       # ensures schema_migration exists
    platform = migrate.discover([migrate.PLATFORM_MIGRATIONS])
    for mig in platform:
        conn.execute(
            """INSERT INTO schema_migration
                 (version, name, checksum, applied_at, runtime_ms, baselined)
               VALUES (?, ?, ?, datetime('now'), 0, 1)
               ON CONFLICT(version) DO UPDATE SET
                 name=excluded.name, checksum=excluded.checksum, baselined=1""",
            (mig.version, mig.name, mig.checksum))
    conn.execute("DELETE FROM schema_migration WHERE version > ? AND version < ?",
                 (max(m.version for m in platform), migrate.SITE_VERSION_FLOOR))

Checkpoint: migrate.status(conn) shows 0001–0004 as baselined and 1000 as pending, and migrate.up then applies only 1000.

Because the platform stream is baselined rather than run, anything platform migrations create that kiln never had must be created by migration 1000. That is not a hypothetical; §3 lists the measured delta.


3. Phase B — the careful ones

B1. The schema rename

Run the schema diff first, and read it rather than assuming. Against the live database, comparing to a database built from the platform stream alone:

TableMissing from Makers MapMakers Map only
entitycategory_primary, category_confidence, headline_scorecraft_primary, craft_confidence, sole_trader_likelihood
entity_scorethe whole table
category_taxonomythe whole tablecraft_taxonomy
entity_signalscore
ladis_listed
addressward_name
crawl_pagepage_role
search_resultcategory_code, towncraft, locality
venueaddress, contact
validation_samplechecksexists_ok, craft_ok, lad_ok, sole_trader_ok
website_audittech_stack, has_dkim, careers_page_url, credibility_scoreoutbound_domains, outbound_count
nndr_releasefile_hashlicence, redaction_style, content_hash
rated_premisesratepayer_normalised, party_typeratepayer_norm, billing_postcode, mandatory_relief_pct, discretionary_relief_pct, created_at
imagetitle, source, file_url, attribution, fetched_atprovider, source_title, alt_text, author, attribution_required, created_at
company_accountsfiled_date, net_assets, creditors_short, is_dormant, overdue, raw_facts27 columns of lateness, ARD and balance-sheet detail

Three shapes in that table, and they are not the same job:

  • Renames and additions — the first four rows, plus is_listed, ward_name, page_role, search_result, venue. These are migration 1000 and 1001, below.
  • Rewritesvalidation_sample and image. Kiln's per-dimension columns become the platform's checks JSON, and kiln's image table has a different identity key. Migration 1002, and each needs a data conversion rather than a DDL change.
  • The platform being poorer than kilncompany_accounts, rated_premises, nndr_release. The extraction narrowed these. Fix them in the platform, before phase A finishes, as platform migrations 0005+ and matching source changes, because they are useful to Circuit too. Do not smuggle them into a site migration: a site migration that adds columns the platform's own writer does not know about is a column nothing will ever populate again.

Then the rename itself.

Read the header before the DDL. The textbook way to change a column in SQLite is the twelve-step table rebuild, and under this migration runner that procedure silently destroys the graph: migrate.up wraps each file in BEGIN/COMMIT, PRAGMA foreign_keys is a no-op inside a transaction, and DROP TABLE entity with enforcement still on cascades through the nine tables referencing entity(id). Run against a copy of production, the rebuild leaves 220 entities and zero facts, and reports success. ALTER TABLE ... RENAME COLUMN and DROP COLUMN do the same job without touching a row.

This file has been applied, reversed and re-applied twice through migrate.up/migrate.down against a copy of the live 220-entity database, with PRAGMA foreign_key_check and PRAGMA integrity_check clean and every row count stable across both directions.

makersmap/migrations/1000_category_and_scores.sql

-- description: Rename Makers Map's craft vocabulary to the platform's
-- vertical-neutral one, and move sole_trader_likelihood out of a column and
-- into entity_score.
--
-- Kiln named three columns and one table after its own vertical:
-- entity.craft_primary, entity.craft_confidence, entity.sole_trader_likelihood
-- and craft_taxonomy. The platform's graph is vertical-neutral, because a
-- directory of makers asks "is this an unincorporated sole trader?" and a
-- directory of tech firms asks "does this company actually operate here?" --
-- same machinery, different question, so the question is data.
--
-- Numbered 1000 because platform migrations occupy 0001-0999 and site
-- migrations start at 1000. This database was built by kiln's own migration
-- runner and already carries versions 1-7 with kiln's checksums, so the
-- platform stream must be BASELINED before this runs. See docs/backport-kiln.md.
--
-- ON NOT REBUILDING THE TABLE.
--
-- The textbook way to change a column in SQLite is the twelve-step dance:
-- foreign keys off, create the new table, copy, drop the old, rename, rebuild
-- the indexes, foreign_key_check, commit, foreign keys back on. Under this
-- migration runner that procedure is not merely unnecessary, it is dangerous,
-- for two reasons that only show up on a database with data in it:
--
--   * `migrate.up` wraps this file in BEGIN/COMMIT, and `PRAGMA foreign_keys`
--     is a silent no-op inside a transaction. The "foreign keys off" step
--     would appear to work and would not.
--   * With enforcement therefore still ON, `DROP TABLE entity` performs an
--     implicit DELETE of every row, which fires ON DELETE CASCADE on the nine
--     tables referencing entity(id) -- fact, entity_location, entity_signal,
--     claim, magic_link and the rest. The migration would report success and
--     leave an entity table with 220 rows and nothing attached to any of them.
--
-- So this uses ALTER TABLE throughout: RENAME COLUMN and DROP COLUMN, both of
-- which rewrite the schema without touching a row and without disturbing a
-- single foreign key. They need SQLite 3.35+ (Ubuntu 24.04 ships 3.45); on
-- anything older the first ALTER fails outright, which is the safe direction.
--
-- Reversible, and exercised: applied, reversed and re-applied against a copy
-- of the live database. A phase-B cutover that cannot be undone is a phase-B
-- cutover nobody runs on a Friday.

-- +up
-- --------------------------------------------------------- the taxonomy table
-- Renaming the table rewrites the REFERENCES clause in entity for us, so
-- entity.craft_primary keeps pointing at the right place throughout.
ALTER TABLE craft_taxonomy RENAME TO category_taxonomy;
ALTER TABLE category_taxonomy RENAME COLUMN dcms_sic_map TO sic_map;
ALTER TABLE category_taxonomy ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;

-- --------------------------------------------------------------- named scores
-- Platform migration 0001 creates this, but 0001 was baselined against a
-- database that predates it, so the table genuinely is not here.
CREATE TABLE entity_score (
    entity_id   INTEGER NOT NULL REFERENCES entity(id) ON DELETE CASCADE,
    score       TEXT NOT NULL,
    value       REAL NOT NULL,
    computed_at TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (entity_id, score)
);
CREATE INDEX idx_score_name ON entity_score(score, value DESC);

INSERT INTO entity_score (entity_id, score, value, computed_at)
SELECT id, 'sole_trader_likelihood', sole_trader_likelihood, datetime('now')
  FROM entity
 WHERE sole_trader_likelihood IS NOT NULL;

-- Kiln had one score, so every stored signal belongs to it. The DEFAULT is
-- what makes this an additive ALTER rather than a table rebuild; the
-- platform's record_signals always supplies the score explicitly, so nothing
-- written after this migration relies on it.
ALTER TABLE entity_signal
    ADD COLUMN score TEXT NOT NULL DEFAULT 'sole_trader_likelihood';
DROP INDEX IF EXISTS idx_signal_entity;
CREATE INDEX idx_signal_entity ON entity_signal(entity_id, score);

-- ---------------------------------------------------------------- the entity
ALTER TABLE entity RENAME COLUMN craft_primary TO category_primary;
ALTER TABLE entity RENAME COLUMN craft_confidence TO category_confidence;

-- headline_score is a denormalised copy of the site's primary score, kept for
-- ordering the directory without a join. entity_score stays the authority, and
-- db.set_score writes both in one call.
ALTER TABLE entity ADD COLUMN headline_score REAL;
UPDATE entity SET headline_score = sole_trader_likelihood;

DROP INDEX IF EXISTS idx_entity_craft;
CREATE INDEX idx_entity_category ON entity(category_primary);
CREATE INDEX idx_entity_company_number ON entity(company_number);

-- Last, and only after the value has been copied twice. SQLite refuses to drop
-- an indexed column, which is why the index work happens above rather than
-- below.
ALTER TABLE entity DROP COLUMN sole_trader_likelihood;

-- ------------------------------------------------------- compatibility layer
-- For the transition only. Datasette bookmarks, saved queries, the validation
-- spreadsheet's import and anything else outside this repository that read the
-- old names keep working while the code moves package by package.
--
-- `craft_taxonomy` keeps its exact old name, because the table underneath it
-- has been renamed. `entity` cannot: the table still owns that name, so
-- external readers of entity.craft_primary have to move to entity_craft. That
-- asymmetry is the honest signal that the entity rename is the one that has to
-- happen in the code rather than be papered over -- and both views are dropped
-- once nothing reads them.
CREATE VIEW craft_taxonomy AS
SELECT code, label, sic_map AS dcms_sic_map, keywords
  FROM category_taxonomy;

CREATE VIEW entity_craft AS
SELECT e.*,
       e.category_primary    AS craft_primary,
       e.category_confidence AS craft_confidence,
       (SELECT s.value FROM entity_score s
         WHERE s.entity_id = e.id AND s.score = 'sole_trader_likelihood')
                             AS sole_trader_likelihood
  FROM entity e;

-- +down
-- The views go first: SQLite refuses to rename or drop a column that a view
-- still names, and it is right to.
DROP VIEW IF EXISTS entity_craft;
DROP VIEW IF EXISTS craft_taxonomy;

ALTER TABLE entity ADD COLUMN sole_trader_likelihood REAL;
UPDATE entity SET sole_trader_likelihood = (
    SELECT s.value FROM entity_score s
     WHERE s.entity_id = entity.id AND s.score = 'sole_trader_likelihood');
ALTER TABLE entity DROP COLUMN headline_score;

DROP INDEX IF EXISTS idx_entity_category;
DROP INDEX IF EXISTS idx_entity_company_number;
ALTER TABLE entity RENAME COLUMN category_confidence TO craft_confidence;
ALTER TABLE entity RENAME COLUMN category_primary TO craft_primary;
CREATE INDEX idx_entity_craft ON entity(craft_primary);

DROP INDEX IF EXISTS idx_signal_entity;
CREATE INDEX idx_signal_entity ON entity_signal(entity_id);
-- The index has to go before the column does. Both of these are dropped
-- rather than left behind so that re-applying `up` afterwards succeeds -- a
-- reversal you can only perform once is not much of a reversal.
ALTER TABLE entity_signal DROP COLUMN score;

DROP INDEX IF EXISTS idx_score_name;
DROP TABLE entity_score;

ALTER TABLE category_taxonomy DROP COLUMN sort_order;
ALTER TABLE category_taxonomy RENAME COLUMN sic_map TO dcms_sic_map;
ALTER TABLE category_taxonomy RENAME TO craft_taxonomy;

makersmap/migrations/1001_platform_columns.sql is the boring remainder — purely additive, so migrate.already_satisfied() will baseline it on any database that already has them:

-- description: The columns the baselined platform stream would have created.
-- Additive only, deliberately: anything needing a rewrite is 1002, where it
-- can be reviewed as a data conversion rather than skimmed as DDL.

-- +up
ALTER TABLE lad ADD COLUMN is_listed INTEGER NOT NULL DEFAULT 0;
ALTER TABLE address ADD COLUMN ward_name TEXT;
ALTER TABLE crawl_page ADD COLUMN page_role TEXT;
ALTER TABLE venue ADD COLUMN address TEXT;
ALTER TABLE venue ADD COLUMN contact TEXT;
ALTER TABLE website_audit ADD COLUMN tech_stack TEXT;
ALTER TABLE website_audit ADD COLUMN has_dkim INTEGER;
ALTER TABLE website_audit ADD COLUMN careers_page_url TEXT;
ALTER TABLE website_audit ADD COLUMN credibility_score REAL;

-- +down
-- A real reversal rather than a note saying it is not worth one. A `-- +down`
-- section containing only prose is not treated as "irreversible" by the
-- runner: unless the text says so in as many words, it is parsed as a script,
-- run as an empty one, and the migration is recorded as reverted having
-- changed nothing.
ALTER TABLE website_audit DROP COLUMN credibility_score;
ALTER TABLE website_audit DROP COLUMN careers_page_url;
ALTER TABLE website_audit DROP COLUMN has_dkim;
ALTER TABLE website_audit DROP COLUMN tech_stack;
ALTER TABLE venue DROP COLUMN contact;
ALTER TABLE venue DROP COLUMN address;
ALTER TABLE crawl_page DROP COLUMN page_role;
ALTER TABLE address DROP COLUMN ward_name;
ALTER TABLE lad DROP COLUMN is_listed;

Renames that are not additions — search_result.craftcategory_code, search_result.localitytown, rated_premises.ratepayer_normratepayer_normalised, nndr_release.content_hashfile_hash — go in 1001 too, as ALTER TABLE ... RENAME COLUMN, but note that already_satisfied will not baseline a migration containing them, which is the safe direction: it will either succeed or fail loudly.

Checkpoint: on a copy of production, up then down then up, with PRAGMA foreign_key_check and PRAGMA integrity_check clean each time and SELECT COUNT(*) stable on entity, fact, entity_signal, entity_location, address, claim and suppression. Then makersmap score and confirm entity_score and entity.headline_score agree row for row.

B2. CRAFTS becomes a Taxonomy

Kiln stores its taxonomy as list[tuple[code, label, sic_csv, keywords]]. The platform's Category accepts a comma-separated SIC string and a keyword sequence and cleans both in __post_init__, so the port is a comprehension — which is the point: nothing about the data changes.

"""Makers Map — the creative craft taxonomy.

Unchanged from kiln/taxonomy.py in substance. What moved out of this file is
the machinery: five-digits-then-four SIC lookup, the inflection-tolerant
keyword matcher, and the rule that a broad code corroborates but never
classifies. What stayed is the judgement — which crafts exist, which codes are
specific to them, and why 96.09 is excluded.
"""
from __future__ import annotations

from placegraph.scoring import Category, Taxonomy

# code, label, SIC codes SPECIFIC to this craft, classifier keywords.
# Verbatim from kiln/taxonomy.py; the tuple shape survives because Category
# cleans '23.41' / '2341' / '23.41,32.12' identically.
CRAFTS: list[tuple[str, str, str, list[str]]] = [
    ("ceramics", "Ceramics & pottery", "23.41", [
        "ceramic", "ceramics", "pottery", "potter", "porcelain", "stoneware",
        "earthenware", "glaze", "wheel thrown", "throwing", "studio pottery",
    ]),
    ("jewellery", "Jewellery & silversmithing", "32.12,32.13", [...]),
    # ... the other sixteen, unchanged ...
    ("tattoo", "Tattoo & body art", "", [
        # No SIC of its own: tattooing sits inside 96.09 alongside dozens of
        # unrelated trades. Name and website text are the only reliable signal.
        "tattoo", "tattooist", "tattoo studio", "body piercing", "flash art",
    ]),
]

CATEGORIES = [Category(code=code, label=label, sic=sic, keywords=tuple(keywords))
              for code, label, sic, keywords in CRAFTS]

BROAD_SIC: dict[str, str] = {
    "6201": "Computer programming activities",
    # ... unchanged, including the 47781 five-digit entry and the 5819 comment
    # about Freestyle Web Design being filed under "other publishing" ...
}

EXCLUDED_SIC: dict[str, str] = {
    "9609": "Other personal service activities n.e.c. — contains tattooing but "
            "also mediation, dating agencies, pet care, shoe repair",
    # ... unchanged ...
}

TAXONOMY = Taxonomy(
    CATEGORIES,
    broad_sic=BROAD_SIC,
    excluded_sic=EXCLUDED_SIC,
    reconciliation_name="DCMS creative industries definition",
)

The CRAFTS list is kept in its original shape rather than rewritten as Category(...) literals so that the diff on this file is additive only. That matters more than tidiness: test_broad_sic_codes_never_assign_a_craft, test_sic_matching_is_five_digit_aware, test_specific_sic_codes_do_assign_a_craft and test_tattoo_is_matched_on_name_not_sic are all assertions about these literals, and a reviewer needs to see at a glance that none of them moved.

Checkpoint: the eight taxonomy tests in test_invariants.py and test_a_catch_all_sic_cannot_assign_a_craft_alone in test_images_and_charts.py, plus:

assert TAXONOMY.discovery_sic_codes() == kiln.taxonomy.CREATIVE_SIC_CODES
assert {c.code for c in TAXONOMY} == {c[0] for c in kiln.taxonomy.CRAFTS}

Delete kiln/taxonomy.py only after that comparison has run once and passed.

B3. SIGNAL_WEIGHTS becomes a ScoreDefinition

The weights are the judgement and do not change. What changes is where they live and how the collector reads its inputs.

"""Makers Map — the named scores.

One score, because the vertical has one hard question: does this business exist
at all? Most of the audience is unincorporated and no register lists them, so
the answer is the intersection of several partial sources rather than any one of
them, and the number is only usable because every signal that produced it is
stored beside it.

The weights are kiln/scoring.py's SIGNAL_WEIGHTS, unchanged. The strengths
follow the strong/medium/weak bands, and `company_suffix_in_name` at -1.20 has
to outweigh the combined absence signals: otherwise a company we merely failed
to match accumulates "no CH match" plus "ICO entry without a company number" and
gets published as a sole trader, which is precisely the misclassification the
DPIA treats as the main accuracy harm.
"""
from __future__ import annotations

from placegraph.core.normalise import has_company_token, looks_personal_name
from placegraph.scoring import ScoreDefinition, ScoreProfile, Signal, SignalContext

SOLE_TRADER = "sole_trader_likelihood"

WEIGHTS: dict[str, float] = {
    # -- absence: they are not a company
    "no_ch_match": 0.35,                   # strong
    "ico_without_company_number": 0.30,    # strong
    "footer_lacks_company_number": 0.20,   # medium
    "personal_name_pattern": 0.18,         # medium
    "gbp_without_ch_match": 0.10,          # weak
    # -- presence: they exist and trade
    "has_website": 0.04,
    "self_registered": 0.25,
    # -- negative: they clearly *are* a company
    "ch_matched": -0.85,
    "company_suffix_in_name": -1.20,
    "company_number_on_site": -0.60,
}


def collect_sole_trader(ctx: SignalContext) -> list[Signal]:
    """kiln.scoring.score_entity's signal block, reading the shared context.

    The context has already loaded the entity, its facts by key, the set of
    contributing sources and every crawled page — once per entity, shared by
    every score in the profile — so none of the queries kiln issued here are
    reissued.
    """
    out: list[Signal] = []
    entity = ctx.entity

    if entity["company_number"]:
        out.append(ctx.signal("ch_matched",
                              f"Companies House {entity['company_number']}"))
    else:
        if ctx.has_fact("ch_checked"):
            out.append(ctx.signal(
                "no_ch_match",
                "name+postcode searched against Companies House, no match"))
        if ctx.from_source("ico"):
            out.append(ctx.signal("ico_without_company_number",
                                  "ICO fee-payer entry with no company number"))
        if ctx.from_source("gplaces"):
            out.append(ctx.signal(
                "gbp_without_ch_match",
                "Google Business Profile with no Companies House match"))

    name = entity["display_name"] or ""
    if has_company_token(name):
        out.append(ctx.signal("company_suffix_in_name",
                              "legal suffix in trading name"))
    elif looks_personal_name(name):
        out.append(ctx.signal("personal_name_pattern",
                              "trades under a personal name"))

    if entity["website"]:
        out.append(ctx.signal("has_website", entity["website"]))

    # kiln read the single most recent crawl_page; ctx.pages is every page,
    # newest first, so [0] is the same row. Taking the newest matters: a
    # company number found on a later crawl has to be able to move the score
    # back down.
    if ctx.pages:
        page = ctx.pages[0]
        if page["company_number_found"]:
            out.append(ctx.signal(
                "company_number_on_site",
                f"site discloses {page['company_number_found']}"))
        else:
            out.append(ctx.signal("footer_lacks_company_number",
                                  "no company number disclosed on site"))

    if ctx.from_source("qr") or ctx.from_source("member"):
        declared = ctx.fact("self_declared_kind")
        if not declared or declared == "sole_trader":
            out.append(ctx.signal("self_registered",
                                  "self-registered via QR/claim"))
    return out


SCORES = [
    ScoreDefinition(
        name=SOLE_TRADER,
        question="How likely is it that this is an unincorporated sole trader?",
        weights=WEIGHTS,
        collector=collect_sole_trader,
        squash="logistic",
        gain=3.0,               # kiln's 1 / (1 + e^(-3.0 * raw))
    ),
]

SCORE_PROFILE = ScoreProfile(
    SCORES,
    # kiln: `elif likelihood >= 0.6: kind = "sole_trader"`, after a company
    # number and after a self-declaration, both of which the platform applies
    # ahead of any rule.
    kind_rules=[(SOLE_TRADER, 0.6, "sole_trader")],
    # kiln fed the classifier craft_hint, sic_description, ico_sector and
    # gbp_types. The first two are platform defaults; the last two are keys
    # this site's sources write, so the site names them.
    classifier_facts=("category_hint", "craft_hint", "sic_description",
                      "ico_sector", "gbp_types"),
)

Checkpoint: test_score_moves_in_both_directions, test_legal_suffix_outweighs_absence_signals, test_scores_are_explainable, test_scoring_is_idempotent, test_generic_only_name_still_scores. Then the one that is not in the test suite and matters most — score the whole live graph under both implementations and diff:

SELECT COUNT(*) FROM entity e JOIN entity_score s
  ON s.entity_id = e.id AND s.score = 'sole_trader_likelihood'
 WHERE ABS(s.value - e.headline_score_before) > 0.001;

Anything other than zero is a finding to explain before the old scorer is deleted, not a tolerance to widen.

B4. The web layer

kiln/web/app.py is 1350 lines and becomes create_app plus seven register_*(app, deps) modules. The mechanical part:

  1. Point kiln at placegraph.web.create_app(config=CONFIG) and add makersmap/asgi.py.
  2. Copy kiln/web/templates/* into makersmap/theme/ wholesale, so the site starts by overriding everything and nothing renders differently.
  3. Delete from theme/ one template at a time, running test_accessibility.py and eyeballing the page after each. What is left at the end is the genuine theme.

That order matters: starting from "override nothing and see what breaks" gives you a broken site and no way to bisect it.

Checkpoint: test_accessibility.py in full — every public page structurally accessible, palette contrast, button contrast, visible focus, forms surviving a missing label — plus test_removal_safety.py, which is the one that must not be allowed to regress by a single assertion: a stranger with a slug cannot destroy a record, a hidden listing is unreachable, scoring does not republish a pending removal, the honeypot swallows bots, rate limiting bounds an abuser.

Then python -m placegraph.compliance.preflight --site makersmap, against the live host, and compare the report to the one taken before phase A started. Any check that changed state is a regression until proven otherwise.


4. Phase C — the rest

  1. posters.py, venues.py, domains.pyplacegraph.outreach. The fifty Southampton venues move from a hard-coded list to extra["venue_worklist"]; poster copy moves to extra["poster_copy"].
  2. guides.py, blog.py, images.py, charts.pyplacegraph.content. The guides themselves register at import from makersmap/content/. test_images_and_charts.py is the harness, including test_every_craft_has_search_terms — which becomes a test that every category has image search terms.
  3. worker/ and worker-outreach/ → rendered from placegraph.outreach.workers, with SENDING_ENABLED="0" unchanged.
  4. deploy/python -m placegraph.deploy render --site makersmap --dest deploy and diff against the existing kit. The service rename kiln.servicemakersmap.service is the one genuinely disruptive step: stop and disable the old units before enabling the new ones, or two timers run the same pipeline against the same SQLite file.
  5. Refinery timers and manifest.json on the existing VM, per refinery.md. Makers Map becomes both a refinery host and a refinery consumer, pulling from http://localhost/refinery — which is worth doing rather than special-casing it, because it exercises the consumer path that Circuit depends on.

Checkpoint: the full suite, preflight green, and a nightly cycle observed end to end — refinery at 02:10, backup at 02:40, maintenance at 03:20, with journalctl showing all three and <slug> stats unchanged in the morning.


5. What the extraction lost, and what the contract does not settle

Found by reading both trees rather than by running them, so each is a thing to decide before phase A is declared complete.

homepage_url has no platform home. Kiln reduces a deep link into a policy page to the site root — a listing pointing at somebody's returns policy is technically right and useless — and leaves a meaningful path alone, because for plenty of makers /shop/ceramics genuinely is their site. placegraph.sources.serper stores result["link"] verbatim. Two kiln tests cover it. Add it to placegraph.core.normalise, and to contract.md §3 in the same change, per the contract's own rule; then use it in serper.enrich.

A trading name no longer beats a specific SIC code. Kiln's classifier has a rule the platform's category_from_sic does not: where a specific SIC code disagrees with a confident (≥0.65) classification from the business's own name, the name wins. "Step Change Design Ltd" filed under 58.11 book publishing and "Freestyle Web Design" under 58.19, and in both cases the code beat the words in their own name. The reasoning generalises — a name is chosen to tell customers what a business does; a SIC code is picked once from a dropdown at incorporation and never looked at again — so this belongs in the platform, and Circuit wants it for exactly the same reason. Land it as a platform change before phase B, with its own test, not inside the move.

Publication geography tightens. Kiln published anything with lad_code IS NOT NULL. The platform requires lad_code IN cfg.listed_lads, so records in Basingstoke, East Hampshire, Hart and Rushmoor — kiln's HAMPSHIRE_LADS, now wider_lads — stop being published. That is the correction the "brand scope must match data scope" lesson asks for, but it is a change to what the public site shows. Count it before and after, and make it a deliberate release note rather than a surprise in the coverage number.

A claim no longer bypasses geography. Kiln's claimed-record override was WHERE status='claimed' AND (gates), with no structural conditions. The platform applies the structural clauses to claims too: a claimed record still has to be in a listed council area and, when required, categorised. Stricter and correct — "the owner asked us to" does not make the geography true — but a claimed record outside the listed set will disappear.

company_accounts, rated_premises and nndr_release are poorer in the platform than in kiln. Twenty-seven columns of lateness analysis, ARD tracking and balance-sheet detail; relief percentages and billing postcode; licence and redaction style. Kiln's accounts/ module computes several of these and the platform's persists a narrower set. Decide per column: promote to the platform (most of them, since Circuit's contractor_likelihood wants dormancy and micro-entity signals) or drop deliberately with a note. Do not leave them half-present.

validation_sample and image need data conversion, not DDL. Kiln's per-dimension check columns become the platform's checks JSON — which is the right shape, since the dimensions a hand-check covers are the vertical's own — and kiln's image rows key on (kind, subject, provider, source_page) where the platform keys on (kind, subject, file_url). Both are one-way. Write them as 1002 with a Python data step rather than pure SQL, and keep the old tables alongside under a _kiln suffix until the first successful validation round after the cutover.

Two counts that must be identical across the move, and are the cheapest proof the extraction changed nothing:

SELECT COUNT(*) FROM entity WHERE is_public = 1;
SELECT category_primary, COUNT(*) FROM entity WHERE is_public = 1
 GROUP BY category_primary ORDER BY 2 DESC;

Take them before phase A. Take them after every checkpoint. The first number that moves without a written reason is where the extraction went wrong, and finding that on the day it happens is the entire argument for doing Makers Map first.