Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
"""Hosted Phase Registry and Extension Registry (WP-0006-T03).
|
|
|
|
|
|
|
|
|
|
Wraps `validation.py`'s already-tested conformance checks with Postgres
|
|
|
|
|
persistence, per `docs/adr/ADR-0002-hosted-trust-service-stack.md`. This
|
|
|
|
|
module adds multi-tenancy and storage; it must not introduce new validation
|
|
|
|
|
logic beyond what `validation.py` already does (TSD §4.1: this task "adds
|
|
|
|
|
multi-tenancy and persistence, not new validation logic").
|
|
|
|
|
|
|
|
|
|
Append-only enforcement for `phase_manifests`, and canonicalization-as-
|
|
|
|
|
governance-action for `extensions`, are primarily enforced at the database
|
|
|
|
|
level (see `migrations/0001_registries.sql`) — this module's job is to
|
|
|
|
|
surface a conformance rejection *before* attempting a write that the
|
|
|
|
|
database would reject anyway, so callers get a field-by-field diff instead
|
|
|
|
|
of an opaque database error.
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
|
|
|
|
|
WP-0009-T02 extended the `licensors` auth model so one Licensor tenant
|
|
|
|
|
(e.g. `binky`) can hold multiple, individually-issued, individually-
|
|
|
|
|
revocable credentials (`issue_sub_credential`, `revoke_sub_credential`),
|
|
|
|
|
each carrying its own `credential_label` and rights tier
|
|
|
|
|
(`RIGHTS_TIERS`), while still resolving to the same `licensor_id` for
|
|
|
|
|
every phase-ownership check in this module and `ledger.py` — unchanged
|
|
|
|
|
from the single-token model WP-0006 originally shipped.
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
import secrets
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from psycopg import Connection
|
|
|
|
|
from psycopg.errors import UniqueViolation
|
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
|
|
|
|
|
|
from . import validation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RegistrationError(ValueError):
|
|
|
|
|
"""Raised with the specific reason a registration was rejected."""
|
|
|
|
|
|
|
|
|
|
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
# Rights tiers accepted 2026-07-30
|
|
|
|
|
# (workplans/TREV-WP-0009-target-revenue-control-plane.md T01,
|
|
|
|
|
# specs/TargetRevenueControlPlaneConcept.md §2), ordered least to most
|
|
|
|
|
# privileged. Enforcing what each tier may actually do is the Control
|
|
|
|
|
# Plane's own job (WP-0009-T03/T04) — this module only carries the tier
|
|
|
|
|
# label through authentication and offers `has_right` as a shared ordinal
|
|
|
|
|
# comparison, so that enforcement logic doesn't have to reinvent the
|
|
|
|
|
# ordering itself.
|
|
|
|
|
RIGHTS_TIERS = ("viewer", "contributor", "operator", "admin")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def has_right(rights: str, minimum: str) -> bool:
|
|
|
|
|
"""True if `rights` is at least as privileged as `minimum` (RIGHTS_TIERS order)."""
|
|
|
|
|
return RIGHTS_TIERS.index(rights) >= RIGHTS_TIERS.index(minimum)
|
|
|
|
|
|
|
|
|
|
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Licensor:
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
"""An authenticated caller: a Licensor tenant, via one of possibly
|
|
|
|
|
several individually-issued, individually-revocable credentials
|
|
|
|
|
(WP-0009-T02). `credential_label` and `rights` distinguish *which*
|
|
|
|
|
credential authenticated, for attribution and authorization purposes,
|
|
|
|
|
even though every credential for the same `licensor_id` has identical
|
|
|
|
|
phase-ownership rights in `registry.py`/`ledger.py`'s existing checks.
|
|
|
|
|
"""
|
|
|
|
|
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
token: str
|
|
|
|
|
licensor_id: str
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
credential_label: str | None = None
|
|
|
|
|
rights: str = "operator"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_credential_token() -> str:
|
|
|
|
|
"""A new random credential token, suitable for `issue_sub_credential`."""
|
|
|
|
|
return secrets.token_hex(32)
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def authenticate(conn: Connection, token: str) -> Licensor:
|
|
|
|
|
"""Resolve a bearer token to a Licensor, or raise RegistrationError.
|
|
|
|
|
|
|
|
|
|
Per ADR-0002: write access is scoped per Licensor via this token, kept
|
|
|
|
|
independent of the per-entry Ed25519 signing WP-0002 already
|
|
|
|
|
implements (that signing happens at the Ledger layer, T04 — this
|
|
|
|
|
function only answers "who is calling," not "is this entry authentic").
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
|
|
|
|
|
A revoked credential (`revoked_at` set) is rejected exactly like an
|
|
|
|
|
unrecognized one — from the caller's perspective there is no
|
|
|
|
|
observable difference between "never existed" and "no longer valid,"
|
|
|
|
|
which is the correct behavior for a revoked credential.
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
"""
|
|
|
|
|
row = conn.execute(
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
"""
|
|
|
|
|
SELECT licensor_id, credential_label, rights
|
|
|
|
|
FROM licensors
|
|
|
|
|
WHERE token = %s AND revoked_at IS NULL
|
|
|
|
|
""",
|
|
|
|
|
(token,),
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
).fetchone()
|
|
|
|
|
if row is None:
|
|
|
|
|
raise RegistrationError("unrecognized or revoked token")
|
Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.
registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.
Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.
All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00
|
|
|
licensor_id, credential_label, rights = row
|
|
|
|
|
return Licensor(
|
|
|
|
|
token=token, licensor_id=licensor_id, credential_label=credential_label, rights=rights
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_licensor_identity(conn: Connection, licensor_id: str) -> None:
|
|
|
|
|
"""Onboard a brand-new Licensor tenant identity (`licensor_identities`),
|
|
|
|
|
a prerequisite before any credential can be issued for it
|
|
|
|
|
(`issue_sub_credential`'s foreign key requires the identity to already
|
|
|
|
|
exist). Deliberately a separate, explicit step rather than an implicit
|
|
|
|
|
side effect of issuing the first credential — matching this
|
|
|
|
|
framework's existing pattern of governance actions being explicit
|
|
|
|
|
(`promote_extension_canonical`, `revoke_sub_credential`), not
|
|
|
|
|
automated. There is exactly one identity today (`binky`,
|
|
|
|
|
`specs/TRSL-Governance.md` §1); this function exists so a future
|
|
|
|
|
additional tenant does not require a schema change to onboard.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO licensor_identities (licensor_id) VALUES (%s)", (licensor_id,)
|
|
|
|
|
)
|
|
|
|
|
except UniqueViolation as exc:
|
|
|
|
|
raise RegistrationError(f"licensor identity {licensor_id!r} already exists") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def issue_sub_credential(
|
|
|
|
|
conn: Connection,
|
|
|
|
|
licensor_id: str,
|
|
|
|
|
credential_label: str,
|
|
|
|
|
rights: str,
|
|
|
|
|
issued_by: str,
|
|
|
|
|
) -> Licensor:
|
|
|
|
|
"""Issue a new, individually-revocable credential for a Licensor
|
|
|
|
|
tenant (e.g. a second credential for `binky`, labeled for a specific
|
|
|
|
|
human user). This is itself a governance action — `issued_by` records
|
|
|
|
|
who issued it, the same attributable-action pattern already used by
|
|
|
|
|
`promote_extension_canonical`. The Licensor identity is auto-created
|
|
|
|
|
on first use if it doesn't already exist
|
|
|
|
|
(`migrations/0005_licensor_credentials.sql`'s `ensure_licensor_identity`
|
|
|
|
|
trigger) — `create_licensor_identity` remains available for callers
|
|
|
|
|
that want tenant onboarding as its own explicit, auditable step rather
|
|
|
|
|
than an implicit side effect of the first credential.
|
|
|
|
|
"""
|
|
|
|
|
if rights not in RIGHTS_TIERS:
|
|
|
|
|
raise RegistrationError(f"rights must be one of {RIGHTS_TIERS}, got {rights!r}")
|
|
|
|
|
token = generate_credential_token()
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
""",
|
|
|
|
|
(token, licensor_id, credential_label, rights, issued_by),
|
|
|
|
|
)
|
|
|
|
|
except UniqueViolation as exc:
|
|
|
|
|
raise RegistrationError(
|
|
|
|
|
f"an active credential labeled {credential_label!r} already exists for "
|
|
|
|
|
f"licensor {licensor_id!r}"
|
|
|
|
|
) from exc
|
|
|
|
|
return Licensor(
|
|
|
|
|
token=token, licensor_id=licensor_id, credential_label=credential_label, rights=rights
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def revoke_sub_credential(conn: Connection, token: str) -> None:
|
|
|
|
|
"""Revoke a credential via the database's `revoke_credential` function
|
|
|
|
|
(not a direct UPDATE — the application role has no UPDATE grant on
|
|
|
|
|
`licensors` at all, matching `promote_extension_canonical`'s pattern).
|
|
|
|
|
Idempotent: revoking an already-revoked or unknown token is not an
|
|
|
|
|
error — the caller's intent (this token must not work) is satisfied
|
|
|
|
|
either way."""
|
|
|
|
|
conn.execute("SELECT revoke_credential(%s)", (token,))
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_phase_manifest(
|
|
|
|
|
conn: Connection, licensor: Licensor, manifest: dict[str, Any]
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Validate and persist a Phase Manifest.
|
|
|
|
|
|
|
|
|
|
Rejects, with the same field-by-field diff `validation.py` produces
|
|
|
|
|
offline, rather than silently accepting a non-conformant manifest
|
|
|
|
|
(TSD §3.1 validation rule). A manifest is inserted once; there is no
|
|
|
|
|
update path — `phase_manifests` grants no UPDATE/DELETE to the
|
|
|
|
|
application role (migrations/0001_registries.sql), so a second attempt
|
|
|
|
|
to register the same `phase_id` fails at the database's unique
|
|
|
|
|
constraint, surfaced here as RegistrationError rather than a raw
|
|
|
|
|
IntegrityError.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
validation.validate_phase_manifest(manifest)
|
|
|
|
|
except validation.ConformanceError as exc:
|
|
|
|
|
raise RegistrationError(
|
|
|
|
|
"manifest rejected: " + "; ".join(exc.errors)
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
phase_id = manifest["phase"]["id"]
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO phase_manifests (phase_id, licensor_id, manifest)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
""",
|
|
|
|
|
(phase_id, licensor.licensor_id, Jsonb(manifest)),
|
|
|
|
|
)
|
|
|
|
|
except UniqueViolation as exc:
|
|
|
|
|
raise RegistrationError(
|
|
|
|
|
f"phase_id {phase_id!r} is already registered; "
|
|
|
|
|
"a Phase Manifest cannot be re-registered or replaced in place"
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_phase_manifest(conn: Connection, phase_id: str) -> dict[str, Any] | None:
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT manifest FROM phase_manifests WHERE phase_id = %s", (phase_id,)
|
|
|
|
|
).fetchone()
|
|
|
|
|
return row[0] if row else None
|
|
|
|
|
|
|
|
|
|
|
Implement WP-0009-T04: Control Plane interactive UI on whynot-design
Builds the Control Plane's browser UI (login, dashboard, Phase
registration, Development Credit entry/proposal/review, credential
admin, audit log) as a FastAPI + Jinja2 app over the already-finished
T03 backend, rather than from scratch — whynot-design's Lit web
components are vendored as static assets (source commit 4b62cffc,
v0.4.1), with lit itself resolved via an esm.sh CDN import map.
Session auth re-checks the credential token against the database on
every request rather than trusting the session cookie's cached rights,
so a mid-session revocation takes effect immediately.
9 new Docker-gated HTTP-level tests via FastAPI's TestClient (no
browser-automation tool available, so real rendering of the <wn-*>
components was never visually verified). All four WP-0009 tasks are
now done; workplan marked finished.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:43:16 +02:00
|
|
|
def list_phase_manifests_for_licensor(conn: Connection, licensor_id: str) -> list[dict[str, Any]]:
|
|
|
|
|
"""All Phases registered by one Licensor tenant, most recently
|
|
|
|
|
registered first — needed by any UI that wants to show "my Phases"
|
|
|
|
|
(WP-0009-T04's Control Plane dashboard) rather than requiring a
|
|
|
|
|
caller to already know every `phase_id` in advance."""
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT manifest FROM phase_manifests
|
|
|
|
|
WHERE licensor_id = %s
|
|
|
|
|
ORDER BY registered_at DESC
|
|
|
|
|
""",
|
|
|
|
|
(licensor_id,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
return [row[0] for row in rows]
|
|
|
|
|
|
|
|
|
|
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
def register_extension(
|
|
|
|
|
conn: Connection, licensor: Licensor, extension: dict[str, Any]
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Validate and persist a Monetization Extension registration.
|
|
|
|
|
|
|
|
|
|
New registrations always start at `status: registered` (assigned here,
|
|
|
|
|
never accepted as an input field from the caller) — `canonical` is a
|
|
|
|
|
separate, governance-gated transition (TS-FR-2; see
|
|
|
|
|
`promote_extension_canonical` below).
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
validation.validate_extension_contract(extension)
|
|
|
|
|
except validation.ConformanceError as exc:
|
|
|
|
|
raise RegistrationError(
|
|
|
|
|
"extension rejected: " + "; ".join(exc.errors)
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
extension_id = extension["id"]
|
|
|
|
|
version = extension["version"]
|
|
|
|
|
try:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO extensions (extension_id, version, licensor_id, contract, status)
|
|
|
|
|
VALUES (%s, %s, %s, %s, 'registered')
|
|
|
|
|
""",
|
|
|
|
|
(extension_id, version, licensor.licensor_id, Jsonb(extension)),
|
|
|
|
|
)
|
|
|
|
|
except UniqueViolation as exc:
|
|
|
|
|
raise RegistrationError(
|
|
|
|
|
f"extension {extension_id!r}@{version!r} is already registered"
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_extension(
|
|
|
|
|
conn: Connection, extension_id: str, version: str
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT contract, status FROM extensions WHERE extension_id = %s AND version = %s",
|
|
|
|
|
(extension_id, version),
|
|
|
|
|
).fetchone()
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
contract, status = row
|
|
|
|
|
return {**contract, "status": status}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def promote_extension_canonical(
|
|
|
|
|
conn: Connection, extension_id: str, version: str, approved_by: str
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Promote an extension from `registered` to `canonical`.
|
|
|
|
|
|
|
|
|
|
Calls the database's `set_extension_status` function rather than an
|
|
|
|
|
UPDATE, because the application role has no UPDATE grant on
|
|
|
|
|
`extensions` at all (migrations/0001_registries.sql) — canonicalization
|
|
|
|
|
is a documented human/governance action recorded with who performed it,
|
|
|
|
|
never a route ordinary application code can take by itself
|
|
|
|
|
(TSD §4.1: "never automated").
|
|
|
|
|
"""
|
|
|
|
|
conn.execute(
|
|
|
|
|
"SELECT set_extension_status(%s, %s, %s, %s)",
|
|
|
|
|
(extension_id, version, "canonical", approved_by),
|
|
|
|
|
)
|