diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md index 8e068abc1..4fec59e94 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/CHUNK_MAP.md @@ -12,19 +12,20 @@ signed-loop records do not make behavior live. | `01` | Canonical specification and ADR 0016 | Merged PR #144 | | `02A` | Shared transactional outbox persistence/append | Merged PR #155; migration 0029 | | `03A` | Adapter-binding persistence | Merged PR #267; migration 0053 | +| `PLAN5` | Complete-context human needs-revision rebase reconciliation | Merged PR #270 | ## Current reconciliation | Chunk | Goal | Risk | Status | |---|---|---:|---| | `PLAN4` | Reconcile current main, ART/AUTH/REV changes, open PRs, and end-to-end order | L1 | Merged PR #261 | -| `PLAN5` | Reconcile complete-context human needs-revision rebase across guide, policy, REV, and CON contracts | L1 | Active specification chunk; no runtime | +| `PLAN5` | Reconcile complete-context human needs-revision rebase across guide, policy, REV, and CON contracts | L1 | Merged PR #270; no runtime | ## Core runtime chunks | Chunk | Goal | Entry gate | Status | |---|---|---|---| -| `03B` | Contribution-policy persistence | 03A | Proposed; unblocks REV-03A2 FK | +| `03B` | Contribution-policy persistence | 03A | Implemented and internally reviewed; PR checkpoint; unblocks REV-03A2 FK after merge | | `02C` | Shared lifecycle-audit participant | PLAN4; current AuditEvent contract | Proposed; independent of dispatcher; required before REV-04B | | `04A` | Hidden adapter-binding service | 03A + exact AUTH registration/PREP contract | Blocked on AUTH registration | | `04B` | Hidden contribution-policy service | 03B + 04A + exact AUTH registration/PREP contract | Blocked on AUTH registration | @@ -79,6 +80,7 @@ distributed-lane changes. ## Stop -CON-03A stops at its PR checkpoint. It does not start 03B, expose binding -creation/lifecycle behavior, or substitute existing ART/REV identities for the -future AUTH-approved compensation adapter contract. +CON-03B stops at this PR checkpoint. No later chunk starts until 03B passes its +external checks and receives human merge approval. Binding creation/lifecycle +behavior remains deferred and existing ART/REV identities do not substitute +for the future AUTH-approved compensation adapter contract. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md index 31e8446a7..9971ed1d0 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/SOURCE_MANIFEST.md @@ -2,8 +2,9 @@ ## Reconciliation baseline -- Current `main`: `cda59fc32e3a60e1e2c337cae7ebeaa94b95e12b`. -- Current migration head: `0053_compensation_bindings`. +- Reconciled `main`: `9550b3cdb8ac12dea72931277cf46ac1b8ffda7f`. +- Current migration head on `main`: `0054_guide_sufficiency_authority`; + this branch advances it to `0055_contribution_policy`. - Current capability ledger: `docs/roadmap_status.md`. - Current contribution process: `AGENTS.md`, `CONTRIBUTING.md`, and `.agent-loop/README.md`. Historical signed-start and merge-intent records are diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md index a5d94ecdc..e4c587ac6 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md @@ -2,13 +2,16 @@ ## Current baseline -- Reconciled main: `cda59fc32e3a60e1e2c337cae7ebeaa94b95e12b`. -- Alembic head on main: `0053_compensation_bindings`. +- Reconciled main: `9550b3cdb8ac12dea72931277cf46ac1b8ffda7f`. +- Alembic head on main: `0054_guide_sufficiency_authority`; 03B advances the branch + to `0055_contribution_policy` after AUTH migration + `0054_guide_sufficiency_authority`. - CON-01, CON-02A, and CON-03A are merged; 03A merged in PR #267. -- PLAN5 is the active documentation/specification reconciliation for the - human-confirmed complete-context `needs_revision` rebase rule. -- Runtime contains shared outbox persistence and the schema-only compensation - binding foundation; contribution, +- PLAN5 is merged in PR #270 and preserves the human-confirmed complete-context + `needs_revision` rebase rule. +- Runtime on main contains shared outbox persistence and the schema-only + compensation binding foundation. 03B is complete on its bounded branch and + adds contribution-policy persistence; contribution-record, dispatcher, fulfillment, operations, and CON API behavior remain absent. - The pre-existing local deletion of the archival reference PDF is user-owned and excluded from this planning change. @@ -70,7 +73,8 @@ schema work. Current dependency analysis yields: ## Immediate next action -Finish PLAN5 canonical reconciliation and stop at its PR checkpoint. It changes -no runtime. After human merge approval, 03B remains the next proposed runtime -chunk; binding creation remains deferred to 04A after AUTH approves the exact -adapter identity/capability contract. +Publish 03B for external CI, CodeRabbit, and human review, then stop at its PR +checkpoint. Do not begin another chunk automatically. After 03B merges it +unblocks the stable policy-version FK required by REV-03A2; binding creation +remains deferred to 04A after AUTH approves the exact adapter +identity/capability contract. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-03B-contribution-policy-persistence.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-03B-contribution-policy-persistence.md index 4205f1cd7..b8262463f 100644 --- a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-03B-contribution-policy-persistence.md +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/chunks/WS-CON-001-03B-contribution-policy-persistence.md @@ -13,13 +13,17 @@ by REV `03A2` lease/policy-freeze persistence; it does not implement REV rows. ## Allowed files ```text -backend/app/modules/contributions/{models,schemas,repository}.py +backend/app/modules/contributions/{__init__,models,schemas,repository}.py backend/app/modules/projects/models.py only contribution-policy relationship backend/app/db/models.py backend/alembic/versions/_contribution_policy.py backend/tests/{test_contributions,test_projects,test_alembic}.py +backend/tests/conftest.py only canonical reset table inventory and schema fingerprint +.github/workflows/backend.yml only fixed contribution subsystem coverage gate +backend/scripts/run_test_lanes.py only assign test_contributions to shared_foundations +docs/spec_contribution_compensation.md only configured-unit and exact-numeric persistence clarification +docs/architecture_data_model.md only configured-unit and exact-numeric persistence clarification .agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/** -.agent-loop/merge-intents/WS-CON-001-03B.json ``` ## Not allowed @@ -32,18 +36,40 @@ public API, background executor, dependency or CI weakening ## Acceptance criteria -- [ ] One active ContributionPolicy per project points to one same-project +- [x] One active ContributionPolicy per project points to one same-project published immutable ContributionPolicyVersion. -- [ ] Every publishable version has exactly one accepted_submission and one - completed_review ContributionRule; each is unpaid or compensated. -- [ ] Unpaid rules have no definition. Compensated rules have at least one and +- [x] Every publishable version has exactly one accepted_submission and one + completed_review ContributionRule; each is unpaid or compensated. PostgreSQL + deferred validation enforces the complete graph before a published version + or active selector can commit; repository validation is not sufficient. +- [x] Unpaid rules have no definition. Compensated rules have at least one and at most two ContributionAwardDefinitions: at most one money and at most one - project_points definition, each with exact positive decimal/unit/binding/ - provenance constraints. -- [ ] Published/retired content is immutable; missing policy has no fallback. -- [ ] Legacy classification follows D10/CON-05 and rewrites no history. -- [ ] Upgrade/downgrade and selector/version races use isolated PostgreSQL. -- [ ] The migration is allocated from the then-current single head and exposes + project_points definition. Money units are uppercase project-enabled ISO 4217 + codes; project-points units have `(project_id, unit_code)` identity. Every + definition matches its rule's project, version, contribution type, and unit, + and its adapter binding matches project and instrument. +- [x] PostgreSQL rejects updates/deletes to published or retired versions and + their rules/definitions; missing policy has no fallback. Focused PostgreSQL + tests prove that child rows cannot rewrite published economic truth. +- [x] Composite PostgreSQL keys prove same-project policy/version/rule/ + definition lineage, expose stable `(version_id, project_id)` targets for + future REV freezes, and bind definitions to 03A ownership by + `(adapter_binding_id, project_id, instrument_type)`. +- [x] Quantities use `NUMERIC(38,18)` and closed Pydantic decimal-string + validation with identical bounds. Float inputs, leading plus signs, exponent + notation, non-finite values, zero, negatives, overflow, excess precision, + and rounding are rejected. +- [x] PostgreSQL stores the unrounded numeric input and applies the exact + `NUMERIC(38,18)` value envelope explicitly before persistence, avoiding + PostgreSQL typemod rounding; project-points quantities are whole numbers. +- [x] A migration-owned immutable ISO 4217 List One registry and project-owned + compensation-unit rows make money enablement and `(project_id, unit_code)` + points identity durable; definitions use a composite unit foreign key. +- [x] Legacy classification follows D10/CON-05 and rewrites no history. +- [x] Upgrade/downgrade and selector/version races use isolated PostgreSQL. +- [x] Isolated PostgreSQL proves concurrent active-selector and publishability + races cannot commit an invalid or mixed policy graph. +- [x] The migration is allocated from the then-current single head and exposes a stable owner contract for the later REV foreign key. ## Verification and reviewers diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-external-review-response.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-external-review-response.md new file mode 100644 index 000000000..1d16fb329 --- /dev/null +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-external-review-response.md @@ -0,0 +1,50 @@ +# External Review Response: WS-CON-001-03B + +## Comments addressed + +- GitHub `project_lifecycle` lane failed because the `Project` mapper named + `ContributionPolicy` in a reverse ORM relationship even when a project-only + process had not imported the contributions module. The resulting mapper + initialization failure cascaded into actor-registry 503 responses. +- Removed the unnecessary bidirectional ORM relationship. Database foreign + keys and composite same-project lineage remain authoritative and unchanged. +- CodeRabbit's four actionable findings were accepted: the chunk-map stop + boundary and contract doc scope are current, migration check-constraint names + now follow metadata conventions, and the contribution migration round trip no + longer runs from the shared lane. The canonical partitioned Alembic test + already proves the same 0055 head downgrade/upgrade path. +- Removed the redundant configured-unit unique index; its composite primary key + remains the award-definition FK target. + +## Comments deferred + +- CodeRabbit's whole-history graph-scan performance suggestion is deferred to + the later command/service chunk. PostgreSQL deferred constraint triggers are + necessarily row-level, and 03B intentionally favors a complete fail-closed + validation before scale behavior exists. +- The ISO snapshot remains duplicated deliberately because migrations must be + immutable and self-contained; a parity test prevents application precheck + drift. Defensive Decimal post-regex checks remain explicit and harmless. +- Additional policy-input unit cases are optional; the closed validators are + covered by the 95% subsystem gate and current negative tests. + +## Human decisions needed + +- None beyond normal PR review and merge approval. + +## Commands rerun + +```text +project-only configure_mappers probe: passed +isolated test_projects.py::test_active_guide_lookup_surfaces_duplicate_rows: passed +semantic-lane integrity: 33 passed +focused isolated contribution PostgreSQL suite: 44 passed +canonical Alembic head upgrade/downgrade: 1 passed +contribution subsystem coverage: 94.89% +Ruff: passed +git diff --check: passed +``` + +## Remaining risks + +GitHub CI and CodeRabbit must complete on the repaired commit. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-internal-review-evidence.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-internal-review-evidence.md new file mode 100644 index 000000000..35940a51d --- /dev/null +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-internal-review-evidence.md @@ -0,0 +1,51 @@ +# Internal Review Evidence: WS-CON-001-03B + +## Scope + +03B adds schema-only contribution-policy persistence: policy/version/rule/ +award-definition lineage, project-configured compensation units, an immutable +migration-owned ISO 4217 registry, exact quantity validation, and PostgreSQL +guards. It adds no service, route, claim, award result, adapter execution, +AUTH/ART behavior, or public API. + +Reconciled main baseline: `9550b3cdb8ac12dea72931277cf46ac1b8ffda7f`. +The user-owned archival PDF deletion is excluded from this chunk. + +## Deterministic Evidence + +```text +44 focused isolated PostgreSQL tests passed +1 canonical Alembic head upgrade/downgrade test passed +33 semantic-lane integrity tests passed +contributions module coverage: 94.89% (required at least 90%) +Ruff: passed +git diff --check: passed +Markdown links: passed +stale Workstream/review/authorization/artifact scans: passed +Alembic head: 0055_contribution_policy +``` + +The PostgreSQL proof covers complete and incomplete policy graphs, configured +unit provenance, exact decimal boundaries without typemod rounding, whole +project points, published/retired immutability, reparent rejection, truncate +rejection, active-selector uniqueness, and publish/child-mutation races. + +## Review Results + +| Track | Result | Resolution | +|---|---|---| +| Senior engineering | PASS | Null ISO bypass closed; reparent and migration concerns resolved. | +| QA/test | PASS | Independent graph, numeric, immutability, unit, and race proof added. | +| Security | PASS | Truncate bypass closed across every economic/provenance table. | +| Product/ops | PASS | Project-configured ISO/points semantics align with the canonical contract. | +| Architecture | PASS | Schema-only boundary and future REV FK remain stable. | +| Docs | PASS | Canonical spec, data model detail, and entity overview align. | +| Reuse/dedup | PASS | Shared compensation instrument enum reused. | +| Test delta | PASS | No removed, skipped, weakened, or masked coverage remains. | +| CI integrity | PASS | Fixed 90% hosted coverage gate and exact shared-foundations lane assignment added. | + +## Deferred Boundary + +Authorized policy mutation belongs to 04B. Contribution records and awards +remain in 03C after REV supplies stable Review, ReviewLease, and +FinalAcceptance targets. No next chunk starts automatically. diff --git a/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-pr-trust-bundle.md b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-pr-trust-bundle.md new file mode 100644 index 000000000..f25c5927f --- /dev/null +++ b/.agent-loop/initiatives/WS-CON-001-contribution-compensation-boundary/reviews/WS-CON-001-03B-pr-trust-bundle.md @@ -0,0 +1,51 @@ +# PR Trust Bundle: WS-CON-001-03B + +## Outcome + +Adds migration `0055_contribution_policy` and the SQLAlchemy/Pydantic +foundation for project-scoped contribution policies and immutable published +economic rules. + +## Design And Boundaries + +- One active policy per project selects one same-policy published version. +- Deferred PostgreSQL validation requires exactly the submission and review + rules and the correct unpaid/compensated definition graph. +- Composite keys preserve same-project lineage through policy, version, rule, + configured unit, award definition, and the 03A adapter binding. +- Money uses project-enabled current ISO 4217 codes; project points use a + project-scoped unit and whole-number quantities. +- Exact numeric input is stored without PostgreSQL typemod rounding and checked + against the 20-integer/18-fractional-digit envelope. +- Published/retired content, ISO registry rows, unit rows, and table contents + reject unauthorized mutation, deletion, reparenting, and truncation. +- No command/service/API/AUTH/ART/REV behavior is introduced. + +## Verification + +- 44 focused isolated PostgreSQL tests passed, including the active-selector + negative regression. +- Canonical head upgrade/downgrade passed. +- Contribution subsystem coverage is 94.89%. +- Hosted CI enforces at least 90% contribution-subsystem coverage. +- The contribution test module is assigned exactly once to the + `shared_foundations` semantic lane; lane-integrity tests pass. +- Ruff, diff hygiene, Markdown links, and all applicable stale scans pass. +- Required internal reviewers pass after their valid findings were repaired. + +## Human Review Focus + +1. Confirm 0055 remains a linear child of AUTH 0054 on the reconciled main head. +2. Confirm deferred graph validation and child locking prevent mixed published + economic truth. +3. Confirm money/points unit provenance and exact numeric constraints match the + canonical specification. +4. Confirm 03B remains schema-only and does not claim AUTH, ART, or REV behavior. + +## Remaining External Gate + +The first hosted project-lifecycle run exposed an import-order failure from an +unnecessary reverse ORM relationship. That relationship was removed without +changing database lineage, and the exact formerly failing project test passes +in isolation. GitHub CI and CodeRabbit must pass on the repaired exact head. +Human merge is required, and no subsequent chunk begins automatically. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 75a92a78f..edcec4423 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -357,6 +357,10 @@ jobs: working-directory: backend run: coverage report --include='app/modules/audit/*' --precision=2 --fail-under=90 + - name: Contribution subsystem coverage + working-directory: backend + run: coverage report --include='app/modules/contributions/*' --precision=2 --fail-under=90 + - name: API router composition coverage working-directory: backend run: coverage report --include='app/api/router.py' --precision=2 --fail-under=90 diff --git a/backend/alembic/versions/0055_contribution_policy.py b/backend/alembic/versions/0055_contribution_policy.py new file mode 100644 index 000000000..49a481bc1 --- /dev/null +++ b/backend/alembic/versions/0055_contribution_policy.py @@ -0,0 +1,627 @@ +"""add contribution-policy persistence + +Revision ID: 0055_contribution_policy +Revises: 0054_guide_sufficiency_authority +Create Date: 2026-08-04 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0055_contribution_policy" +down_revision = "0054_guide_sufficiency_authority" +branch_labels = depends_on = None + +# SIX is the ISO 4217 Maintenance Agency. Snapshot: List One, 2026-08-04. +ISO_4217_CURRENCY_CODES = tuple( + """AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BHD BIF BMD BND BOB BOV + BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF CLP CNY COP COU CRC CUP CVE + CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD + HKD HNL HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW + KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU + MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR + PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD SSP STN + SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD USN UYI UYU + UYW UZS VED VES VND VUV WST XAD XAF XAG XAU XBA XBB XBC XBD XCD XCG XDR + XOF XPD XPF XPT XSU XTS XUA XXX YER ZAR ZMW ZWG""".split() +) + + +def upgrade() -> None: + op.create_table( + "contribution_policies", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("status", sa.String(16), server_default="draft", nullable=False), + sa.Column("current_published_version_id", sa.Uuid()), + sa.Column("created_by", sa.String(36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("statement_timestamp()"), + nullable=False, + ), + sa.Column("retired_by", sa.String(36)), + sa.Column("retired_at", sa.DateTime(timezone=True)), + sa.CheckConstraint( + "status in ('draft','active','retired')", + name="status", + ), + sa.CheckConstraint( + "char_length(btrim(name)) between 1 and 200", + name="name", + ), + sa.CheckConstraint( + "(status='draft' and current_published_version_id is null " + "and retired_by is null and retired_at is null) or " + "(status='active' and current_published_version_id is not null " + "and retired_by is null and retired_at is null) or " + "(status='retired' and current_published_version_id is not null " + "and retired_by is not null and retired_at is not null)", + name="lifecycle_shape", + ), + sa.CheckConstraint( + "retired_at is null or retired_at >= created_at", + name="retirement_timestamp", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="fk_contribution_policy_project" + ), + sa.ForeignKeyConstraint( + ["created_by"], ["actor_profiles.id"], name="fk_contribution_policy_created_by" + ), + sa.ForeignKeyConstraint( + ["retired_by"], ["actor_profiles.id"], name="fk_contribution_policy_retired_by" + ), + sa.PrimaryKeyConstraint("id", name="pk_contribution_policies"), + sa.UniqueConstraint("id", "project_id", name="uq_contribution_policy_ownership"), + ) + op.create_index( + "uq_contribution_policy_active_project", + "contribution_policies", + ["project_id"], + unique=True, + postgresql_where=sa.text("status='active'"), + ) + + op.create_table( + "contribution_policy_versions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("contribution_policy_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("version_number", sa.Integer(), nullable=False), + sa.Column("status", sa.String(16), server_default="draft", nullable=False), + sa.Column("created_by", sa.String(36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("statement_timestamp()"), + nullable=False, + ), + sa.Column("published_by", sa.String(36)), + sa.Column("published_at", sa.DateTime(timezone=True)), + sa.Column("retired_by", sa.String(36)), + sa.Column("retired_at", sa.DateTime(timezone=True)), + sa.CheckConstraint( + "version_number > 0", + name="version_number_positive", + ), + sa.CheckConstraint( + "status in ('draft','published','retired')", + name="status", + ), + sa.CheckConstraint( + "(status='draft' and published_by is null and published_at is null " + "and retired_by is null and retired_at is null) or " + "(status='published' and published_by is not null and published_at is not null " + "and retired_by is null and retired_at is null) or " + "(status='retired' and published_by is not null and published_at is not null " + "and retired_by is not null and retired_at is not null)", + name="lifecycle_shape", + ), + sa.CheckConstraint( + "(published_at is null or published_at >= created_at) and " + "(retired_at is null or retired_at >= published_at)", + name="lifecycle_timestamps", + ), + sa.ForeignKeyConstraint( + ["contribution_policy_id", "project_id"], + ["contribution_policies.id", "contribution_policies.project_id"], + name="fk_contribution_policy_version_policy", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="fk_contribution_policy_version_project" + ), + sa.ForeignKeyConstraint( + ["created_by"], + ["actor_profiles.id"], + name="fk_contribution_policy_version_created_by", + ), + sa.ForeignKeyConstraint( + ["published_by"], + ["actor_profiles.id"], + name="fk_contribution_policy_version_published_by", + ), + sa.ForeignKeyConstraint( + ["retired_by"], + ["actor_profiles.id"], + name="fk_contribution_policy_version_retired_by", + ), + sa.PrimaryKeyConstraint("id", name="pk_contribution_policy_versions"), + sa.UniqueConstraint( + "id", "project_id", name="uq_contribution_policy_version_project" + ), + sa.UniqueConstraint( + "id", + "contribution_policy_id", + "project_id", + name="uq_contribution_policy_version_ownership", + ), + sa.UniqueConstraint( + "contribution_policy_id", + "version_number", + name="uq_contribution_policy_version_number", + ), + ) + op.create_foreign_key( + "fk_contribution_policy_current_version", + "contribution_policies", + "contribution_policy_versions", + ["current_published_version_id", "id", "project_id"], + ["id", "contribution_policy_id", "project_id"], + deferrable=True, + initially="DEFERRED", + ) + + op.create_table( + "contribution_rules", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("contribution_policy_version_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("contribution_type", sa.String(32), nullable=False), + sa.Column("compensation_mode", sa.String(16), nullable=False), + sa.CheckConstraint( + "contribution_type in ('accepted_submission','completed_review')", + name="contribution_type", + ), + sa.CheckConstraint( + "compensation_mode in ('unpaid','compensated')", + name="compensation_mode", + ), + sa.ForeignKeyConstraint( + ["contribution_policy_version_id", "project_id"], + ["contribution_policy_versions.id", "contribution_policy_versions.project_id"], + name="fk_contribution_rule_version", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="fk_contribution_rule_project" + ), + sa.PrimaryKeyConstraint("id", name="pk_contribution_rules"), + sa.UniqueConstraint( + "id", + "contribution_policy_version_id", + "project_id", + "contribution_type", + name="uq_contribution_rule_ownership", + ), + sa.UniqueConstraint( + "contribution_policy_version_id", + "contribution_type", + name="uq_contribution_rule_type", + ), + ) + + iso_currency_codes = op.create_table( + "iso_4217_currency_codes", + sa.Column("code", sa.String(3), nullable=False), + sa.CheckConstraint( + "code ~ '^[A-Z]{3}$'", name="code" + ), + sa.PrimaryKeyConstraint("code", name="pk_iso_4217_currency_codes"), + ) + op.bulk_insert( + iso_currency_codes, + [{"code": code} for code in ISO_4217_CURRENCY_CODES], + ) + op.create_table( + "project_compensation_units", + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("instrument_type", sa.String(32), nullable=False), + sa.Column("unit_code", sa.String(32), nullable=False), + sa.Column("iso_currency_code", sa.String(3)), + sa.Column("status", sa.String(16), server_default="active", nullable=False), + sa.Column("created_by", sa.String(36), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("statement_timestamp()"), + nullable=False, + ), + sa.Column("retired_by", sa.String(36)), + sa.Column("retired_at", sa.DateTime(timezone=True)), + sa.CheckConstraint( + "instrument_type in ('money','project_points')", + name="instrument_type", + ), + sa.CheckConstraint( + "status in ('active','retired')", + name="status", + ), + sa.CheckConstraint( + "(instrument_type='money' and iso_currency_code is not null " + "and unit_code=iso_currency_code) or " + "(instrument_type='project_points' and iso_currency_code is null " + "and unit_code ~ '^[A-Za-z][A-Za-z0-9._:-]{0,31}$')", + name="unit_identity", + ), + sa.CheckConstraint( + "(status='active' and retired_by is null and retired_at is null) or " + "(status='retired' and retired_by is not null and retired_at is not null)", + name="lifecycle_shape", + ), + sa.CheckConstraint( + "retired_at is null or retired_at >= created_at", + name="retirement_time", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="fk_project_compensation_unit_project" + ), + sa.ForeignKeyConstraint( + ["iso_currency_code"], + ["iso_4217_currency_codes.code"], + name="fk_project_compensation_unit_iso_currency", + ), + sa.ForeignKeyConstraint( + ["created_by"], + ["actor_profiles.id"], + name="fk_project_compensation_unit_created_by", + ), + sa.ForeignKeyConstraint( + ["retired_by"], + ["actor_profiles.id"], + name="fk_project_compensation_unit_retired_by", + ), + sa.PrimaryKeyConstraint( + "project_id", + "instrument_type", + "unit_code", + name="pk_project_compensation_units", + ), + ) + + op.create_table( + "contribution_award_definitions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("contribution_rule_id", sa.Uuid(), nullable=False), + sa.Column("contribution_policy_version_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("contribution_type", sa.String(32), nullable=False), + sa.Column("instrument_type", sa.String(32), nullable=False), + sa.Column("unit_code", sa.String(32), nullable=False), + sa.Column("quantity", sa.Numeric(), nullable=False), + sa.Column("adapter_binding_id", sa.Uuid(), nullable=False), + sa.CheckConstraint( + "contribution_type in ('accepted_submission','completed_review')", + name="contribution_type", + ), + sa.CheckConstraint( + "instrument_type in ('money','project_points')", + name="instrument_type", + ), + sa.CheckConstraint( + "quantity > 0 and quantity < 100000000000000000000 " + "and scale(quantity) between 0 and 18", + name="quantity_exact_bounds", + ), + sa.CheckConstraint( + "instrument_type <> 'project_points' or scale(quantity)=0", + name="project_points_whole", + ), + sa.ForeignKeyConstraint( + [ + "contribution_rule_id", + "contribution_policy_version_id", + "project_id", + "contribution_type", + ], + [ + "contribution_rules.id", + "contribution_rules.contribution_policy_version_id", + "contribution_rules.project_id", + "contribution_rules.contribution_type", + ], + name="fk_contribution_award_definition_rule", + ), + sa.ForeignKeyConstraint( + ["adapter_binding_id", "project_id", "instrument_type"], + [ + "project_compensation_adapter_bindings.id", + "project_compensation_adapter_bindings.project_id", + "project_compensation_adapter_bindings.instrument_type", + ], + name="fk_contribution_award_definition_binding", + ), + sa.ForeignKeyConstraint( + ["project_id", "instrument_type", "unit_code"], + [ + "project_compensation_units.project_id", + "project_compensation_units.instrument_type", + "project_compensation_units.unit_code", + ], + name="fk_contribution_award_definition_unit", + ), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + name="fk_contribution_award_definition_project", + ), + sa.PrimaryKeyConstraint("id", name="pk_contribution_award_definitions"), + sa.UniqueConstraint( + "contribution_rule_id", + "instrument_type", + name="uq_contribution_award_definition_instrument", + ), + ) + + op.execute( + """ + create function guard_contribution_policy_version_content() returns trigger + language plpgsql as $$ + begin + if tg_op='DELETE' and old.status in ('published','retired') then + raise exception 'published contribution policy versions are immutable' + using errcode='55000'; + end if; + if tg_op='UPDATE' and old.status='retired' then + raise exception 'retired contribution policy versions are immutable' + using errcode='55000'; + end if; + if tg_op='UPDATE' and old.status='published' and not ( + new.status='retired' + and new.id=old.id + and new.contribution_policy_id=old.contribution_policy_id + and new.project_id=old.project_id + and new.version_number=old.version_number + and new.created_by=old.created_by + and new.created_at=old.created_at + and new.published_by=old.published_by + and new.published_at=old.published_at + and new.retired_by is not null + and new.retired_at is not null + ) then + raise exception 'published contribution policy version content is immutable' + using errcode='55000'; + end if; + return case when tg_op='DELETE' then old else new end; + end; + $$; + """ + ) + op.execute( + "create trigger contribution_policy_versions_content_guard " + "before update or delete on contribution_policy_versions for each row " + "execute function guard_contribution_policy_version_content()" + ) + op.execute( + """ + create function guard_iso_4217_currency_codes() returns trigger + language plpgsql as $$ + begin + raise exception 'ISO 4217 currency-code registry is migration-owned and immutable' + using errcode='55000'; + end; + $$; + """ + ) + op.execute( + "create trigger iso_4217_currency_codes_immutable " + "before insert or update or delete on iso_4217_currency_codes for each row " + "execute function guard_iso_4217_currency_codes()" + ) + op.execute( + """ + create function guard_project_compensation_units() returns trigger + language plpgsql as $$ + begin + if tg_op in ('UPDATE','DELETE') then + raise exception 'project compensation-unit lifecycle behavior is deferred' + using errcode='55000'; + end if; + if new.status <> 'active' then + raise exception 'project compensation units must begin active' + using errcode='23514'; + end if; + return new; + end; + $$; + """ + ) + op.execute( + "create trigger project_compensation_units_lifecycle_guard " + "before insert or update or delete on project_compensation_units for each row " + "execute function guard_project_compensation_units()" + ) + op.execute( + """ + create function guard_contribution_policy_children() returns trigger + language plpgsql as $$ + declare old_parent_status text; + declare new_parent_status text; + begin + if tg_op in ('UPDATE','DELETE') then + select status into old_parent_status from contribution_policy_versions + where id=old.contribution_policy_version_id for update; + end if; + if tg_op in ('INSERT','UPDATE') then + select status into new_parent_status from contribution_policy_versions + where id=new.contribution_policy_version_id for update; + end if; + if old_parent_status in ('published','retired') + or new_parent_status in ('published','retired') then + raise exception 'published contribution policy rules and definitions are immutable' + using errcode='55000'; + end if; + return case when tg_op='DELETE' then old else new end; + end; + $$; + """ + ) + for table in ("contribution_rules", "contribution_award_definitions"): + op.execute( + f"create trigger {table}_content_guard before insert or update or delete on {table} " + "for each row execute function guard_contribution_policy_children()" + ) + + op.execute( + """ + create function validate_contribution_policy_graph() returns trigger + language plpgsql as $$ + begin + if exists ( + select 1 from contribution_policy_versions v + where v.status in ('published','retired') and ( + (select count(*) from contribution_rules r + where r.contribution_policy_version_id=v.id + and r.contribution_type='accepted_submission') <> 1 + or + (select count(*) from contribution_rules r + where r.contribution_policy_version_id=v.id + and r.contribution_type='completed_review') <> 1 + or exists ( + select 1 from contribution_rules r + where r.contribution_policy_version_id=v.id and ( + (r.compensation_mode='unpaid' and + (select count(*) from contribution_award_definitions d + where d.contribution_rule_id=r.id) <> 0) + or + (r.compensation_mode='compensated' and + (select count(*) from contribution_award_definitions d + where d.contribution_rule_id=r.id) not between 1 and 2) + ) + ) + ) + ) then + raise exception 'published contribution policy graph is incomplete' + using errcode='23514'; + end if; + + if exists ( + select 1 from contribution_policies p + left join contribution_policy_versions v + on v.id=p.current_published_version_id + and v.contribution_policy_id=p.id + and v.project_id=p.project_id + where p.status='active' and (v.id is null or v.status <> 'published') + ) then + raise exception 'active contribution policy selector is invalid' + using errcode='23514'; + end if; + return null; + end; + $$; + """ + ) + for table in ( + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "contribution_award_definitions", + ): + op.execute( + f"create constraint trigger {table}_graph_guard " + f"after insert or update or delete on {table} deferrable initially deferred " + "for each row execute function validate_contribution_policy_graph()" + ) + op.execute( + """ + create function reject_contribution_policy_truncate() returns trigger + language plpgsql as $$ + begin + raise exception 'contribution policy persistence cannot be truncated' + using errcode='55000'; + end; + $$; + """ + ) + for table in ( + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "contribution_award_definitions", + "project_compensation_units", + "iso_4217_currency_codes", + ): + op.execute( + f"create trigger {table}_reject_truncate before truncate on {table} " + "execute function reject_contribution_policy_truncate()" + ) + + +def downgrade() -> None: + bind = op.get_bind() + populated = sum( + bind.execute(sa.text(f"select count(*) from {table}")).scalar_one() + for table in ( + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "contribution_award_definitions", + "project_compensation_units", + ) + ) + if populated: + raise RuntimeError("cannot downgrade populated contribution policy persistence") + + for table in ( + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "contribution_award_definitions", + "project_compensation_units", + "iso_4217_currency_codes", + ): + op.execute(f"drop trigger {table}_reject_truncate on {table}") + op.execute("drop function reject_contribution_policy_truncate()") + for table in ( + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "contribution_award_definitions", + ): + op.execute(f"drop trigger {table}_graph_guard on {table}") + op.execute("drop function validate_contribution_policy_graph()") + for table in ("contribution_award_definitions", "contribution_rules"): + op.execute(f"drop trigger {table}_content_guard on {table}") + op.execute("drop function guard_contribution_policy_children()") + op.execute( + "drop trigger project_compensation_units_lifecycle_guard " + "on project_compensation_units" + ) + op.execute("drop function guard_project_compensation_units()") + op.execute( + "drop trigger iso_4217_currency_codes_immutable on iso_4217_currency_codes" + ) + op.execute("drop function guard_iso_4217_currency_codes()") + op.execute( + "drop trigger contribution_policy_versions_content_guard " + "on contribution_policy_versions" + ) + op.execute("drop function guard_contribution_policy_version_content()") + op.drop_table("contribution_award_definitions") + op.drop_table("project_compensation_units") + op.drop_table("iso_4217_currency_codes") + op.drop_table("contribution_rules") + op.drop_constraint( + "fk_contribution_policy_current_version", + "contribution_policies", + type_="foreignkey", + ) + op.drop_table("contribution_policy_versions") + op.drop_index( + "uq_contribution_policy_active_project", table_name="contribution_policies" + ) + op.drop_table("contribution_policies") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 8bca63771..d217f4e69 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -30,6 +30,14 @@ ) from app.modules.checkers.models import CheckerResult, CheckerRun # noqa: F401 from app.modules.compensation.models import ProjectCompensationAdapterBinding # noqa: F401 +from app.modules.contributions.models import ( # noqa: F401 + ContributionAwardDefinition, + ContributionPolicy, + ContributionPolicyVersion, + ContributionRule, + Iso4217CurrencyCode, + ProjectCompensationUnit, +) from app.modules.outbox.models import OutboxEvent # noqa: F401 from app.modules.projects.models import ( # noqa: F401 EffectiveProjectSubmissionArtifactPolicy, diff --git a/backend/app/modules/contributions/__init__.py b/backend/app/modules/contributions/__init__.py new file mode 100644 index 000000000..b4cb59ba6 --- /dev/null +++ b/backend/app/modules/contributions/__init__.py @@ -0,0 +1 @@ +"""Contribution-policy and contribution-record domain persistence.""" diff --git a/backend/app/modules/contributions/models.py b/backend/app/modules/contributions/models.py new file mode 100644 index 000000000..94176b857 --- /dev/null +++ b/backend/app/modules/contributions/models.py @@ -0,0 +1,348 @@ +"""SQLAlchemy persistence for contribution-policy economic truth.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + UniqueConstraint, + Uuid, + text, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class ContributionPolicy(Base): + """Stable project aggregate selecting one published policy version.""" + + __tablename__ = "contribution_policies" + __table_args__ = ( + UniqueConstraint("id", "project_id", name="uq_contribution_policy_ownership"), + ForeignKeyConstraint( + ["current_published_version_id", "id", "project_id"], + [ + "contribution_policy_versions.id", + "contribution_policy_versions.contribution_policy_id", + "contribution_policy_versions.project_id", + ], + name="fk_contribution_policy_current_version", + use_alter=True, + deferrable=True, + initially="DEFERRED", + ), + CheckConstraint("status in ('draft','active','retired')", name="status"), + CheckConstraint("char_length(btrim(name)) between 1 and 200", name="name"), + CheckConstraint( + "(status='draft' and current_published_version_id is null " + "and retired_by is null and retired_at is null) or " + "(status='active' and current_published_version_id is not null " + "and retired_by is null and retired_at is null) or " + "(status='retired' and current_published_version_id is not null " + "and retired_by is not null and retired_at is not null)", + name="lifecycle_shape", + ), + CheckConstraint( + "retired_at is null or retired_at >= created_at", + name="retirement_timestamp", + ), + Index( + "uq_contribution_policy_active_project", + "project_id", + unique=True, + postgresql_where=text("status='active'"), + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_contribution_policy_project"), nullable=False + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'draft'")) + current_published_version_id: Mapped[UUID | None] = mapped_column(Uuid()) + created_by: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_contribution_policy_created_by"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("statement_timestamp()") + ) + retired_by: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_contribution_policy_retired_by") + ) + retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + versions: Mapped[list["ContributionPolicyVersion"]] = relationship( + back_populates="policy", + foreign_keys="ContributionPolicyVersion.contribution_policy_id", + ) + + +class ContributionPolicyVersion(Base): + """Versioned contribution rules; published economic content is immutable.""" + + __tablename__ = "contribution_policy_versions" + __table_args__ = ( + ForeignKeyConstraint( + ["contribution_policy_id", "project_id"], + ["contribution_policies.id", "contribution_policies.project_id"], + name="fk_contribution_policy_version_policy", + ), + UniqueConstraint( + "id", "project_id", name="uq_contribution_policy_version_project" + ), + UniqueConstraint( + "id", + "contribution_policy_id", + "project_id", + name="uq_contribution_policy_version_ownership", + ), + UniqueConstraint( + "contribution_policy_id", + "version_number", + name="uq_contribution_policy_version_number", + ), + CheckConstraint("version_number > 0", name="version_number_positive"), + CheckConstraint("status in ('draft','published','retired')", name="status"), + CheckConstraint( + "(status='draft' and published_by is null and published_at is null " + "and retired_by is null and retired_at is null) or " + "(status='published' and published_by is not null and published_at is not null " + "and retired_by is null and retired_at is null) or " + "(status='retired' and published_by is not null and published_at is not null " + "and retired_by is not null and retired_at is not null)", + name="lifecycle_shape", + ), + CheckConstraint( + "(published_at is null or published_at >= created_at) and " + "(retired_at is null or retired_at >= published_at)", + name="lifecycle_timestamps", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + contribution_policy_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_contribution_policy_version_project"), + nullable=False, + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'draft'")) + created_by: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_contribution_policy_version_created_by"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("statement_timestamp()") + ) + published_by: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_contribution_policy_version_published_by") + ) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + retired_by: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_contribution_policy_version_retired_by") + ) + retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + policy: Mapped[ContributionPolicy] = relationship( + back_populates="versions", + foreign_keys=[contribution_policy_id], + ) + rules: Mapped[list["ContributionRule"]] = relationship(back_populates="version") + + +class ContributionRule(Base): + """One explicit eligibility rule for a canonical contribution type.""" + + __tablename__ = "contribution_rules" + __table_args__ = ( + ForeignKeyConstraint( + ["contribution_policy_version_id", "project_id"], + ["contribution_policy_versions.id", "contribution_policy_versions.project_id"], + name="fk_contribution_rule_version", + ), + UniqueConstraint( + "id", + "contribution_policy_version_id", + "project_id", + "contribution_type", + name="uq_contribution_rule_ownership", + ), + UniqueConstraint( + "contribution_policy_version_id", + "contribution_type", + name="uq_contribution_rule_type", + ), + CheckConstraint( + "contribution_type in ('accepted_submission','completed_review')", + name="contribution_type", + ), + CheckConstraint( + "compensation_mode in ('unpaid','compensated')", + name="compensation_mode", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + contribution_policy_version_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_contribution_rule_project"), nullable=False + ) + contribution_type: Mapped[str] = mapped_column(String(32), nullable=False) + compensation_mode: Mapped[str] = mapped_column(String(16), nullable=False) + + version: Mapped[ContributionPolicyVersion] = relationship(back_populates="rules") + award_definitions: Mapped[list["ContributionAwardDefinition"]] = relationship( + back_populates="rule" + ) + + +class Iso4217CurrencyCode(Base): + """Migration-seeded immutable ISO 4217 List One alphabetic code.""" + + __tablename__ = "iso_4217_currency_codes" + __table_args__ = ( + CheckConstraint("code ~ '^[A-Z]{3}$'", name="code"), + ) + + code: Mapped[str] = mapped_column(String(3), primary_key=True) + + +class ProjectCompensationUnit(Base): + """Project-enabled money or project-scoped points unit identity.""" + + __tablename__ = "project_compensation_units" + __table_args__ = ( + ForeignKeyConstraint( + ["iso_currency_code"], + ["iso_4217_currency_codes.code"], + name="fk_project_compensation_unit_iso_currency", + ), + CheckConstraint( + "instrument_type in ('money','project_points')", name="instrument_type" + ), + CheckConstraint("status in ('active','retired')", name="status"), + CheckConstraint( + "(instrument_type='money' and iso_currency_code is not null " + "and unit_code=iso_currency_code) or " + "(instrument_type='project_points' and iso_currency_code is null " + "and unit_code ~ '^[A-Za-z][A-Za-z0-9._:-]{0,31}$')", + name="unit_identity", + ), + CheckConstraint( + "(status='active' and retired_by is null and retired_at is null) or " + "(status='retired' and retired_by is not null and retired_at is not null)", + name="lifecycle_shape", + ), + CheckConstraint("retired_at is null or retired_at >= created_at", name="retirement_time"), + ) + + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_project_compensation_unit_project"), + primary_key=True, + ) + instrument_type: Mapped[str] = mapped_column(String(32), primary_key=True) + unit_code: Mapped[str] = mapped_column(String(32), primary_key=True) + iso_currency_code: Mapped[str | None] = mapped_column(String(3)) + status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'")) + created_by: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_project_compensation_unit_created_by"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("statement_timestamp()") + ) + retired_by: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id", name="fk_project_compensation_unit_retired_by") + ) + retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ContributionAwardDefinition(Base): + """Exact compensation fact attached to one compensated contribution rule.""" + + __tablename__ = "contribution_award_definitions" + __table_args__ = ( + ForeignKeyConstraint( + [ + "contribution_rule_id", + "contribution_policy_version_id", + "project_id", + "contribution_type", + ], + [ + "contribution_rules.id", + "contribution_rules.contribution_policy_version_id", + "contribution_rules.project_id", + "contribution_rules.contribution_type", + ], + name="fk_contribution_award_definition_rule", + ), + ForeignKeyConstraint( + ["adapter_binding_id", "project_id", "instrument_type"], + [ + "project_compensation_adapter_bindings.id", + "project_compensation_adapter_bindings.project_id", + "project_compensation_adapter_bindings.instrument_type", + ], + name="fk_contribution_award_definition_binding", + ), + ForeignKeyConstraint( + ["project_id", "instrument_type", "unit_code"], + [ + "project_compensation_units.project_id", + "project_compensation_units.instrument_type", + "project_compensation_units.unit_code", + ], + name="fk_contribution_award_definition_unit", + ), + UniqueConstraint( + "contribution_rule_id", + "instrument_type", + name="uq_contribution_award_definition_instrument", + ), + CheckConstraint( + "contribution_type in ('accepted_submission','completed_review')", + name="contribution_type", + ), + CheckConstraint( + "instrument_type in ('money','project_points')", + name="instrument_type", + ), + CheckConstraint( + "quantity > 0 and quantity < 100000000000000000000 " + "and scale(quantity) between 0 and 18", + name="quantity_exact_bounds", + ), + CheckConstraint( + "instrument_type <> 'project_points' or scale(quantity)=0", + name="project_points_whole", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + contribution_rule_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + contribution_policy_version_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", name="fk_contribution_award_definition_project"), + nullable=False, + ) + contribution_type: Mapped[str] = mapped_column(String(32), nullable=False) + instrument_type: Mapped[str] = mapped_column(String(32), nullable=False) + unit_code: Mapped[str] = mapped_column(String(32), nullable=False) + quantity: Mapped[Decimal] = mapped_column(Numeric(), nullable=False) + adapter_binding_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + + rule: Mapped[ContributionRule] = relationship(back_populates="award_definitions") diff --git a/backend/app/modules/contributions/schemas.py b/backend/app/modules/contributions/schemas.py new file mode 100644 index 000000000..768b32625 --- /dev/null +++ b/backend/app/modules/contributions/schemas.py @@ -0,0 +1,171 @@ +"""Closed persistence inputs for contribution-policy economic truth.""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from enum import StrEnum +import re +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.modules.compensation.schemas import ( + CompensationInstrumentType as ContributionInstrumentType, +) + + +_QUANTITY_PATTERN = re.compile(r"^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$") +_PROJECT_ID_PATTERN = r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$" +_MONEY_UNIT_PATTERN = re.compile(r"^[A-Z]{3}$") +_POINTS_UNIT_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9._:-]{0,31}$") +# Mirrors the migration-owned SIX ISO 4217 List One snapshot dated 2026-08-04. +ISO_4217_CURRENCY_CODES = frozenset( + """AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BHD BIF BMD BND BOB BOV + BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF CLP CNY COP COU CRC CUP CVE + CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD + HKD HNL HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW + KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU + MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR + PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD SSP STN + SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD USN UYI UYU + UYW UZS VED VES VND VUV WST XAD XAF XAG XAU XBA XBB XBC XBD XCD XCG XDR + XOF XPD XPF XPT XSU XTS XUA XXX YER ZAR ZMW ZWG""".split() +) + + +class ContributionPolicyStatus(StrEnum): + DRAFT = "draft" + ACTIVE = "active" + RETIRED = "retired" + + +class ContributionPolicyVersionStatus(StrEnum): + DRAFT = "draft" + PUBLISHED = "published" + RETIRED = "retired" + + +class ContributionType(StrEnum): + ACCEPTED_SUBMISSION = "accepted_submission" + COMPLETED_REVIEW = "completed_review" + + +class CompensationMode(StrEnum): + UNPAID = "unpaid" + COMPENSATED = "compensated" + + +class ContributionPolicyInput(BaseModel): + """Structural policy aggregate facts; grants no publish authority.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: UUID + project_id: str = Field(pattern=_PROJECT_ID_PATTERN) + name: str = Field(min_length=1, max_length=200) + created_by: str = Field(pattern=_PROJECT_ID_PATTERN) + + @field_validator("name") + @classmethod + def reject_blank_name(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("name must not have surrounding whitespace") + return value + + +class ContributionPolicyVersionInput(BaseModel): + """Structural draft version identity; lifecycle behavior is deferred.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: UUID + contribution_policy_id: UUID + project_id: str = Field(pattern=_PROJECT_ID_PATTERN) + version_number: int = Field(gt=0) + created_by: str = Field(pattern=_PROJECT_ID_PATTERN) + + +class ContributionRuleInput(BaseModel): + """One closed contribution rule for a policy version.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: UUID + contribution_policy_version_id: UUID + project_id: str = Field(pattern=_PROJECT_ID_PATTERN) + contribution_type: ContributionType + compensation_mode: CompensationMode + + +class ProjectCompensationUnitInput(BaseModel): + """Structural project-unit facts; 03B exposes no lifecycle command.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + project_id: str = Field(pattern=_PROJECT_ID_PATTERN) + instrument_type: ContributionInstrumentType + unit_code: str = Field(min_length=1, max_length=32) + created_by: str = Field(pattern=_PROJECT_ID_PATTERN) + + @model_validator(mode="after") + def validate_registered_unit(self) -> "ProjectCompensationUnitInput": + if self.instrument_type is ContributionInstrumentType.MONEY: + if self.unit_code not in ISO_4217_CURRENCY_CODES: + raise ValueError("money unit_code must be a current ISO 4217 code") + elif _POINTS_UNIT_PATTERN.fullmatch(self.unit_code) is None: + raise ValueError("project_points unit_code is invalid") + return self + + +class ContributionAwardDefinitionInput(BaseModel): + """Canonical fixed-point award definition facts.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: UUID + contribution_rule_id: UUID + contribution_policy_version_id: UUID + project_id: str = Field(pattern=_PROJECT_ID_PATTERN) + contribution_type: ContributionType + instrument_type: ContributionInstrumentType + unit_code: str = Field(min_length=1, max_length=32) + quantity: str + adapter_binding_id: UUID + + @field_validator("quantity", mode="before") + @classmethod + def require_canonical_decimal_string(cls, value: object) -> str: + if not isinstance(value, str) or _QUANTITY_PATTERN.fullmatch(value) is None: + raise ValueError("quantity must be a canonical positive decimal string") + try: + quantity = Decimal(value) + except InvalidOperation as error: + raise ValueError("quantity must be a canonical positive decimal string") from error + if not quantity.is_finite() or quantity <= 0: + raise ValueError("quantity must be greater than zero") + return value + + @model_validator(mode="after") + def validate_unit_for_instrument(self) -> "ContributionAwardDefinitionInput": + pattern = ( + _MONEY_UNIT_PATTERN + if self.instrument_type is ContributionInstrumentType.MONEY + else _POINTS_UNIT_PATTERN + ) + if pattern.fullmatch(self.unit_code) is None: + raise ValueError("unit_code does not match instrument_type") + if ( + self.instrument_type is ContributionInstrumentType.MONEY + and self.unit_code not in ISO_4217_CURRENCY_CODES + ): + raise ValueError("money unit_code must be a current ISO 4217 code") + if ( + self.instrument_type is ContributionInstrumentType.PROJECT_POINTS + and Decimal(self.quantity).as_tuple().exponent < 0 + ): + raise ValueError("project_points quantity must be a whole-number string") + return self + + def quantity_decimal(self) -> Decimal: + """Return the already-validated exact database value without rounding.""" + return Decimal(self.quantity) diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index c2eeeef03..4996ab0a3 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -73,8 +73,6 @@ class Project(Base): back_populates="project", cascade="all, delete-orphan", ) - - class ProjectCreateIdempotencyRecord(Base): """Project-owned reservation and replay state for one project creation.""" diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 377443db2..d7bc72861 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -103,6 +103,7 @@ class TestLane: "tests/test_ci_test_lanes.py", "tests/test_config.py", "tests/test_compensation.py", + "tests/test_contributions.py", "tests/test_coverage_contract.py", "tests/test_external_service_adapters.py", "tests/test_guide_artifacts.py", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 683f318ab..3bb4cabce 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,10 +21,11 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "ae47899464f7635fbbc426f0f53abd005c3a0489edd3d18cfe6b1889c9eacd5c" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "e52ae0ebd83dbd1786fc2ea2dac25608a0c61f8b0f623cb0d19fa854c8db95c9" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", + "iso_4217_currency_codes", ) RESETTABLE_TEST_TABLES = ( "actor_identity_links", @@ -50,6 +51,10 @@ "checker_policies", "checker_results", "checker_runs", + "contribution_award_definitions", + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", "effective_project_submission_artifact_policies", "evidence_items", "guide_source_artifact_ingests", @@ -73,6 +78,7 @@ "policy_mutation_idempotency_records", "pre_submit_checker_policies", "project_compensation_adapter_bindings", + "project_compensation_units", "project_create_idempotency_records", "project_guides", "project_role_grants", @@ -98,6 +104,11 @@ "guide_source_snapshot_items", "outbox_events", "policy_mutation_idempotency_records", + "contribution_award_definitions", + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "project_compensation_units", "project_create_idempotency_records", "project_role_grants", "project_role_qualification_snapshots", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 366325f00..1ca886524 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0054_guide_sufficiency_authority" +HEAD_REVISION = "0055_contribution_policy" pytestmark = pytest.mark.postgres_schema_contract diff --git a/backend/tests/test_contributions.py b/backend/tests/test_contributions.py new file mode 100644 index 000000000..ac416686f --- /dev/null +++ b/backend/tests/test_contributions.py @@ -0,0 +1,960 @@ +"""Focused PostgreSQL proof for contribution-policy persistence.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from datetime import UTC, datetime +from decimal import Decimal +from uuid import UUID, uuid4 + +from pydantic import ValidationError +import pytest +from sqlalchemy import delete, text, update +from sqlalchemy.exc import DBAPIError, IntegrityError + +from app.core.config import get_settings +from app.db import session as db_session +from app.db.base import Base +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.compensation.models import ProjectCompensationAdapterBinding +from app.modules.contributions.models import ( + ContributionAwardDefinition, + ContributionPolicy, + ContributionPolicyVersion, + ContributionRule, + ProjectCompensationUnit, +) +from app.modules.contributions.schemas import ( + ContributionAwardDefinitionInput, + ISO_4217_CURRENCY_CODES, + ProjectCompensationUnitInput, +) +from project_create_fixtures import insert_historical_project + + +@pytest.fixture +def contribution_database_env( + monkeypatch: pytest.MonkeyPatch, + clean_postgres_database: str, +) -> Iterator[str]: + """Bind contribution tests to a runner-owned isolated PostgreSQL database.""" + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", clean_postgres_database) + get_settings.cache_clear() + try: + yield clean_postgres_database + finally: + get_settings.cache_clear() + + +async def _seed_project() -> tuple[str, str, str, UUID, UUID]: + project_id = str(uuid4()) + creator_id = str(uuid4()) + adapter_actor_id = str(uuid4()) + money_binding_id = uuid4() + points_binding_id = uuid4() + async with db_session.get_session_factory()() as session: + session.add_all( + [ + ActorProfile( + id=creator_id, + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=creator_id, + ), + ActorProfile( + id=adapter_actor_id, + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + created_by=creator_id, + ), + ] + ) + await session.flush() + session.add_all( + [ + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=creator_id, + issuer="https://contributions.test", + subject=f"creator-{creator_id}", + subject_kind="human", + status="active", + linked_by=creator_id, + last_verified_at=datetime.now(UTC), + ), + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=adapter_actor_id, + issuer="https://contributions.test", + subject=f"adapter-{adapter_actor_id}", + subject_kind="service", + status="active", + linked_by=creator_id, + ), + ] + ) + await insert_historical_project( + session, + project_id=project_id, + name="Contribution project", + slug=f"contribution-{project_id[:8]}", + ) + session.add_all( + [ + ProjectCompensationAdapterBinding( + id=money_binding_id, + project_id=project_id, + instrument_type="money", + adapter_actor_id=adapter_actor_id, + route_key="contribution.money", + status="active", + binding_lifecycle_version=1, + created_by=creator_id, + ), + ProjectCompensationAdapterBinding( + id=points_binding_id, + project_id=project_id, + instrument_type="project_points", + adapter_actor_id=adapter_actor_id, + route_key="contribution.points", + status="active", + binding_lifecycle_version=1, + created_by=creator_id, + ), + ] + ) + session.add_all( + [ + ProjectCompensationUnit( + project_id=project_id, + instrument_type="money", + unit_code="USD", + iso_currency_code="USD", + status="active", + created_by=creator_id, + ), + ProjectCompensationUnit( + project_id=project_id, + instrument_type="project_points", + unit_code="merit.points", + status="active", + created_by=creator_id, + ), + ] + ) + await session.commit() + return project_id, creator_id, adapter_actor_id, money_binding_id, points_binding_id + + +async def _draft_policy( + project_id: str, + creator_id: str, + *, + policy_id: UUID | None = None, +) -> tuple[UUID, UUID]: + policy_id = policy_id or uuid4() + version_id = uuid4() + async with db_session.get_session_factory()() as session: + session.add( + ContributionPolicy( + id=policy_id, + project_id=project_id, + name=f"Policy {policy_id}", + status="draft", + created_by=creator_id, + ) + ) + await session.flush() + session.add( + ContributionPolicyVersion( + id=version_id, + contribution_policy_id=policy_id, + project_id=project_id, + version_number=1, + status="draft", + created_by=creator_id, + ) + ) + await session.commit() + return policy_id, version_id + + +async def _add_rule( + version_id: UUID, + project_id: str, + contribution_type: str, + compensation_mode: str, + *, + binding_id: UUID | None = None, +) -> UUID: + rule_id = uuid4() + async with db_session.get_session_factory()() as session: + session.add( + ContributionRule( + id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type=contribution_type, + compensation_mode=compensation_mode, + ) + ) + if binding_id is not None: + session.add( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type=contribution_type, + instrument_type="money", + unit_code="USD", + quantity="25.500000000000000000", + adapter_binding_id=binding_id, + ) + ) + await session.commit() + return rule_id + + +async def _publish_version(version_id: UUID, creator_id: str) -> None: + async with db_session.get_session_factory()() as session: + await session.execute( + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == version_id) + .values(status="published", published_by=creator_id, published_at=datetime.now(UTC)) + ) + await session.commit() + + +async def _complete_published_policy( + project_id: str, + creator_id: str, + binding_id: UUID, +) -> tuple[UUID, UUID, UUID]: + policy_id, version_id = await _draft_policy(project_id, creator_id) + unpaid_rule_id = await _add_rule( + version_id, project_id, "accepted_submission", "unpaid" + ) + await _add_rule( + version_id, + project_id, + "completed_review", + "compensated", + binding_id=binding_id, + ) + await _publish_version(version_id, creator_id) + return policy_id, version_id, unpaid_rule_id + + +def test_contribution_models_register_closed_canonical_tables() -> None: + assert set(Base.metadata.tables["contribution_policies"].columns.keys()) == { + "id", + "project_id", + "name", + "status", + "current_published_version_id", + "created_by", + "created_at", + "retired_by", + "retired_at", + } + assert set(Base.metadata.tables["contribution_policy_versions"].columns.keys()) == { + "id", + "contribution_policy_id", + "project_id", + "version_number", + "status", + "created_by", + "created_at", + "published_by", + "published_at", + "retired_by", + "retired_at", + } + assert { + "iso_4217_currency_codes", + "project_compensation_units", + } <= set(Base.metadata.tables) + + +@pytest.mark.parametrize( + "quantity", + ( + 1, + 1.5, + Decimal("1"), + "", + "+1", + "-1", + "0", + "0.0", + "01", + ".5", + "1.", + "1e2", + "NaN", + "Infinity", + "100000000000000000000", + "1.0000000000000000000", + ), +) +def test_award_definition_input_rejects_noncanonical_quantity(quantity: object) -> None: + with pytest.raises(ValidationError): + ContributionAwardDefinitionInput( + id=uuid4(), + contribution_rule_id=uuid4(), + contribution_policy_version_id=uuid4(), + project_id=str(uuid4()), + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity=quantity, + adapter_binding_id=uuid4(), + ) + + +@pytest.mark.parametrize( + ("instrument", "unit"), + (("money", "usd"), ("money", "USDX"), ("project_points", "bad unit")), +) +def test_award_definition_input_rejects_wrong_unit_shape(instrument: str, unit: str) -> None: + with pytest.raises(ValidationError): + ContributionAwardDefinitionInput( + id=uuid4(), + contribution_rule_id=uuid4(), + contribution_policy_version_id=uuid4(), + project_id=str(uuid4()), + contribution_type="accepted_submission", + instrument_type=instrument, + unit_code=unit, + quantity="1.25", + adapter_binding_id=uuid4(), + ) + + +def test_award_definition_input_returns_exact_decimal() -> None: + value = ContributionAwardDefinitionInput( + id=uuid4(), + contribution_rule_id=uuid4(), + contribution_policy_version_id=uuid4(), + project_id=str(uuid4()), + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity="1.230000000000000000", + adapter_binding_id=uuid4(), + ) + assert value.quantity_decimal() == Decimal("1.230000000000000000") + + +def test_project_compensation_unit_input_rejects_unknown_currency() -> None: + with pytest.raises(ValidationError): + ProjectCompensationUnitInput( + project_id=str(uuid4()), + instrument_type="money", + unit_code="ZZZ", + created_by=str(uuid4()), + ) + + +@pytest.mark.parametrize("quantity", ("1.0", "1.5")) +def test_award_definition_input_rejects_fractional_project_points(quantity: str) -> None: + with pytest.raises(ValidationError): + ContributionAwardDefinitionInput( + id=uuid4(), + contribution_rule_id=uuid4(), + contribution_policy_version_id=uuid4(), + project_id=str(uuid4()), + contribution_type="accepted_submission", + instrument_type="project_points", + unit_code="merit.points", + quantity=quantity, + adapter_binding_id=uuid4(), + ) + + +@pytest.mark.asyncio +async def test_database_iso_registry_matches_schema_precheck( + contribution_database_env: str, +) -> None: + async with db_session.get_session_factory()() as session: + codes = set(await session.scalars(text("select code from iso_4217_currency_codes"))) + assert codes == ISO_4217_CURRENCY_CODES + + +@pytest.mark.asyncio +async def test_complete_policy_graph_can_publish_and_become_active( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + policy_id, version_id, _ = await _complete_published_policy( + project_id, creator_id, money_binding_id + ) + async with db_session.get_session_factory()() as session: + await session.execute( + update(ContributionPolicy) + .where(ContributionPolicy.id == policy_id) + .values(status="active", current_published_version_id=version_id) + ) + await session.commit() + async with db_session.get_session_factory()() as session: + policy = await session.get(ContributionPolicy, policy_id) + assert policy is not None + assert (policy.status, policy.current_published_version_id) == ("active", version_id) + + +@pytest.mark.asyncio +async def test_active_policy_cannot_select_a_draft_version( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, _, _ = await _seed_project() + policy_id, version_id = await _draft_policy(project_id, creator_id) + async with db_session.get_session_factory()() as session: + await session.execute( + update(ContributionPolicy) + .where(ContributionPolicy.id == policy_id) + .values(status="active", current_published_version_id=version_id) + ) + with pytest.raises(DBAPIError): + await session.commit() + + +@pytest.mark.asyncio +async def test_incomplete_or_unpaid_definition_graph_cannot_publish( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + await _add_rule( + version_id, + project_id, + "accepted_submission", + "unpaid", + binding_id=money_binding_id, + ) + with pytest.raises(DBAPIError): + await _publish_version(version_id, creator_id) + + +@pytest.mark.asyncio +async def test_each_incomplete_policy_graph_shape_is_rejected( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + + for only_type in ("accepted_submission", "completed_review"): + _, version_id = await _draft_policy(project_id, creator_id) + await _add_rule(version_id, project_id, only_type, "unpaid") + with pytest.raises(DBAPIError): + await _publish_version(version_id, creator_id) + + _, unpaid_version_id = await _draft_policy(project_id, creator_id) + await _add_rule( + unpaid_version_id, + project_id, + "accepted_submission", + "unpaid", + binding_id=money_binding_id, + ) + await _add_rule(unpaid_version_id, project_id, "completed_review", "unpaid") + with pytest.raises(DBAPIError): + await _publish_version(unpaid_version_id, creator_id) + + _, empty_version_id = await _draft_policy(project_id, creator_id) + await _add_rule(empty_version_id, project_id, "accepted_submission", "compensated") + await _add_rule(empty_version_id, project_id, "completed_review", "unpaid") + with pytest.raises(DBAPIError): + await _publish_version(empty_version_id, creator_id) + + _, duplicate_version_id = await _draft_policy(project_id, creator_id) + rule_id = await _add_rule( + duplicate_version_id, + project_id, + "accepted_submission", + "compensated", + binding_id=money_binding_id, + ) + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError): + session.add( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=duplicate_version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity=Decimal("1"), + adapter_binding_id=money_binding_id, + ) + ) + await session.flush() + +@pytest.mark.asyncio +async def test_definition_binding_must_match_project_and_instrument( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, _, points_binding_id = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + with pytest.raises(IntegrityError): + await _add_rule( + version_id, + project_id, + "accepted_submission", + "compensated", + binding_id=points_binding_id, + ) + + +@pytest.mark.asyncio +async def test_database_rejects_unconfigured_units_and_fractional_points( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, points_binding_id = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + rule_id = await _add_rule( + version_id, + project_id, + "accepted_submission", + "compensated", + binding_id=money_binding_id, + ) + invalid_definitions = ( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="money", + unit_code="EUR", + quantity=Decimal("1"), + adapter_binding_id=money_binding_id, + ), + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="project_points", + unit_code="merit.points", + quantity=Decimal("1.5"), + adapter_binding_id=points_binding_id, + ), + ) + for definition in invalid_definitions: + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError): + session.add(definition) + await session.flush() + + +@pytest.mark.asyncio +async def test_database_rejects_unknown_iso_code_and_overprecision( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError): + session.add( + ProjectCompensationUnit( + project_id=project_id, + instrument_type="money", + unit_code="ZZZ", + iso_currency_code="ZZZ", + status="active", + created_by=creator_id, + ) + ) + await session.flush() + + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError): + session.add( + ProjectCompensationUnit( + project_id=project_id, + instrument_type="money", + unit_code="ZZZ", + iso_currency_code=None, + status="active", + created_by=creator_id, + ) + ) + await session.flush() + + _, version_id = await _draft_policy(project_id, creator_id) + rule_id = await _add_rule(version_id, project_id, "accepted_submission", "compensated") + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError): + session.add( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity=Decimal("1.0000000000000000001"), + adapter_binding_id=money_binding_id, + ) + ) + await session.flush() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "quantity", + ( + Decimal("0"), + Decimal("-1"), + Decimal("100000000000000000000"), + Decimal("1.0000000000000000001"), + ), +) +async def test_database_rejects_each_invalid_quantity_edge( + contribution_database_env: str, + quantity: Decimal, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + rule_id = await _add_rule(version_id, project_id, "accepted_submission", "compensated") + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError): + session.add( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity=quantity, + adapter_binding_id=money_binding_id, + ) + ) + await session.flush() + + +@pytest.mark.asyncio +async def test_database_accepts_exact_maximum_quantity_edge( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + rule_id = await _add_rule(version_id, project_id, "accepted_submission", "compensated") + maximum = Decimal("99999999999999999999.999999999999999999") + async with db_session.get_session_factory()() as session: + definition = ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="money", + unit_code="USD", + quantity=maximum, + adapter_binding_id=money_binding_id, + ) + session.add(definition) + await session.flush() + assert definition.quantity == maximum + + +@pytest.mark.asyncio +async def test_published_rules_definitions_and_version_identity_are_immutable( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + _, version_id, unpaid_rule_id = await _complete_published_policy( + project_id, creator_id, money_binding_id + ) + statements = ( + update(ContributionRule) + .where(ContributionRule.id == unpaid_rule_id) + .values(compensation_mode="compensated"), + delete(ContributionRule).where(ContributionRule.id == unpaid_rule_id), + update(ContributionAwardDefinition) + .where(ContributionAwardDefinition.contribution_policy_version_id == version_id) + .values(quantity="99.000000000000000000"), + delete(ContributionAwardDefinition).where( + ContributionAwardDefinition.contribution_policy_version_id == version_id + ), + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == version_id) + .values(version_number=2), + ) + for statement in statements: + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + await session.execute(statement) + + +@pytest.mark.asyncio +async def test_retired_policy_version_and_children_are_immutable( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + _, version_id, unpaid_rule_id = await _complete_published_policy( + project_id, creator_id, money_binding_id + ) + async with db_session.get_session_factory()() as session: + await session.execute( + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == version_id) + .values( + status="retired", + retired_by=creator_id, + retired_at=datetime.now(UTC), + ) + ) + await session.commit() + + statements = ( + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == version_id) + .values(version_number=2), + delete(ContributionPolicyVersion).where(ContributionPolicyVersion.id == version_id), + update(ContributionRule) + .where(ContributionRule.id == unpaid_rule_id) + .values(compensation_mode="compensated"), + delete(ContributionRule).where(ContributionRule.id == unpaid_rule_id), + update(ContributionAwardDefinition) + .where(ContributionAwardDefinition.contribution_policy_version_id == version_id) + .values(quantity=Decimal("99")), + delete(ContributionAwardDefinition).where( + ContributionAwardDefinition.contribution_policy_version_id == version_id + ), + ) + for statement in statements: + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + await session.execute(statement) + + +@pytest.mark.asyncio +async def test_policy_and_unit_tables_reject_truncate( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + await _complete_published_policy(project_id, creator_id, money_binding_id) + for table_name in ( + "contribution_award_definitions", + "contribution_rules", + "contribution_policy_versions", + "contribution_policies", + "project_compensation_units", + "iso_4217_currency_codes", + ): + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + await session.execute(text(f"truncate table {table_name} cascade")) + + +@pytest.mark.asyncio +async def test_published_definition_cannot_be_reparented_to_draft_version( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, points_binding_id = await _seed_project() + _, published_version_id = await _draft_policy(project_id, creator_id) + compensated_rule_id = await _add_rule( + published_version_id, + project_id, + "accepted_submission", + "compensated", + binding_id=money_binding_id, + ) + async with db_session.get_session_factory()() as session: + session.add( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=compensated_rule_id, + contribution_policy_version_id=published_version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="project_points", + unit_code="merit.points", + quantity=Decimal("10"), + adapter_binding_id=points_binding_id, + ) + ) + await session.commit() + await _add_rule(published_version_id, project_id, "completed_review", "unpaid") + await _publish_version(published_version_id, creator_id) + + _, draft_version_id = await _draft_policy(project_id, creator_id) + draft_rule_id = await _add_rule( + draft_version_id, project_id, "accepted_submission", "compensated" + ) + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError): + await session.execute( + update(ContributionAwardDefinition) + .where( + ContributionAwardDefinition.contribution_policy_version_id + == published_version_id, + ContributionAwardDefinition.instrument_type == "project_points", + ) + .values( + contribution_rule_id=draft_rule_id, + contribution_policy_version_id=draft_version_id, + ) + ) + + +@pytest.mark.asyncio +async def test_active_policy_race_has_one_winner(contribution_database_env: str) -> None: + project_id, creator_id, _, money_binding_id, _ = await _seed_project() + first_policy, first_version, _ = await _complete_published_policy( + project_id, creator_id, money_binding_id + ) + second_policy, second_version, _ = await _complete_published_policy( + project_id, creator_id, money_binding_id + ) + + async def activate(policy_id: UUID, version_id: UUID) -> str: + async with db_session.get_session_factory()() as session: + try: + await session.execute( + update(ContributionPolicy) + .where(ContributionPolicy.id == policy_id) + .values(status="active", current_published_version_id=version_id) + ) + await session.commit() + return "active" + except (DBAPIError, IntegrityError): + await session.rollback() + return "conflict" + + assert sorted( + await asyncio.gather( + activate(first_policy, first_version), + activate(second_policy, second_version), + ) + ) == ["active", "conflict"] + + +@pytest.mark.asyncio +async def test_publishability_race_cannot_use_uncommitted_rule( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, _, _ = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + await _add_rule(version_id, project_id, "accepted_submission", "unpaid") + rule_staged = asyncio.Event() + allow_rule_commit = asyncio.Event() + + async def add_missing_rule() -> str: + async with db_session.get_session_factory()() as session: + session.add( + ContributionRule( + id=uuid4(), + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="completed_review", + compensation_mode="unpaid", + ) + ) + await session.flush() + rule_staged.set() + await allow_rule_commit.wait() + await session.commit() + return "rule_committed" + + async def publish_without_visible_rule() -> str: + await rule_staged.wait() + try: + async with db_session.get_session_factory()() as session: + await session.execute(text("set local lock_timeout='500ms'")) + await session.execute( + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == version_id) + .values( + status="published", + published_by=creator_id, + published_at=datetime.now(UTC), + ) + ) + await session.commit() + return "published" + except DBAPIError: + return "rejected" + finally: + allow_rule_commit.set() + + assert sorted(await asyncio.gather(add_missing_rule(), publish_without_visible_rule())) == [ + "rejected", + "rule_committed", + ] + + +@pytest.mark.asyncio +async def test_publish_lock_rejects_concurrent_draft_child_mutation( + contribution_database_env: str, +) -> None: + project_id, creator_id, _, money_binding_id, points_binding_id = await _seed_project() + _, version_id = await _draft_policy(project_id, creator_id) + compensated_rule_id = await _add_rule( + version_id, + project_id, + "accepted_submission", + "compensated", + binding_id=money_binding_id, + ) + await _add_rule(version_id, project_id, "completed_review", "unpaid") + publish_locked = asyncio.Event() + child_attempted = asyncio.Event() + + async def publish() -> str: + async with db_session.get_session_factory()() as session: + await session.execute( + update(ContributionPolicyVersion) + .where(ContributionPolicyVersion.id == version_id) + .values( + status="published", + published_by=creator_id, + published_at=datetime.now(UTC), + ) + ) + publish_locked.set() + await child_attempted.wait() + await session.commit() + return "published" + + async def mutate_child() -> str: + await publish_locked.wait() + async with db_session.get_session_factory()() as session: + try: + await session.execute(text("set local lock_timeout='500ms'")) + session.add( + ContributionAwardDefinition( + id=uuid4(), + contribution_rule_id=compensated_rule_id, + contribution_policy_version_id=version_id, + project_id=project_id, + contribution_type="accepted_submission", + instrument_type="project_points", + unit_code="merit.points", + quantity=Decimal("5"), + adapter_binding_id=points_binding_id, + ) + ) + await session.flush() + return "mutated" + except DBAPIError: + return "rejected" + finally: + child_attempted.set() + + assert sorted(await asyncio.gather(publish(), mutate_child())) == [ + "published", + "rejected", + ] diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index d0b322b0a..a93367239 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -15,9 +15,11 @@ ActorProfile AuthorityControl AuthorityIdempotencyRecord AuthorityInvalidationEvent +Iso4217CurrencyCode Project ProjectCompensationAdapterBinding + ProjectCompensationUnit ProjectGuide GuideSourceSnapshot GuideSourceSnapshotItem @@ -1092,16 +1094,34 @@ Fields: - `contribution_type` - `instrument_type`: `money | project_points` - `unit_code` -- `quantity` as a `NUMERIC(38, 18)` fixed-point decimal greater than zero +- `quantity` in the exact `NUMERIC(38, 18)` value envelope, stored unrounded + and checked explicitly before persistence - `adapter_binding_id` API quantities are bounded decimal strings; binary floating point, exponent notation, non-finite values, zero, negatives, overflow, and excess precision are rejected rather than rounded. Money units are uppercase configured ISO 4217 codes. Project-points units have project-scoped identity -`(project_id, unit_code)`. Published definitions are immutable and project, +`(project_id, unit_code)` and whole-number quantities. Published definitions are immutable and project, instrument, and unit consistent with the referenced adapter binding. +## ProjectCompensationUnit + +Fields: + +- `project_id` +- `instrument_type`: `money | project_points` +- `unit_code` +- `iso_currency_code` for money, null for project points +- `status`: `active | retired` +- creation and retirement actor/timestamp fields + +The identity is `(project_id, instrument_type, unit_code)`. Money units reference +the migration-owned immutable ISO 4217 List One registry. Project-points units +are project-scoped. Award definitions reference the unit identity with a +composite foreign key. Persistence initially allows active creation only and +defers unit lifecycle mutation to the authorized contribution-policy behavior. + ## ProjectCompensationAdapterBinding Fields: diff --git a/docs/spec_contribution_compensation.md b/docs/spec_contribution_compensation.md index d119c9fe7..f694c1861 100644 --- a/docs/spec_contribution_compensation.md +++ b/docs/spec_contribution_compensation.md @@ -175,7 +175,11 @@ adapter_binding_id ``` Every policy, award, and fulfilled quantity uses the same fixed-point -`NUMERIC(38, 18)` contract. API quantities are canonical decimal strings with +`NUMERIC(38, 18)` value envelope. PostgreSQL persistence retains the unrounded +numeric input and checks at most 20 integer and 18 fractional digits explicitly; +using a `NUMERIC(38,18)` column typemod directly is forbidden because PostgreSQL +would round excess fractional precision before a check could reject it. API +quantities are canonical decimal strings with at most 20 integer digits and 18 fractional digits; leading plus signs, binary floating-point, NaN, infinity, exponent notation, negative values, zero, excess precision, and values above the database maximum are rejected rather than @@ -183,12 +187,25 @@ rounded. Pydantic, application, and PostgreSQL validation MUST enforce identical bounds. For `money`, `unit_code` is an uppercase ISO 4217 code enabled for the project. -For `project_points`, it is the exact configured project-scoped unit; its +The immutable ISO code registry is migration-owned from the official current +SIX ISO 4217 List One; application behavior cannot add arbitrary codes. For +`project_points`, quantity is a whole number and `unit_code` is the exact +configured project-scoped unit; its identity is `(project_id, unit_code)`, so equal text in another project is a different unit. A definition MUST match the rule's project, policy version, contribution type, and unit. Its binding MUST match the project and instrument. Published definitions are immutable. +### ProjectCompensationUnit + +`ProjectCompensationUnit` is the durable project enablement target used by +award definitions. Its identity is `(project_id, instrument_type, unit_code)`. +Money rows must reference the migration-owned ISO 4217 registry; project-points +rows use the project-scoped configured code. Persistence permits only active +creation and rejects lifecycle mutation until the authorized policy behavior +chunk installs its transition guard. Definitions reference this identity with +a composite foreign key, so regex-shaped but unconfigured units cannot publish. + ### ProjectCompensationAdapterBinding Canonical fields: