Distribution targets — why the own-site row is a sentinel
pipeline_distributions is the table that answers “where did this task’s work
end up?” One row per (task_id, target, medium):
target is a small closed vocabulary, and every value is a platform name —
youtube today, whatever comes next tomorrow — with one exception: the row
recording that the post itself went live on the site this install publishes.
That row carries the sentinel 'site', exported as
services.pipeline_db.SITE_TARGET.
Why a sentinel and not the operator’s domain
Until Glad-Labs/poindexter#1038 that row carried the literalgladlabs.io — the
source operator’s own domain — hardcoded at three write sites and matched by
name at five read sites:
No fresh install was broken by this. The literal matched on both the write
and the read side, so a fork’s own-site rows resolved fine. That is precisely
why it survived for as long as it did — there was no failing behaviour to
notice, only the fact that every install labelled its own publishes with one
operator’s brand, and that every “not the site itself” filter read as
target <> '<one operator's domain>', which is true elsewhere only by
coincidence. It also slipped past scripts/ci/check_public_mirror_safety.py,
whose gladlabs.io rule is scoped to a SQL VALUES tuple on the theory that a
leaked operator domain looks like a seeded default; a kwarg and a WHERE
predicate are neither.
The obvious alternative — write app_settings.site_domain into the column — does
not work, for a structural reason worth remembering:
The read side is a VIEW.It is also the wrong model. The row records a relationship — “this task published to self” — not a hostname, and the hostname can change without any of these rows meaning something different.content_tasksandpipeline_tasks_viewresolvepost_id/post_slug/published_atthrough correlated subqueries against this table, and a view cannot consult a settings row to build its own predicate. Whatever goes intargethas to be knowable by SQL alone.
Reading the column
Bindservices.pipeline_db.SITE_TARGETS rather than inlining a list:
SITE_TARGETS is (SITE_TARGET, *LEGACY_SITE_TARGETS). Readers accept the
legacy spelling on purpose: writers only ever produce the sentinel now, so a
legacy row means a DB restored from an older dump or a task published inside a
rollback window. Those rows must still resolve their own-site columns instead of
silently reading as an unpublished task — that is the difference between a
back-compat list and a cosmetic rename.
SQL that cannot import the constant (the view bodies, the Grafana panels) spells
the list out:
The migration
20260901_183235_retarget_the_own_site_distribution_rows_onto_a_sentinel_*.py
does two things, both idempotent and both safe on a fresh install where the
baseline already created the widened views:
- Rewrites the rows with bound parameters, guarded by a
NOT EXISTSso a task that somehow holds both spellings keeps its legacy row rather than violatingUNIQUE (task_id, target, medium). Prod carried 179 rows, allmedium='default', with no such conflict. - Widens both view bodies — read back with
pg_get_viewdefand transformed, then re-issued throughCREATE OR REPLACE VIEW. The definitions are deliberately not pasted into the migration: their real home is0000_baseline.schema.sql, and a pasted copy rots the first time that one changes. A transform adapts to whatever the view actually is.
- It matches the literal, never the left-hand side.
pg_get_viewdef(..., pretty := true)renderspd.target::textwhere the unpretty form renders(pd.target)::text. Pinning either spelling is how a transform quietly matches nothing and reports success — which is exactly what the first draft of this migration did. - It raises when it matches nothing. A view that exists, is not already widened, and carries no legacy literal is a rendering change, not a no-op. A check that scanned nothing has not passed.
CREATE OR REPLACE VIEW preserves the three INSTEAD OF triggers on
content_tasks, because the column list is untouched.
The guard
check_public_mirror_safety.py now carries an operator domain used as a
distribution target rule, matching a domain-shaped value on either side of a
target comparison. It is deliberately not gladlabs.io-specific — any
hostname in this position is the same bug — and deliberately does not fire on a
membership test (NOT IN (...), <> ALL ($n)), which is how the widened
readers keep accepting the pre-cutover value.
Contract tests: tests/unit/scripts/test_check_public_mirror_safety_distribution_target.py
(the rule), tests/unit/services/test_pipeline_db.py (the constants),
tests/integration_db/test_own_site_distribution_target.py (both views against a
real Postgres, both spellings, plus the check that a platform row does not
fill the own-site columns).