diff --git a/.github/workflows/assignment-category-quality.yml b/.github/workflows/assignment-category-quality.yml new file mode 100644 index 000000000..d6f6065ef --- /dev/null +++ b/.github/workflows/assignment-category-quality.yml @@ -0,0 +1,72 @@ +name: Assignment Category Quality + +on: + pull_request: + branches: + - bootstrap + - develop + - main + paths: + - "database/migrations/0017_assignment_category_code.sql" + - "docs/adr/0015-explicit-assignment-category.md" + - "docs/API_CONTRACT.md" + - "docs/DATA_MODEL.md" + - "docs/ERD.md" + - "docs/TRACEABILITY.md" + - "manifest.json" + - "packages/hris-kernel/**" + - "schemas/openapi.yaml" + - "services/people-api/**" + - "tests/assignment-category-provenance.test.mjs" + - "tests/openapi-contract.test.mjs" + - "tests/test_assignment_category_postgres.sh" + - "tests/validate_repository.py" + - ".github/workflows/assignment-category-quality.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: assignment-category-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + postgres: + name: Explicit assignment category persistence contract + runs-on: ubuntu-24.04 + timeout-minutes: 10 + services: + postgres: + image: postgres:16.14@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + env: + POSTGRES_USER: orgmetra + POSTGRES_PASSWORD: orgmetra + POSTGRES_DB: orgmetra + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U orgmetra -d orgmetra" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://orgmetra:orgmetra@localhost:5432/orgmetra + steps: + - name: Checkout exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Prove exact candidate checkout + env: + ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" + - name: Prove assignment category provenance inventory + run: node --test tests/assignment-category-provenance.test.mjs + - name: Prove migration compatibility and new-write invariants + run: bash tests/test_assignment_category_postgres.sh + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f4752d7..03158fa6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Orgmetra will be documented in this file. ### Added +- Active-PR explicit assignment-category contract: new assignment writes must record `primary` or `concurrent_secondary`; historical pre-contract rows remain `legacy_unspecified` without allocation-, row-order-, or topology-based guessing. The People API, idempotency digest, PostgreSQL persistence, OpenAPI contract, and bitemporal tenant/Employment invariant carry the same governed meaning. - Accepted ADRs 0001–0003 now include buyer-facing Context, Decision, and Consequences grounded in verified ISO 30400:2022, ISO 30414:2025, Uniform Guidelines (29 C.F.R. Part 1607), SIOP (2018), OpenAPI Specification v3.2.0, OpenID Connect Core 1.0 errata set 2, CloudEvents v1.0.2, Jensen and Snodgrass (1999), Snodgrass (1999), and Allen (1983) records already listed in `docs/doctoring/REFERENCES.md`. ADRs 0004 and 0005 gained APA 7th References pointers to that same bibliography without changing their Decision bodies. - Active-PR governed Job Analysis persistence/API on the canonical `JobAnalysisSnapshot` model: migration `0013_job_analysis_snapshot.sql` stores immutable tenant-scoped snapshot, Task, KSAO, Task–KSAO, FJA and write-command evidence; `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots` and matching GET enforce purpose-bound Keyverse scope, authenticated-principal actor authority, bounded/strict JSON handling, transactional Idempotency-Key serialization, parent-scope fail-closed integrity, forced RLS, and atomic audit/outbox evidence. ADR 0014 records the persistence decision while ADR 0007 remains the domain/evidence authority; validated evidence still requires accountable human review and non-LLM provenance, and the service does not make a high-impact employment decision. - Active-PR `orgmetra_selection_review` packet for PII-minimized, evidence-bound human selection review: canonical operational tenant identity, UUID-backed opaque candidate/Job/sealed-evidence/reviewer references, explicit purpose/reason/evidence version, deterministic canonical JSON and SHA-256 correlation, mandatory human decision state, redacted packet repr, and provenance-paired model evidence that remains `untrusted_draft`, with exact 100% owned statement and branch coverage required by its quality gate. diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql new file mode 100644 index 000000000..1eade3dce --- /dev/null +++ b/database/migrations/0017_assignment_category_code.sql @@ -0,0 +1,80 @@ +-- Record assignment role classification as authoritative HRIS truth. +-- Historical rows are preserved explicitly; no allocation/order heuristic is allowed. +-- The constant default materializes the historical sentinel for pre-contract rows +-- without issuing an UPDATE that would violate the existing bitemporal history +-- guard. Dropping the default immediately keeps every post-contract write explicit. +-- legacy_unspecified is migration provenance only. The table constraint preserves +-- that historical sentinel so system-time closure remains possible, while the +-- write guard rejects introduction of the sentinel on a new row or by changing +-- an already classified row back to legacy state. + +BEGIN; + +SET LOCAL search_path = public, pg_catalog; + +ALTER TABLE public.assignment_record + ADD COLUMN assignment_category_code text NOT NULL DEFAULT 'legacy_unspecified'; + +ALTER TABLE public.assignment_record + ALTER COLUMN assignment_category_code DROP DEFAULT; + +ALTER TABLE public.assignment_record + ADD CONSTRAINT assignment_record_category_code_check + CHECK (assignment_category_code IN ('legacy_unspecified', 'primary', 'concurrent_secondary')) NOT VALID; + +-- Add the constraint without the strongest validation-time table lock, then +-- prove every migrated historical row conforms before the migration completes. +ALTER TABLE public.assignment_record + VALIDATE CONSTRAINT assignment_record_category_code_check; + +CREATE FUNCTION public.enforce_assignment_category_write() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF NEW.assignment_category_code = 'legacy_unspecified' THEN + IF TG_OP = 'INSERT' THEN + RAISE EXCEPTION 'assignment_record_category_code_check: legacy_unspecified is migration provenance only' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'assignment_record_category_code_check', + TABLE = 'assignment_record', + SCHEMA = 'public'; + ELSIF OLD.assignment_category_code IS DISTINCT FROM 'legacy_unspecified' THEN + RAISE EXCEPTION 'assignment_record_category_code_check: classified assignment cannot become legacy_unspecified' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'assignment_record_category_code_check', + TABLE = 'assignment_record', + SCHEMA = 'public'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.enforce_assignment_category_write() IS + 'Rejects new or retroactively introduced legacy_unspecified assignment categories while preserving pre-contract system-time history.'; + +-- PostgreSQL fires same-kind triggers in name order. This name intentionally +-- sorts before assignment_record_bitemporal_guard so a classified-to-sentinel +-- rewrite reports the category invariant before the generic history guard. +CREATE TRIGGER assignment_record_authoritative_category_guard +BEFORE INSERT OR UPDATE OF assignment_category_code ON public.assignment_record +FOR EACH ROW +EXECUTE FUNCTION public.enforce_assignment_category_write(); + +COMMENT ON TRIGGER assignment_record_authoritative_category_guard ON public.assignment_record IS + 'Keeps legacy_unspecified as migration provenance instead of a writable assignment classification.'; + +ALTER TABLE public.assignment_record + ADD CONSTRAINT assignment_record_primary_bitemporal_exclusion + EXCLUDE USING gist ( + tenant_record_id WITH =, + employment_record_id WITH =, + daterange(effective_from, effective_to, '[)') WITH &&, + tstzrange(recorded_from, recorded_to, '[)') WITH && + ) + WHERE (assignment_category_code = 'primary'); + +COMMIT; diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 27235d9c9..53fa4c44d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -41,7 +41,9 @@ High-impact commands additionally require: For confirmed-hire materialization, those high-impact facts are resolved from the exact already-sealed `selection_decision` and its evidence set inside the tenant-bound transaction rather than accepted again as mutable request-body assertions. -The server rejects a reused idempotency key when its method, resource, tenant, actor, purpose, or semantic command digest differs. People employment, position, assignment, and confirmed-hire writes persist that digest on `people_mutation_idempotency_record` in the same transaction as the authoritative HRIS fact and audit/outbox pair. A matching retry returns the first committed record identity without duplicating authoritative or audit/outbox facts. Generated record identifiers are excluded from the employment/position/assignment digest so a retried POST that allocates fresh UUIDs still replays; the confirmed-hire route requires the caller to repeat the exact confirmed identities and rejects a same-key command whose materialization identities differ. +Assignment creation additionally requires `assignment_category_code` with exactly `primary` or `concurrent_secondary`. The API never accepts `legacy_unspecified` for a new command and never derives category from allocation percentage, row order, position identity, or graph topology. At one tenant/employment/effective/system-time coordinate, creating a second visible `primary` fails closed; additional concurrent work must be recorded explicitly as `concurrent_secondary` while still satisfying employment, position, and allocation-portfolio invariants. + +The server rejects a reused idempotency key when its method, resource, tenant, actor, purpose, or semantic command digest differs. People employment, position, assignment, and confirmed-hire writes persist that digest on `people_mutation_idempotency_record` in the same transaction as the authoritative HRIS fact and audit/outbox pair. Assignment category is part of the assignment semantic digest, so changing `primary` to `concurrent_secondary` under the same key is an idempotency conflict. A matching retry returns the first committed record identity without duplicating authoritative or audit/outbox facts. Generated record identifiers are excluded from the employment/position/assignment digest so a retried POST that allocates fresh UUIDs still replays; the confirmed-hire route requires the caller to repeat the exact confirmed identities and rejects a same-key command whose materialization identities differ. ## Example endpoints @@ -60,7 +62,7 @@ POST /v1/criterion-observations POST /v1/validity-studies ``` -The foundation OpenAPI contract covers the shared command vocabulary and baseline person, employment, position, assignment, job-profile, and selection-decision operations. Runtime services must publish any additional path-specific contract before release and may not weaken the shared `Idempotency-Key`, least-privilege scope, authorization, evidence, or error semantics. Employment and assignment writes fail closed when exclusive jobs overlap, a seat is not staffable, or visible seat allocations exceed 1.0000. +The foundation OpenAPI contract covers the shared command vocabulary and baseline person, employment, position, assignment, job-profile, and selection-decision operations. Runtime services must publish any additional path-specific contract before release and may not weaken the shared `Idempotency-Key`, least-privilege scope, authorization, evidence, or error semantics. Employment and assignment writes fail closed when exclusive jobs overlap, a seat is not staffable, visible seat allocations exceed 1.0000, assignment category is not explicit, or two primary assignment intervals overlap for the same tenant-local employment. ## Error shape @@ -73,4 +75,4 @@ The foundation OpenAPI contract covers the shared command vocabulary and baselin } ``` -`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. \ No newline at end of file +`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7d4afd563..a36b5e3d5 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -14,7 +14,7 @@ | `job_profile_version` | Bitemporal title, family, and version definition for a job profile. | | `position_record` | Durable seat identity that keeps stable organization and job references. | | `position_record_version` | Bitemporal position status and effective period. | -| `assignment_record` | A person's allocation to a position through one employment. | +| `assignment_record` | A person's allocation and explicit primary/concurrent-secondary category for a position through one employment. | | `candidate_profile` | Applicant/candidate record before hire. | | `candidate_worker_link` | Legacy append-only candidate-to-worker linkage retained for historical reads; new writes use `candidate_worker_conversion_record`. | | `candidate_worker_conversion_record` | Governed bitemporal candidate-to-worker conversion bound to the hire decision, person, employment, immutable audit event, and outbox evidence. | @@ -54,6 +54,8 @@ Durable anchors such as `organization_unit`, `job_profile`, `employment_record`, Assignments remain a legitimately multiple-membership fact. Each assignment must name the covering employment and the same person as that employment. Exclusive employments for one person cannot overlap; a second job must be marked `concurrent`. Allocation totals for one employment, and visible allocations for one position, are enforced by `orgmetra_hris_kernel` rather than a single-valued exclusion. An assignment day must also land on an `active` or `open` position version. +`assignment_category_code` records a different invariant from allocation. Every new assignment write must state `primary` or `concurrent_secondary`; allocation percentage, row order, position identity, and graph topology are never used to infer the category. Rows created before migration 0017 are explicitly preserved as `legacy_unspecified`. The storage CHECK admits only the three known values so those historical rows can still undergo a legitimate system-time closure, while the dedicated assignment-category write guard rejects introducing `legacy_unspecified` on INSERT or changing an already classified assignment back to that sentinel. Replacement/current writes remain limited to `primary | concurrent_secondary`. A tenant/employment-scoped partial GiST exclusion over effective and recorded ranges allows multiple concurrent assignments while rejecting two simultaneously visible `primary` assignments. This keeps the category on the normalized assignment fact instead of denormalizing a mutable current-primary pointer, and localizes exclusion conflicts to one tenant-local employment portfolio. + ## High-impact decision evidence Evidence membership is constructed in `selection_decision_evidence` while its `decision_evidence_set` is open. An open set has no caller-supplied content digest. Finalizing `selection_decision` requires at least one versioned evidence member, canonicalizes the members by `(evidence_reference, evidence_version_code)`, computes SHA-256 inside PostgreSQL, and atomically stores that digest while binding `sealed_selection_decision_id`. Database triggers reject later evidence inserts, second-decision reuse, arbitrary post-seal mutation, and a sealed-set pointer that does not resolve back to the decision that consumed that exact set. This makes the stored digest evidence about database-observed membership at finalization rather than an unverified client assertion. @@ -62,7 +64,7 @@ New predictive-validity membership uses `validity_study_case_record` rather than ## People mutation idempotency -`people_mutation_idempotency_record` is the durable retry boundary for governed candidate-worker conversion, Employment, Position, and Assignment mutations. Its unique business key is `(tenant_record_id, command_route, idempotency_key)`; the row stores the canonical semantic-command SHA-256 digest and the first committed created-record identity. Matching retries replay that identity, while a changed command under the same tenant/route/key fails closed instead of creating another HRIS fact. +`people_mutation_idempotency_record` is the durable retry boundary for governed candidate-worker conversion, Employment, Position, and Assignment mutations. Its unique business key is `(tenant_record_id, command_route, idempotency_key)`; the row stores the canonical semantic-command SHA-256 digest and the first committed created-record identity. Matching retries replay that identity, while a changed command under the same tenant/route/key fails closed instead of creating another HRIS fact. Assignment category participates in that semantic digest, so changing `primary` to `concurrent_secondary` under the same Idempotency-Key is a conflict rather than an idempotent replay. The owning write port acquires an exact-key transaction-scoped advisory lock and writes the HRIS fact, immutable audit/outbox evidence, and idempotency row inside one PostgreSQL transaction. A rolled-back mutation therefore cannot leave a false replay marker. The relation is append-only, TRUNCATE-protected, tenant-RLS isolated, and uses opaque operational UUIDs. The idempotency key is transport correlation, not HR data or authorization evidence; actor, purpose, human-confirmation and resource authorization remain independently required. diff --git a/docs/ERD.md b/docs/ERD.md index a547cc3a0..45c2a8931 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -49,6 +49,8 @@ erDiagram `organization_unit`, `job_profile`, `employment_record`, and `position_record` are durable anchors. Mutable names, classifications, parent relationships, titles, families, version codes, and employment or position status live in bitemporal version rows. Positions retain stable organization/job references while retroactive corrections append or supersede version facts rather than rewriting identity. An organization version may reference another durable organization as its parent; self-parenting is rejected at the database boundary. An assignment names the employment that covers it, so a person cannot be assigned through another worker's employment. Exclusive employment versions for one person cannot overlap. An assignment day must land on an `active` or `open` position version, and visible allocations for one seat cannot exceed 1.0000. +Assignments are intentionally many-to-one from Employment and Position, but `assignment_category_code` makes the primary-versus-concurrent-secondary decision explicit. New rows accept only `primary` or `concurrent_secondary`; pre-contract rows remain `legacy_unspecified` without inference. A partial tenant/employment/effective/system-time exclusion permits many overlapping assignment facts while allowing at most one simultaneously visible `primary` for the same tenant-local employment. Category changes are corrections to assignment truth, not a mutable foreign pointer or row-order convention. + Every owned HRIS fact carries `tenant_record_id`. Relationships that cross table boundaries use tenant-qualified foreign keys, and row-level security independently filters every tenant-scoped relation. The tenant column is therefore both a referential-integrity boundary and a runtime isolation boundary, not a caller-supplied business attribute. A candidate profile can be linked to at most one worker identity within its tenant. A person identity can have multiple candidate-worker links across reapplications or historical candidate profiles, so the person-side cardinality is one-to-many. @@ -61,7 +63,7 @@ A `validity_study` connects the criterion blueprint to the exact selection decis One immutable `audit_event_record` may have multiple `outbox_delivery_record` rows when the same event must reach multiple delivery targets. The unique `(tenant_record_id, audit_event_record_id, delivery_target_code)` key permits at most one delivery lifecycle per target. Delivery retries mutate only the delivery relation; the canonical event bytes and digest are append-only and therefore cannot drift with transport state. -A `people_mutation_idempotency_record` belongs to one tenant and names one created employment, position, or assignment identity for one route and `Idempotency-Key`. The unique `(tenant_record_id, command_route, idempotency_key)` key prevents a retry from creating a second authoritative fact. Tenants do not share keys. +A `people_mutation_idempotency_record` belongs to one tenant and names one created employment, position, or assignment identity for one route and `Idempotency-Key`. The unique `(tenant_record_id, command_route, idempotency_key)` key prevents a retry from creating a second authoritative fact. For assignments, the semantic digest includes `assignment_category_code`, so a same-key replay cannot silently change primary/secondary meaning. Tenants do not share keys. A delivery can have at most one `outbox_delivery_escalation_record`, enforced by the unique `(tenant_record_id, outbox_delivery_record_id)` key. The escalation row exists only for a terminal `dead_lettered` delivery and records the failure classification, terminal attempt count, recorded time, and an opaque operator/customer escalation reference without copying the event payload. The row is append-only; terminal queue history is not reopened or rewritten. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 22a4178fe..b3b80b113 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -5,6 +5,7 @@ | Requirement | Architecture | Data object | Test family | ADR | Maturity | |---|---|---|---|---|---| | Separate person/employment/organization/job/position/assignment | Core bounded contexts | `person_record`, `employment_record`, `employment_record_version`, `organization_unit`, `job_profile`, `position_record`, `position_record_version`, `assignment_record` | schema/domain and `orgmetra_hris_kernel` tests | ADR-0001, ADR-0004, ADR-0005 | implemented_on_active_pr | +| Explicit non-heuristic primary/concurrent-secondary assignment classification | People core / Organization–Job–Position–Assignment boundary | `assignment_record.assignment_category_code`; `legacy_unspecified` migration provenance only | assignment-category domain/idempotency regression, HTTP parser regression, PostgreSQL forward-write/legacy/overlapping-primary regression, OpenAPI structural regression | ADR-0015 | implemented_on_active_pr | | Exclusive employment and staffable seats | Core bounded contexts | `employment_concurrency_code`, staffable `position_status_code`, assignment allocation totals | Memorial Hospital exclusivity, freeze, and seat-capacity kernel tests plus OpenAPI employment/position/assignment commands | ADR-0005 | implemented_on_active_pr | | Tenant-qualified HRIS integrity and fail-closed isolation | Core bounded contexts / Security architecture | `tenant_record`, tenant-qualified foreign keys, forced row-level security policies, tenant-scoped kernel query parameters | PostgreSQL cross-tenant FK/application-role RLS contracts plus kernel cross-tenant reconstruction, employment coverage, position coverage, seat-capacity, portfolio, exclusivity, and organization-hierarchy regressions | ADR-0001, ADR-0003 | implemented_on_active_pr | | Reserved UUID sentinel exclusion | Persistence integrity boundary | every foundation UUID `*_id` column plus audit/outbox identifiers | PostgreSQL inventory proof plus Nil/Max foundation and audit/outbox persistence regressions | ADR-0001, RFC 9562 | implemented_on_active_pr | @@ -31,6 +32,7 @@ | External contract | Orgmetra owner boundary | Integration style | Required evidence | ADR | Maturity | |---|---|---|---|---|---| | Keyverse identity and authorization | API Gateway / purpose-bound authorization | Published OIDC/API identity and scope contract plus Orgmetra-owned `orgmetra_keyverse_adapter` policy evaluation | tenant/actor/resource agreement, exact opaque target-resource reference, purpose, operation-specific scope, requested-field minimization, opaque subject, no stored credentials or protected values in authorization evidence | ADR-0002, ADR-0008 | implemented_on_protected_main | +| context-graph-contracts assignment membership | People core / Integration Hub ACL | Versioned downstream projection of authoritative `assignment_category_code` into primary/secondary membership predicates | no category inference, tenant scope, bitemporal source version, provenance, no direct Orgmetra table access | ADR-0015 | accepted_architecture | | naruon communication and calendar | Integration Hub | Published API/event adapter | idempotency, delivery audit, no direct table access | ADR-0002 | planned | | Psychometrics Commons @ `cc5850a0d1eacbbf16d03075534fce460a8286e6` | Workforce Validation | Immutable response/result snapshot contract | pinned revision, model/version/provenance snapshot, immutable result linkage, no direct application-table access | ADR-0002 | accepted_architecture | | fast-mlsirm @ `fb67ced09d8ee00542c05d56374537a9a7239751` | Workforce Validation | Published `orgmetra.fast_mlsirm.v1` result contract; direct calls only from approved offline validation worker | pinned revision, contract identifier, backend/result provenance, CPU/GPU parity evidence where material, no duplicated kernel | ADR-0002 | accepted_architecture | diff --git a/docs/adr/0015-explicit-assignment-category.md b/docs/adr/0015-explicit-assignment-category.md new file mode 100644 index 000000000..f59e17a18 --- /dev/null +++ b/docs/adr/0015-explicit-assignment-category.md @@ -0,0 +1,72 @@ +# ADR 0015: Explicit assignment category is authoritative HRIS truth + +- Status: Proposed +- Date: 2026-09-02 +- Owners: `people_core` and the Organization–Job–Position–Assignment domain boundary + +## Context + +An `assignment_record` already states that one person, through one employment, occupies one position for an effective/system-time interval and allocation ratio. Allocation does not answer a different business question: which assignment is the worker's primary reporting/work relationship and which concurrent assignment is secondary or temporary-factor-team work. Inferring that decision from the largest allocation, row order, position identity, or graph topology would turn storage coincidence into HR truth and would make downstream authorization and context-graph consumers disagree after corrections. + +The ecosystem contract in `ContextualWisdomLab/context-graph-contracts#23` needs a non-heuristic source for primary-versus-secondary organization membership. Orgmetra owns that source because it owns employment and assignment facts; consumers may translate the published value through an anti-corruption layer but must not author or infer it. + +Bitemporal interpretation matters because historical Assignment facts can be closed and replacement facts can become known later while effective time and recorded time remain distinct. This increment does not claim a complete Assignment category-correction or supersession workflow: `assignment_record` is currently the immutable fact identity, and Orgmetra #164 owns the separate linked-replacement provenance/API gap. Allen (1983) and Jensen and Snodgrass (1999) provide the temporal-data basis for treating interval overlap and recorded-time knowledge as first-class semantics rather than collapsing them into a current-row flag. ISO 30400:2022 is used only as HR vocabulary context, not as evidence that ISO prescribes these exact category codes. + +## Decision + +`assignment_record.assignment_category_code` is an authoritative value object with two values for every new write: + +- `primary`: the one primary assignment permitted for an employment at a bitemporal coordinate; +- `concurrent_secondary`: an additional concurrently valid assignment. + +Migration provenance may contain `legacy_unspecified` only for rows created before this contract. Application commands and new direct database writes cannot create that sentinel, and no application or migration may convert it by heuristic inference. + +The Assignment aggregate remains rooted in the stable `assignment_record_id` and is evaluated within tenant plus `employment_record_id`. Its invariants are: + +1. category is explicit on every new mutation; +2. at most one `primary` assignment is visible for the same tenant and employment at overlapping effective and recorded intervals; +3. concurrent secondary assignments remain subject to the existing assignment-portfolio allocation ceiling and employment/position coverage invariants; +4. category participates in the mutation's semantic idempotency digest, so replaying one Idempotency-Key with a different category is a conflict rather than the same command; and +5. historical `legacy_unspecified` remains readable but never gains an inferred primary/secondary meaning. + +The `people_core` application service validates the value before authorization/persistence composition, the HRIS domain service validates the visible portfolio, and PostgreSQL independently enforces the forward-write vocabulary plus the single-primary overlap invariant. The HTTP and OpenAPI contracts expose only `primary | concurrent_secondary` for new commands. + +## DDD boundary and context map + +Ubiquitous language uses **Assignment Category**, **Primary Assignment**, **Concurrent Secondary Assignment**, and **Legacy Unspecified History**. `people_core` is the upstream authoritative bounded context. Keyverse is an identity/authorization peer and does not own assignment category. Context-graph consumers are downstream and receive the classification through versioned contracts/ACL translation. No shared kernel is introduced for the category vocabulary; only the published contract crosses the boundary. + +The aggregate/entity/value-object split is: + +- aggregate/entity: `assignment_record` identified by `assignment_record_id`; +- value object: `assignment_category_code`; +- domain service: bitemporal assignment-portfolio validation; +- repository boundary: the People mutation port/PostgreSQL adapter; +- domain event/provenance boundary: existing People mutation audit/outbox evidence, whose command digest includes category semantics. + +## Persistence and concurrency consequences + +Migration 0017 backfills pre-contract rows explicitly as `legacy_unspecified`. Its table CHECK accepts only the three known storage values so historical rows remain valid when their system-time interval is closed. A dedicated write guard rejects introducing `legacy_unspecified` on INSERT and rejects changing an already classified row back to that sentinel; closing the `recorded_to` interval of a pre-contract legacy row therefore preserves history without inventing a classification. Post-contract writes still require `primary | concurrent_secondary` through the application/API contract and database write guard. A partial GiST exclusion constraint over tenant, employment, effective interval, and recorded interval rejects two simultaneously visible primary rows without serializing unrelated employments. This preserves normalized assignment facts rather than adding a denormalized 'current primary' pointer and keeps the model in 3NF. + +The exclusion key starts with tenant and employment scope, so conflict work is localized to the employment portfolio instead of creating an organization-wide hot partition. Read paths continue to reconstruct bitemporal facts; this ADR does not introduce a cross-service read/write shortcut or direct access to another bounded context's database. + +## Security, privacy, and decision authority + +Assignment category is employment metadata and can affect authorization context, so it remains tenant- and purpose-bound. It does not itself authorize access or make a hiring, promotion, compensation, termination, or other high-impact employment decision. Downstream consumers must combine it with their own authorized policy context and must not expose internal service boundaries in customer-facing copy. + +Tests and documentation use synthetic organization/person identifiers. Production PII is neither indiscriminately masked nor copied into category/audit metadata. + +## Verification and traceability + +The implementation is test-first: domain/idempotency regression preceded production changes; a PostgreSQL regression then preceded the forward-only persistence repair; and an OpenAPI regression preceded publishing the required command field. A later RED regression proved that system-time closure of pre-contract `legacy_unspecified` history must remain legal; the database repair separates the storage vocabulary from the forward-write guard rather than forcing a guessed category. Exact-head validation must cover People API statement/branch coverage, PostgreSQL compatibility/invariants, Foundation manifest/provenance, Recovery, Security/SAST, and required organization review workflows before ordinary protected-branch integration. + +## Consequences + +Consumers can distinguish primary from secondary membership without guessing. Historical uncertainty remains explicit rather than silently rewritten. In-place category mutation is forbidden. A complete category correction requires a governed close-and-linked-replacement workflow with explicit supersession provenance; Orgmetra #164 owns that successor buyer gap. Until that contract integrates, this increment exposes category creation/read semantics only and must not be represented as supporting Assignment category correction. + +## References + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 + +International Organization for Standardization. (2022). *ISO 30400:2022 Human resource management — Vocabulary*. ISO. https://www.iso.org/standard/78044.html + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a21139..39fb73ba7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,4 @@ | [0012](0012-governed-migration-handoff.md) | Governed migration handoff | Accepted on active implementation branch | | [0013](0013-governed-requisition-review-packet.md) | Governed requisition review packet | Accepted on active implementation branch | | [0014](0014-job-analysis-snapshot-persistence.md) | Persist governed job-analysis snapshots | Accepted on active implementation branch | +| [0015](0015-explicit-assignment-category.md) | Explicit assignment category is authoritative HRIS truth | Proposed on active implementation branch | \ No newline at end of file diff --git a/manifest.json b/manifest.json index 97f2bab14..0242cac88 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1,511 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"32cc4ef78d1eca557fa01731026840be01211a043eb0ada552e4e6cb9eace353","bytes":17295,"lines":76},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac","bytes":11185,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"595e8381dbd62e97093b11eef818af5f04d6473ac592d57e3985ffbc2210d445","bytes":28173,"lines":689},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"918cf92fd18d81572e9bd5f5daa7f033c32731e2e13f0d00661d1c1de30b12a9","bytes":27291,"lines":638}]} +{ + "package": "orgmetra-foundation-pack", + "version": "0.1.0", + "generated_for_branch": "feat/audit-outbox-envelope", + "files": [ + { + "path": ".github/workflows/assignment-category-quality.yml", + "sha256": "1a283c456f7c2c698d34436593b2bcecdf90d83283a91754985ab789b28e9551", + "bytes": 2456, + "lines": 72 + }, + { + "path": ".github/workflows/foundation-ci.yml", + "sha256": "12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537", + "bytes": 4379, + "lines": 123 + }, + { + "path": ".github/workflows/job-analysis-api-quality.yml", + "sha256": "352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a", + "bytes": 4159, + "lines": 105 + }, + { + "path": ".gitignore", + "sha256": "145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21", + "bytes": 375, + "lines": 37 + }, + { + "path": "AGENTS.md", + "sha256": "28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16", + "bytes": 2246, + "lines": 34 + }, + { + "path": "ARCHITECTURE.md", + "sha256": "52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850", + "bytes": 7864, + "lines": 107 + }, + { + "path": "CHANGELOG.md", + "sha256": "46e105027d1a046b26a2bc55e0cccf70444d262dcaf2e9f62886e5e88e3042c5", + "bytes": 17689, + "lines": 77 + }, + { + "path": "CLAUDE.md", + "sha256": "add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f", + "bytes": 1229, + "lines": 20 + }, + { + "path": "LICENSE", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "bytes": 11358, + "lines": 202 + }, + { + "path": "NOTICE", + "sha256": "34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042", + "bytes": 305, + "lines": 4 + }, + { + "path": "README.md", + "sha256": "1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6", + "bytes": 3785, + "lines": 81 + }, + { + "path": "database/migrations/0001_foundation_schema.sql", + "sha256": "ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd", + "bytes": 38747, + "lines": 916 + }, + { + "path": "database/migrations/0002_sealed_evidence_digest.sql", + "sha256": "93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c", + "bytes": 6649, + "lines": 202 + }, + { + "path": "database/migrations/0003_audit_outbox_persistence.sql", + "sha256": "2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc", + "bytes": 15417, + "lines": 423 + }, + { + "path": "database/migrations/0004_outbox_delivery_claim.sql", + "sha256": "d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef", + "bytes": 9451, + "lines": 234 + }, + { + "path": "database/migrations/0005_outbox_delivery_finalization.sql", + "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", + "bytes": 6125, + "lines": 170 + }, + { + "path": "database/migrations/0006_outbox_delivery_dead_letter.sql", + "sha256": "c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7", + "bytes": 24919, + "lines": 628 + }, + { + "path": "database/migrations/0007_outbox_retry_exhaustion.sql", + "sha256": "812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5", + "bytes": 19081, + "lines": 476 + }, + { + "path": "database/migrations/0008_audit_outbox_review_hardening.sql", + "sha256": "c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b", + "bytes": 17562, + "lines": 448 + }, + { + "path": "database/migrations/0009_candidate_worker_conversion_governance.sql", + "sha256": "4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9", + "bytes": 11537, + "lines": 281 + }, + { + "path": "database/migrations/0010_validity_study_case_integrity.sql", + "sha256": "3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1", + "bytes": 11979, + "lines": 313 + }, + { + "path": "database/migrations/0011_criterion_observation_scope.sql", + "sha256": "f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9", + "bytes": 7444, + "lines": 165 + }, + { + "path": "database/migrations/0012_people_mutation_idempotency.sql", + "sha256": "52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69", + "bytes": 3162, + "lines": 76 + }, + { + "path": "database/migrations/0013_job_analysis_snapshot.sql", + "sha256": "b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee", + "bytes": 12713, + "lines": 260 + }, + { + "path": "database/migrations/0017_assignment_category_code.sql", + "sha256": "2686ae2e44f894cc2c0ce21cb724d3909fb4a7dd36e70842bde299b6c5f79120", + "bytes": 3733, + "lines": 80 + }, + { + "path": "docs/API_CONTRACT.md", + "sha256": "6b1fcd3c3b357e2883cc608d53b1427df97656cb51ebd853c9ffc10aaba63ce8", + "bytes": 5387, + "lines": 78 + }, + { + "path": "docs/DATA_MODEL.md", + "sha256": "ab8a34d89983845e4a9b298a44f07be778ac9f0d0429815c4386b0adf9f20b35", + "bytes": 14712, + "lines": 87 + }, + { + "path": "docs/ERD.md", + "sha256": "7cfd113927fc6535289bdc08e150ccadff112d0b4bab1468c8defa309a830084", + "bytes": 7710, + "lines": 72 + }, + { + "path": "docs/OPERABILITY.md", + "sha256": "82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62", + "bytes": 11189, + "lines": 71 + }, + { + "path": "docs/PRD.md", + "sha256": "3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1", + "bytes": 5490, + "lines": 111 + }, + { + "path": "docs/SECURITY.md", + "sha256": "01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac", + "bytes": 11185, + "lines": 64 + }, + { + "path": "docs/STORYBOARD.md", + "sha256": "6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2", + "bytes": 1342, + "lines": 28 + }, + { + "path": "docs/STORYBOOK.md", + "sha256": "82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9", + "bytes": 1389, + "lines": 50 + }, + { + "path": "docs/TEST_STRATEGY.md", + "sha256": "d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8", + "bytes": 16534, + "lines": 135 + }, + { + "path": "docs/THREAT_MODEL.md", + "sha256": "f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252", + "bytes": 6736, + "lines": 23 + }, + { + "path": "docs/TRACEABILITY.md", + "sha256": "94ebe6ff2a8ea13728eacea57f1f26139f3b01c211cf2f1da2b5cd165f7a8da7", + "bytes": 12270, + "lines": 42 + }, + { + "path": "docs/TRD.md", + "sha256": "23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077", + "bytes": 9064, + "lines": 101 + }, + { + "path": "docs/UML.md", + "sha256": "fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9", + "bytes": 5528, + "lines": 122 + }, + { + "path": "docs/USER_STORIES.md", + "sha256": "5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f", + "bytes": 2670, + "lines": 37 + }, + { + "path": "docs/WIREFRAMES.md", + "sha256": "b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e", + "bytes": 2005, + "lines": 77 + }, + { + "path": "docs/adr/0001-orgmetra-authoritative-hris-record.md", + "sha256": "0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572", + "bytes": 6108, + "lines": 53 + }, + { + "path": "docs/adr/0002-federated-cwl-integration-boundaries.md", + "sha256": "b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2", + "bytes": 4072, + "lines": 44 + }, + { + "path": "docs/adr/0003-bitemporal-hris-data-contract.md", + "sha256": "d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799", + "bytes": 4453, + "lines": 47 + }, + { + "path": "docs/adr/0004-employment-position-version-and-assignment-binding.md", + "sha256": "fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182", + "bytes": 1872, + "lines": 30 + }, + { + "path": "docs/adr/0005-exclusive-employment-and-staffable-seats.md", + "sha256": "10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b", + "bytes": 2091, + "lines": 34 + }, + { + "path": "docs/adr/0006-governed-audit-outbox-envelope.md", + "sha256": "827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd", + "bytes": 14100, + "lines": 66 + }, + { + "path": "docs/adr/0007-governed-job-analysis-evidence.md", + "sha256": "953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52", + "bytes": 5653, + "lines": 57 + }, + { + "path": "docs/adr/0008-purpose-bound-pii-authorization.md", + "sha256": "c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7", + "bytes": 5988, + "lines": 55 + }, + { + "path": "docs/adr/0009-performance-criterion-observation-scope.md", + "sha256": "1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64", + "bytes": 7057, + "lines": 57 + }, + { + "path": "docs/adr/0010-naruon-calendar-intent-boundary.md", + "sha256": "3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9", + "bytes": 3917, + "lines": 35 + }, + { + "path": "docs/adr/0011-bitemporal-workforce-composition.md", + "sha256": "1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b", + "bytes": 5568, + "lines": 53 + }, + { + "path": "docs/adr/0012-governed-migration-handoff.md", + "sha256": "713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80", + "bytes": 5965, + "lines": 59 + }, + { + "path": "docs/adr/0013-governed-requisition-review-packet.md", + "sha256": "70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802", + "bytes": 4693, + "lines": 46 + }, + { + "path": "docs/adr/0014-job-analysis-snapshot-persistence.md", + "sha256": "a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105", + "bytes": 5365, + "lines": 49 + }, + { + "path": "docs/adr/0015-explicit-assignment-category.md", + "sha256": "a89cf0381d7896c5671a0bbe463184fa0cf686406dfe62dfb7faba58b29e0c58", + "bytes": 8360, + "lines": 72 + }, + { + "path": "docs/adr/README.md", + "sha256": "070854c47de36ea53d62cc9ce3c102fc4fdbc809ffb0e33b0c157ddddeee41d6", + "bytes": 1988, + "lines": 19 + }, + { + "path": "docs/doctoring/REFERENCES.md", + "sha256": "929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5", + "bytes": 6352, + "lines": 69 + }, + { + "path": "docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md", + "sha256": "b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd", + "bytes": 8227, + "lines": 226 + }, + { + "path": "docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md", + "sha256": "4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d", + "bytes": 6237, + "lines": 187 + }, + { + "path": "package.json", + "sha256": "59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5", + "bytes": 388, + "lines": 9 + }, + { + "path": "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", + "sha256": "3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190", + "bytes": 7707, + "lines": 160 + }, + { + "path": "packages/hris-kernel/tests/test_audit_outbox.py", + "sha256": "5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c", + "bytes": 7556, + "lines": 200 + }, + { + "path": "schemas/openapi.yaml", + "sha256": "00e80a29850f9b607d9fc6afc8d58177d94eecd968d5f29bc19e12d902fd000f", + "bytes": 29648, + "lines": 1024 + }, + { + "path": "scripts/foundation-contract-core.mjs", + "sha256": "0ce6276b23727c97e5faf3e2fd7784e64a3a058014536628d9336de77f46a21f", + "bytes": 28436, + "lines": 694 + }, + { + "path": "scripts/foundation-contract.mjs", + "sha256": "5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a", + "bytes": 218, + "lines": 6 + }, + { + "path": "tests/assignment-category-provenance.test.mjs", + "sha256": "42ce6026533fc2e29339d0983f8a97334eaedcb02e252954edf826567b9ae766", + "bytes": 1013, + "lines": 21 + }, + { + "path": "tests/dispatcher-inventory.test.mjs", + "sha256": "09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261", + "bytes": 1597, + "lines": 34 + }, + { + "path": "tests/foundation-contract.test.mjs", + "sha256": "960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615", + "bytes": 14860, + "lines": 386 + }, + { + "path": "tests/openapi-contract.test.mjs", + "sha256": "debb3231b30051ae166151971b0ed15f915e8c59a215c4561d3c54b766e97e9b", + "bytes": 7248, + "lines": 214 + }, + { + "path": "tests/test_assignment_category_postgres.sh", + "sha256": "4cf959fe1f0962ff228f817b1c4ce6b4cd050cc56e865d4acec60c2dba702818", + "bytes": 10345, + "lines": 152 + }, + { + "path": "tests/test_audit_outbox_hardening_postgres.sh", + "sha256": "518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0", + "bytes": 13396, + "lines": 333 + }, + { + "path": "tests/test_audit_outbox_postgres.sh", + "sha256": "e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2", + "bytes": 13443, + "lines": 357 + }, + { + "path": "tests/test_bitemporal_postgres.sh", + "sha256": "7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc", + "bytes": 8209, + "lines": 230 + }, + { + "path": "tests/test_candidate_worker_conversion_postgres.sh", + "sha256": "681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90", + "bytes": 14673, + "lines": 344 + }, + { + "path": "tests/test_criterion_observation_scope_postgres.sh", + "sha256": "0ee9539ee57f840c27d0809f7868cdc8662669df78a01dbc8be39216b8f1a3d", + "bytes": 17811, + "lines": 469 + }, + { + "path": "tests/test_evidence_sealing_postgres.sh", + "sha256": "57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7", + "bytes": 11349, + "lines": 370 + }, + { + "path": "tests/test_job_analysis_snapshot_postgres.sh", + "sha256": "ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", + "bytes": 13542, + "lines": 296 + }, + { + "path": "tests/test_operational_uuid_postgres.sh", + "sha256": "7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7", + "bytes": 3346, + "lines": 101 + }, + { + "path": "tests/test_outbox_claim_postgres.sh", + "sha256": "1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b", + "bytes": 14817, + "lines": 429 + }, + { + "path": "tests/test_outbox_dead_letter_postgres.sh", + "sha256": "0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d", + "bytes": 14008, + "lines": 377 + }, + { + "path": "tests/test_people_mutation_idempotency_postgres.sh", + "sha256": "3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5", + "bytes": 16191, + "lines": 381 + }, + { + "path": "tests/test_tenant_isolation_postgres.sh", + "sha256": "dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a", + "bytes": 15134, + "lines": 388 + }, + { + "path": "tests/test_validity_study_case_postgres.sh", + "sha256": "0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02", + "bytes": 14708, + "lines": 301 + }, + { + "path": "tests/validate_repository.py", + "sha256": "971c2820a6f4d845bae90044e47de369d0d270f8129dfe1ab5469d18da2a57d8", + "bytes": 27564, + "lines": 643 + } + ] +} \ No newline at end of file diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py index 710ecbcd7..d63546556 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py @@ -20,11 +20,12 @@ _ZERO = Decimal("0") _ASSIGNMENT_ELIGIBLE_EMPLOYMENT_STATUSES = frozenset({"active", "leave"}) _STAFFABLE_POSITION_STATUSES = frozenset({"active", "open"}) +_ASSIGNMENT_CATEGORY_CODES = frozenset({"primary", "concurrent_secondary", "legacy_unspecified"}) def _ratio_is_valid(allocation_ratio: Decimal) -> bool: - """Return whether one assignment row stays inside (0, 1.0000].""" - return allocation_ratio > _ZERO and allocation_ratio <= _ONE + """Return whether one exact Decimal assignment row stays inside (0, 1.0000].""" + return type(allocation_ratio) is Decimal and allocation_ratio > _ZERO and allocation_ratio <= _ONE def _union_covers(intervals: list[DateInterval], target: DateInterval) -> bool: @@ -48,8 +49,12 @@ def validate_assignment_portfolio( employment_record_id: UUID, effective_on: date, known_at: datetime, -) -> None: - """Reject invalid ratios or a visible allocation total above 1.0000. +) -> list[AssignmentFact]: + """Reject invalid ratios, categories, duplicate primaries, or allocation above 1.0000. + + ``legacy_unspecified`` is accepted only as historical truth. This validator + never reclassifies it from allocation ratio, insertion order, or any other + heuristic. Args: assignments: Candidate assignment facts, including other tenants and people. @@ -59,8 +64,11 @@ def validate_assignment_portfolio( effective_on: The day whose split is being reviewed. known_at: The knowledge cutoff used for the review. + Returns: + The visible assignment facts at the requested bitemporal coordinate. + Raises: - AssignmentPortfolioError: Reduce one allocation, then save again. + AssignmentPortfolioError: Correct the category or allocation, then save again. """ scoped = [ fact @@ -70,6 +78,14 @@ def validate_assignment_portfolio( and fact.employment_record_id == employment_record_id ] for fact in scoped: + if type(fact.assignment_category_code) is not str or fact.assignment_category_code not in _ASSIGNMENT_CATEGORY_CODES: + raise AssignmentPortfolioError( + "assignment_category_code is not a governed assignment classification.", + next_action=( + "Use primary or concurrent_secondary for new writes; preserve " + "legacy_unspecified only for historical rows." + ), + ) if not _ratio_is_valid(fact.allocation_ratio): raise AssignmentPortfolioError( "allocation_ratio must be greater than 0 and at most 1.0000.", @@ -83,12 +99,20 @@ def validate_assignment_portfolio( effective_on=effective_on, known_at=known_at, ) + if sum(fact.assignment_category_code == "primary" for fact in visible) > 1: + raise AssignmentPortfolioError( + "One employment cannot have two visible primary assignments.", + next_action=( + "Keep one primary assignment and record additional assignments as concurrent_secondary." + ), + ) total = sum((fact.allocation_ratio for fact in visible), start=_ZERO) if total > _ONE: raise AssignmentPortfolioError( "Visible allocations for one employment exceed 1.0000.", next_action="Reduce one assignment so the employment total is at most 1.0000.", ) + return visible def validate_assignment_employment_coverage( @@ -212,6 +236,12 @@ def validate_position_seat_capacity( if fact.tenant_record_id == tenant_record_id and fact.position_record_id == position_record_id ] + for fact in scoped: + if not _ratio_is_valid(fact.allocation_ratio): + raise PositionSeatError( + "allocation_ratio must be greater than 0 and at most 1.0000.", + next_action="Enter an allocation between 0.0001 and 1.0000, then save.", + ) visible = resolve_bitemporal_facts( scoped, tenant_record_id=tenant_record_id, diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py b/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py index 3ce020b6b..1c6ec0137 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py @@ -63,7 +63,14 @@ class PositionVersion: @dataclass(frozen=True, slots=True) class AssignmentFact: - """One recorded assignment of a person, through one employment, to a position.""" + """One recorded assignment of a person, through one employment, to a position. + + ``assignment_category_code`` is authoritative HRIS truth. Historical rows + created before the explicit classification contract remain + ``legacy_unspecified``; restoration and historical fixtures must state that + value explicitly, while every new public Assignment fact must provide its + classification rather than inheriting a default. + """ tenant_record_id: UUID assignment_record_id: UUID @@ -73,3 +80,4 @@ class AssignmentFact: allocation_ratio: Decimal effective: DateInterval recorded: RecordedInterval + assignment_category_code: str diff --git a/packages/hris-kernel/tests/conftest.py b/packages/hris-kernel/tests/conftest.py index 666d11c9b..8f4c9cfe2 100644 --- a/packages/hris-kernel/tests/conftest.py +++ b/packages/hris-kernel/tests/conftest.py @@ -63,6 +63,7 @@ def jordan_icu_assignment() -> AssignmentFact: allocation_ratio=Decimal("0.8000"), effective=effective(date(2024, 3, 1)), recorded=recorded(utc(2024, 3, 1, 16)), + assignment_category_code="legacy_unspecified", ) @@ -78,4 +79,5 @@ def jordan_float_assignment() -> AssignmentFact: allocation_ratio=Decimal("0.2000"), effective=effective(date(2024, 3, 1)), recorded=recorded(utc(2024, 3, 1, 16)), + assignment_category_code="legacy_unspecified", ) diff --git a/packages/hris-kernel/tests/test_assignment_category_constructor_contract.py b/packages/hris-kernel/tests/test_assignment_category_constructor_contract.py new file mode 100644 index 000000000..f1036ae97 --- /dev/null +++ b/packages/hris-kernel/tests/test_assignment_category_constructor_contract.py @@ -0,0 +1,30 @@ +"""Regression for explicit Assignment category construction semantics.""" + +from datetime import date, datetime, timezone +from decimal import Decimal +import unittest +from uuid import UUID + +from orgmetra_hris_kernel import AssignmentFact, DateInterval, RecordedInterval + + +class AssignmentCategoryConstructorContractTests(unittest.TestCase): + """Keep legacy classification confined to explicit restoration/fixture paths.""" + + def test_public_assignment_fact_requires_explicit_category(self) -> None: + """Reject omission instead of silently creating a new legacy-unspecified fact.""" + with self.assertRaises(TypeError): + AssignmentFact( + tenant_record_id=UUID("10000000-0000-7000-8000-000000000101"), + assignment_record_id=UUID("10000000-0000-7000-8000-000000000301"), + employment_record_id=UUID("10000000-0000-7000-8000-000000000104"), + person_record_id=UUID("10000000-0000-7000-8000-000000000102"), + position_record_id=UUID("10000000-0000-7000-8000-000000000106"), + allocation_ratio=Decimal("1.0000"), + effective=DateInterval(date(2024, 3, 1)), + recorded=RecordedInterval(datetime(2024, 3, 1, 16, tzinfo=timezone.utc)), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py b/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py new file mode 100644 index 000000000..20cb01199 --- /dev/null +++ b/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py @@ -0,0 +1,67 @@ +"""Assignment allocation runtime-integrity regressions.""" + +from dataclasses import replace +from datetime import date +from decimal import Decimal + +import pytest + +from orgmetra_hris_kernel import ( + AssignmentPortfolioError, + PositionSeatError, + validate_assignment_portfolio, + validate_position_seat_capacity, +) + +from .conftest import ICU_POSITION, JORDAN, JORDAN_EMPLOYMENT, TENANT, utc + + +class ForgedAllocationRatio(Decimal): + """Expose an invalid numeric value while spoofing governed range comparisons.""" + + def __gt__(self, other: object) -> bool: + """Pretend the negative allocation is greater than the lower bound.""" + return True + + def __le__(self, other: object) -> bool: + """Pretend the negative allocation is no greater than the upper bound.""" + return True + + +def test_portfolio_rejects_decimal_subclass_before_ratio_comparisons( + jordan_icu_assignment, +) -> None: + """Caller-controlled Decimal behavior must not decide an Employment allocation invariant.""" + forged = replace( + jordan_icu_assignment, + allocation_ratio=ForgedAllocationRatio("-0.5000"), + ) + + with pytest.raises(AssignmentPortfolioError, match="allocation_ratio"): + validate_assignment_portfolio( + [forged], + tenant_record_id=TENANT, + person_record_id=JORDAN, + employment_record_id=JORDAN_EMPLOYMENT, + effective_on=date(2024, 5, 1), + known_at=utc(2024, 5, 1), + ) + + +def test_position_capacity_rejects_decimal_subclass_before_aggregation( + jordan_icu_assignment, +) -> None: + """Caller-controlled Decimal behavior must not decide a Position capacity invariant.""" + forged = replace( + jordan_icu_assignment, + allocation_ratio=ForgedAllocationRatio("-0.5000"), + ) + + with pytest.raises(PositionSeatError, match="allocation_ratio"): + validate_position_seat_capacity( + [forged], + tenant_record_id=TENANT, + position_record_id=ICU_POSITION, + effective_on=date(2024, 5, 1), + known_at=utc(2024, 5, 1), + ) diff --git a/packages/hris-kernel/tests/test_assignment_portfolio.py b/packages/hris-kernel/tests/test_assignment_portfolio.py index b064f3350..bf541ed69 100644 --- a/packages/hris-kernel/tests/test_assignment_portfolio.py +++ b/packages/hris-kernel/tests/test_assignment_portfolio.py @@ -80,6 +80,7 @@ def test_portfolio_ignores_another_person_and_another_employment( allocation_ratio=Decimal("1.0000"), effective=effective(date(2024, 3, 1)), recorded=recorded(utc(2024, 3, 1, 16)), + assignment_category_code="legacy_unspecified", ) validate_assignment_portfolio( [jordan_icu_assignment, riley], @@ -133,6 +134,48 @@ def test_portfolio_rejects_non_positive_or_oversized_ratio(jordan_icu_assignment ) + +def test_portfolio_rejects_ungoverned_assignment_categories( + jordan_icu_assignment, +) -> None: + """HR must choose a governed string category before saving an assignment.""" + invalid_type = replace(jordan_icu_assignment, assignment_category_code=1) + invalid_code = replace(jordan_icu_assignment, assignment_category_code="lead") + + for invalid in (invalid_type, invalid_code): + with pytest.raises(AssignmentPortfolioError, match="classification"): + validate_assignment_portfolio( + [invalid], + tenant_record_id=TENANT, + person_record_id=JORDAN, + employment_record_id=JORDAN_EMPLOYMENT, + effective_on=date(2024, 5, 1), + known_at=utc(2024, 5, 1), + ) + + +def test_portfolio_rejects_two_visible_primary_assignments( + jordan_icu_assignment, + jordan_float_assignment, +) -> None: + """HR must keep one primary and mark simultaneous additional work secondary.""" + primary_icu = replace(jordan_icu_assignment, assignment_category_code="primary") + primary_float = replace( + jordan_float_assignment, + assignment_category_code="primary", + ) + + with pytest.raises(AssignmentPortfolioError, match="two visible primary"): + validate_assignment_portfolio( + [primary_icu, primary_float], + tenant_record_id=TENANT, + person_record_id=JORDAN, + employment_record_id=JORDAN_EMPLOYMENT, + effective_on=date(2024, 5, 1), + known_at=utc(2024, 5, 1), + ) + + def test_assignment_requires_covering_active_employment( jordan_icu_assignment, jordan_active_employment, diff --git a/packages/hris-kernel/tests/test_hospital_assignment_lifecycle.py b/packages/hris-kernel/tests/test_hospital_assignment_lifecycle.py index b3a01584b..396b15d46 100644 --- a/packages/hris-kernel/tests/test_hospital_assignment_lifecycle.py +++ b/packages/hris-kernel/tests/test_hospital_assignment_lifecycle.py @@ -63,6 +63,7 @@ def test_june_correction_changes_may_history_only_after_it_is_recorded( allocation_ratio=Decimal("1.0000"), effective=effective(date(2024, 4, 1)), recorded=recorded(utc(2024, 6, 15, 10)), + assignment_category_code="legacy_unspecified", ) original_through_march = replace( jordan_icu_assignment, diff --git a/packages/hris-kernel/tests/test_workforce_composition.py b/packages/hris-kernel/tests/test_workforce_composition.py index 6a2988508..4db39b38d 100644 --- a/packages/hris-kernel/tests/test_workforce_composition.py +++ b/packages/hris-kernel/tests/test_workforce_composition.py @@ -65,7 +65,7 @@ def _assignment( tenant_id: int = 1, recorded: RecordedInterval | None = None, ) -> AssignmentFact: - """Build one visible position assignment.""" + """Build one visible historical-fixture position assignment.""" return AssignmentFact( tenant_record_id=_id(tenant_id), assignment_record_id=_id(assignment_id), @@ -75,6 +75,7 @@ def _assignment( allocation_ratio=Decimal(ratio), effective=DateInterval(date(2026, 1, 1)), recorded=recorded or _recorded(), + assignment_category_code="legacy_unspecified", ) diff --git a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py index 4661d5af6..59f6ae36a 100644 --- a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py +++ b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py @@ -97,9 +97,39 @@ def test_snapshot_excludes_future_business_and_late_recorded_facts() -> None: EmploymentVersion(_id(1), _id(103), _id(1003), _id(13), "active", january, known_late), ] assignments = [ - AssignmentFact(_id(1), _id(201), _id(101), _id(11), _id(1201), Decimal("0.7500"), january, known_from_start), - AssignmentFact(_id(1), _id(202), _id(102), _id(12), _id(1202), Decimal("1.0000"), february, known_from_start), - AssignmentFact(_id(1), _id(203), _id(101), _id(11), _id(1203), Decimal("0.1000"), january, known_late), + AssignmentFact( + _id(1), + _id(201), + _id(101), + _id(11), + _id(1201), + Decimal("0.7500"), + january, + known_from_start, + "legacy_unspecified", + ), + AssignmentFact( + _id(1), + _id(202), + _id(102), + _id(12), + _id(1202), + Decimal("1.0000"), + february, + known_from_start, + "legacy_unspecified", + ), + AssignmentFact( + _id(1), + _id(203), + _id(101), + _id(11), + _id(1203), + Decimal("0.1000"), + january, + known_late, + "legacy_unspecified", + ), ] snapshot = build_workforce_composition_snapshot( diff --git a/packages/hris-kernel/tests/test_workforce_position_capacity.py b/packages/hris-kernel/tests/test_workforce_position_capacity.py index 067539842..1092dfd2f 100644 --- a/packages/hris-kernel/tests/test_workforce_position_capacity.py +++ b/packages/hris-kernel/tests/test_workforce_position_capacity.py @@ -30,8 +30,28 @@ def test_snapshot_rejects_position_seat_overallocation() -> None: EmploymentVersion(_id(1), _id(102), _id(1002), _id(12), "active", effective, known), ] assignments = [ - AssignmentFact(_id(1), _id(201), _id(101), _id(11), _id(9001), Decimal("0.6000"), effective, known), - AssignmentFact(_id(1), _id(202), _id(102), _id(12), _id(9001), Decimal("0.6000"), effective, known), + AssignmentFact( + _id(1), + _id(201), + _id(101), + _id(11), + _id(9001), + Decimal("0.6000"), + effective, + known, + "legacy_unspecified", + ), + AssignmentFact( + _id(1), + _id(202), + _id(102), + _id(12), + _id(9001), + Decimal("0.6000"), + effective, + known, + "legacy_unspecified", + ), ] with pytest.raises(PositionSeatError, match="exceed"): diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e92..9d2c2ba8b 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -625,6 +625,7 @@ components: - person_record_id - position_record_id - allocation_ratio + - assignment_category_code - effective_from - decision_reason - confirmation_reference @@ -642,6 +643,9 @@ components: allocation_ratio: type: string pattern: '^(0\.[0-9]{4}|1\.0000)$' + assignment_category_code: + type: string + pattern: '^(primary|concurrent_secondary)$' effective_from: type: string format: date @@ -1017,4 +1021,4 @@ components: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ErrorResponse' \ No newline at end of file diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267c..f7831c2f1 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -20,6 +20,7 @@ export const REQUIRED_FILES = Object.freeze([ 'NOTICE', 'manifest.json', 'package.json', + '.github/workflows/assignment-category-quality.yml', '.github/workflows/foundation-ci.yml', '.github/workflows/job-analysis-api-quality.yml', 'docs/PRD.md', @@ -52,6 +53,7 @@ export const REQUIRED_FILES = Object.freeze([ 'docs/adr/0012-governed-migration-handoff.md', 'docs/adr/0013-governed-requisition-review-packet.md', 'docs/adr/0014-job-analysis-snapshot-persistence.md', + 'docs/adr/0015-explicit-assignment-category.md', 'docs/doctoring/REFERENCES.md', 'docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md', 'docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md', @@ -68,11 +70,13 @@ export const REQUIRED_FILES = Object.freeze([ 'database/migrations/0011_criterion_observation_scope.sql', 'database/migrations/0012_people_mutation_idempotency.sql', 'database/migrations/0013_job_analysis_snapshot.sql', + 'database/migrations/0017_assignment_category_code.sql', 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', 'scripts/foundation-contract-core.mjs', 'scripts/foundation-contract.mjs', + 'tests/assignment-category-provenance.test.mjs', 'tests/dispatcher-inventory.test.mjs', 'tests/foundation-contract.test.mjs', 'tests/openapi-contract.test.mjs', @@ -89,6 +93,7 @@ export const REQUIRED_FILES = Object.freeze([ 'tests/test_criterion_observation_scope_postgres.sh', 'tests/test_people_mutation_idempotency_postgres.sh', 'tests/test_job_analysis_snapshot_postgres.sh', + 'tests/test_assignment_category_postgres.sh', 'tests/validate_repository.py' ]); @@ -686,4 +691,4 @@ export function runCli(rootPath, outputStream = process.stdout, errorStream = pr } errorStream.write(`${JSON.stringify({ status: 'failed', error_count: errors.length, errors }, null, 2)}\n`); return 1; -} +} \ No newline at end of file diff --git a/services/people-api/src/orgmetra_people_api/mutation_http.py b/services/people-api/src/orgmetra_people_api/mutation_http.py index 5158e9871..17b9abdf8 100644 --- a/services/people-api/src/orgmetra_people_api/mutation_http.py +++ b/services/people-api/src/orgmetra_people_api/mutation_http.py @@ -75,6 +75,7 @@ "person_record_id", "position_record_id", "allocation_ratio", + "assignment_category_code", "effective_from", "decision_reason", "confirmation_reference", @@ -584,6 +585,7 @@ def _command_for_route( audit_event_record_id=id_factory(), outbox_delivery_record_id=id_factory(), allocation_ratio=parse_allocation_ratio(payload["allocation_ratio"]), + assignment_category_code=_require_string_field(payload, "assignment_category_code"), effective_from=effective_from, confirmation_reference=confirmation_reference, evidence_version_code=evidence_version_code, @@ -634,4 +636,4 @@ def _dispatch_mutation( mutation_port=app.mutation_port, ) created = str(result.assignment_record_id) - return {"assignment_record_id": created}, f"/v1/assignment-records/{created}" \ No newline at end of file + return {"assignment_record_id": created}, f"/v1/assignment-records/{created}" diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 6baeac684..4442fdb7b 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -33,6 +33,7 @@ _EMPLOYMENT_STATUSES = frozenset({"active", "leave", "terminated"}) _CONCURRENCY_CODES = frozenset({"exclusive", "concurrent"}) _POSITION_STATUSES = frozenset({"active", "open", "closed", "frozen", "abolished"}) +_NEW_ASSIGNMENT_CATEGORY_CODES = frozenset({"primary", "concurrent_secondary"}) _EMPLOYMENT_FIELDS = frozenset({"employment_record"}) _POSITION_FIELDS = frozenset({"position_record"}) _ASSIGNMENT_FIELDS = frozenset({"assignment_record"}) @@ -141,6 +142,7 @@ def mutation_command_digest( route = "assignment-records" semantic_command = { "allocation_ratio": _canonical_allocation_ratio(command.allocation_ratio), + "assignment_category_code": command.assignment_category_code, "confirmation_reference": command.confirmation_reference, "effective_from": command.effective_from.isoformat(), "employment_record_id": str(command.employment_record_id), @@ -257,6 +259,7 @@ class AssignmentMutationCommand: confirmation_reference: str evidence_version_code: str idempotency_key: str + assignment_category_code: str def __post_init__(self) -> None: """Fail closed before authorization or persistence on malformed input.""" @@ -272,7 +275,7 @@ def __post_init__(self) -> None: _validate_operational_uuid(field_name, getattr(self, field_name)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") - if not isinstance(self.allocation_ratio, Decimal): + if type(self.allocation_ratio) is not Decimal: raise ValueError("allocation_ratio must be a Decimal.") if not self.allocation_ratio.is_finite(): raise ValueError("allocation_ratio must be finite.") @@ -280,6 +283,13 @@ def __post_init__(self) -> None: raise ValueError("allocation_ratio must be greater than 0 and at most 1.0000.") if self.allocation_ratio.as_tuple().exponent < -4: raise ValueError("allocation_ratio must have at most four decimal places.") + if ( + type(self.assignment_category_code) is not str + or self.assignment_category_code not in _NEW_ASSIGNMENT_CATEGORY_CODES + ): + raise ValueError( + "assignment_category_code must be primary or concurrent_secondary for new writes." + ) _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) @@ -445,4 +455,4 @@ def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" if not isinstance(raw_value, str) or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) + return Decimal(raw_value) \ No newline at end of file diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index d94832cf8..d6ed5ebf7 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -52,6 +52,7 @@ _EMPLOYMENT_FIELDS = frozenset({"employment_record"}) _POSITION_FIELDS = frozenset({"position_record"}) _ASSIGNMENT_FIELDS = frozenset({"assignment_record"}) +_PERSISTED_ASSIGNMENT_CATEGORY_CODES = frozenset({"legacy_unspecified", "primary", "concurrent_secondary"}) _CONVERSION_SQL = """ SELECT @@ -184,6 +185,7 @@ assignment.person_record_id, assignment.position_record_id, assignment.allocation_ratio, + assignment.assignment_category_code, assignment.effective_from, assignment.effective_to, assignment.recorded_from, @@ -204,9 +206,10 @@ person_record_id, position_record_id, allocation_ratio, + assignment_category_code, effective_from, recorded_from -) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) +) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """.strip() _LOOKUP_IDEMPOTENCY_SQL = """ @@ -441,8 +444,8 @@ def _position_version_from_row(tenant_record_id: UUID, row: tuple[object, ...]) def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> AssignmentFact: - """Reconstruct one assignment fact used by the assignment kernel.""" - if len(row) != 9: + """Reconstruct one assignment fact used by the assignment kernel without heuristic classification.""" + if len(row) != 10: raise PeopleMutationIntegrityError("assignment row has an invalid shape") ( assignment_record_id, @@ -450,6 +453,7 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass person_record_id, position_record_id, allocation_ratio, + assignment_category_code, effective_from, effective_to, recorded_from, @@ -461,6 +465,8 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass or not _is_operational_uuid(person_record_id) or not _is_operational_uuid(position_record_id) or not isinstance(allocation_ratio, Decimal) + or type(assignment_category_code) is not str + or assignment_category_code not in _PERSISTED_ASSIGNMENT_CATEGORY_CODES or type(effective_from) is not date or (effective_to is not None and type(effective_to) is not date) or not _is_aware_datetime(recorded_from) @@ -482,6 +488,7 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass allocation_ratio=allocation_ratio, effective=DateInterval(effective_from, effective_to if isinstance(effective_to, date) else None), recorded=RecordedInterval(recorded_from, recorded_to if isinstance(recorded_to, datetime) else None), + assignment_category_code=assignment_category_code, ) @@ -783,6 +790,7 @@ def create_assignment( allocation_ratio=command.allocation_ratio, effective=DateInterval(command.effective_from), recorded=RecordedInterval(recorded_at), + assignment_category_code=command.assignment_category_code, ) try: validate_assignment_write( @@ -803,6 +811,7 @@ def create_assignment( command.person_record_id, command.position_record_id, command.allocation_ratio, + command.assignment_category_code, command.effective_from, recorded_at, ), diff --git a/services/people-api/tests/test_assignment_allocation_runtime_integrity.py b/services/people-api/tests/test_assignment_allocation_runtime_integrity.py new file mode 100644 index 000000000..eda9fe0e2 --- /dev/null +++ b/services/people-api/tests/test_assignment_allocation_runtime_integrity.py @@ -0,0 +1,41 @@ +"""Assignment command allocation runtime-integrity regressions.""" + +from datetime import date +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_people_api.mutations import AssignmentMutationCommand + + +class ForgedCommandAllocationRatio(Decimal): + """Hide an invalid negative value from overloaded range comparisons.""" + + def __le__(self, other: object) -> bool: + """Pretend the negative value is above the command lower bound.""" + return False + + def __gt__(self, other: object) -> bool: + """Pretend the negative value is below the command upper bound.""" + return False + + +def test_assignment_command_rejects_decimal_subclass_before_numeric_methods() -> None: + """Executable Decimal subtype behavior must not enter a governed command.""" + with pytest.raises(ValueError, match="Decimal"): + AssignmentMutationCommand( + tenant_record_id=UUID("0198a412-8000-7000-8000-000000000001"), + employment_record_id=UUID("0198a412-8000-7000-8000-000000000030"), + person_record_id=UUID("0198a412-8000-7000-8000-000000000020"), + position_record_id=UUID("0198a412-8000-7000-8000-000000000040"), + assignment_record_id=UUID("0198a412-8000-7000-8000-000000000070"), + audit_event_record_id=UUID("0198a412-8000-7000-8000-000000000080"), + outbox_delivery_record_id=UUID("0198a412-8000-7000-8000-000000000081"), + allocation_ratio=ForgedCommandAllocationRatio("-0.5000"), + effective_from=date(2026, 8, 18), + confirmation_reference="human_confirmation:review-88", + evidence_version_code="decision_evidence_set:v1", + idempotency_key="idempotency-key-17xx", + assignment_category_code="primary", + ) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py new file mode 100644 index 000000000..1bcac9db1 --- /dev/null +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -0,0 +1,241 @@ +"""Regression contract for explicit, non-heuristic assignment classification.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal +import unittest +from uuid import UUID + +from orgmetra_hris_kernel import ( + AssignmentFact, + AssignmentPortfolioError, + DateInterval, + RecordedInterval, + validate_assignment_portfolio, +) +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + PeopleMutationIntegrityError, + mutation_command_digest, +) +from orgmetra_people_api.postgres_mutations import _assignment_from_row + +TENANT = UUID("0199a412-9200-7000-8000-000000000001") +PERSON = UUID("0199a412-9200-7000-8000-000000000002") +EMPLOYMENT = UUID("0199a412-9200-7000-8000-000000000003") +PRIMARY_POSITION = UUID("0199a412-9200-7000-8000-000000000004") +SECONDARY_POSITION = UUID("0199a412-9200-7000-8000-000000000005") +ASSIGNMENT_A = UUID("0199a412-9200-7000-8000-000000000006") +ASSIGNMENT_B = UUID("0199a412-9200-7000-8000-000000000007") +AUDIT = UUID("0199a412-9200-7000-8000-000000000008") +OUTBOX = UUID("0199a412-9200-7000-8000-000000000009") +KNOWN_AT = datetime(2026, 9, 2, 5, 0, tzinfo=timezone.utc) + + +class ForgedAssignmentCategory(str): + """Spoof governed membership while retaining different serialized text.""" + + def __hash__(self) -> int: + return hash("primary") + + def __eq__(self, other: object) -> bool: + return other == "primary" + + +def assignment_fact(*, assignment_id: UUID, position_id: UUID, category: str) -> AssignmentFact: + """Build one visible assignment with an explicit classification code.""" + return AssignmentFact( + tenant_record_id=TENANT, + assignment_record_id=assignment_id, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=position_id, + allocation_ratio=Decimal("0.5000"), + effective=DateInterval(date(2026, 9, 1)), + recorded=RecordedInterval(KNOWN_AT), + assignment_category_code=category, + ) + + +def assignment_command(*, category: str) -> AssignmentMutationCommand: + """Build one governed assignment command with an explicit classification code.""" + return AssignmentMutationCommand( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=PRIMARY_POSITION, + assignment_record_id=ASSIGNMENT_A, + audit_event_record_id=AUDIT, + outbox_delivery_record_id=OUTBOX, + allocation_ratio=Decimal("1.0000"), + effective_from=date(2026, 9, 1), + confirmation_reference="human_confirmation:assignment-162", + evidence_version_code="assignment-evidence-v1", + idempotency_key="assignment-category-contract-162", + assignment_category_code=category, + ) + + +def authorization() -> AuthorizationDecision: + """Return exact allow evidence used only to prove idempotency semantics.""" + fields = frozenset({"assignment_record"}) + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-162", + resource_reference=f"assignment_record:{ASSIGNMENT_A.hex}", + policy_version_code="assignment-policy-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="assignment_record", + requested_fields=fields, + authorized_fields=fields, + reason_code="allowed", + next_action="Continue with only the authorized fields.", + ) + + +class AssignmentCategoryContractTests(unittest.TestCase): + """Keep assignment semantics explicit instead of inferring them from allocation or order.""" + + def test_portfolio_allows_one_primary_plus_explicit_secondary(self) -> None: + assignments = [ + assignment_fact(assignment_id=ASSIGNMENT_A, position_id=PRIMARY_POSITION, category="primary"), + assignment_fact( + assignment_id=ASSIGNMENT_B, + position_id=SECONDARY_POSITION, + category="concurrent_secondary", + ), + ] + + visible = validate_assignment_portfolio( + assignments, + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + effective_on=date(2026, 9, 2), + known_at=KNOWN_AT, + ) + + self.assertEqual([fact.assignment_category_code for fact in visible], ["primary", "concurrent_secondary"]) + + def test_portfolio_rejects_two_visible_primary_assignments(self) -> None: + assignments = [ + assignment_fact(assignment_id=ASSIGNMENT_A, position_id=PRIMARY_POSITION, category="primary"), + assignment_fact(assignment_id=ASSIGNMENT_B, position_id=SECONDARY_POSITION, category="primary"), + ] + + with self.assertRaisesRegex(AssignmentPortfolioError, "primary"): + validate_assignment_portfolio( + assignments, + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + effective_on=date(2026, 9, 2), + known_at=KNOWN_AT, + ) + + def test_legacy_unspecified_is_preserved_without_heuristic_classification(self) -> None: + historical = assignment_fact( + assignment_id=ASSIGNMENT_A, + position_id=PRIMARY_POSITION, + category="legacy_unspecified", + ) + + visible = validate_assignment_portfolio( + [historical], + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + effective_on=date(2026, 9, 2), + known_at=KNOWN_AT, + ) + + self.assertEqual(visible[0].assignment_category_code, "legacy_unspecified") + + def test_portfolio_rejects_category_string_subclass_spoofing(self) -> None: + forged = ForgedAssignmentCategory("not_a_governed_category") + self.assertEqual(forged, "primary") + + with self.assertRaisesRegex(AssignmentPortfolioError, "assignment_category_code"): + validate_assignment_portfolio( + [ + assignment_fact( + assignment_id=ASSIGNMENT_A, + position_id=PRIMARY_POSITION, + category=forged, + ) + ], + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + effective_on=date(2026, 9, 2), + known_at=KNOWN_AT, + ) + + def test_new_write_requires_primary_or_concurrent_secondary(self) -> None: + self.assertEqual(assignment_command(category="primary").assignment_category_code, "primary") + self.assertEqual( + assignment_command(category="concurrent_secondary").assignment_category_code, + "concurrent_secondary", + ) + for invalid in ("legacy_unspecified", "secondary", "", "primary_assignment", None): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "assignment_category_code"): + assignment_command(category=invalid) # type: ignore[arg-type] + + def test_new_write_rejects_category_string_subclass_spoofing(self) -> None: + forged = ForgedAssignmentCategory("legacy_unspecified") + self.assertEqual(forged, "primary") + + with self.assertRaisesRegex(ValueError, "assignment_category_code"): + assignment_command(category=forged) + + def test_persistence_reconstruction_rejects_category_string_subclass_spoofing(self) -> None: + forged = ForgedAssignmentCategory("not_a_governed_category") + row = ( + ASSIGNMENT_A, + EMPLOYMENT, + PERSON, + PRIMARY_POSITION, + Decimal("1.0000"), + forged, + date(2026, 9, 1), + None, + KNOWN_AT, + None, + ) + + with self.assertRaisesRegex(PeopleMutationIntegrityError, "assignment row is invalid"): + _assignment_from_row(TENANT, row) + + def test_persistence_reconstruction_rejects_unknown_builtin_category(self) -> None: + row = ( + ASSIGNMENT_A, + EMPLOYMENT, + PERSON, + PRIMARY_POSITION, + Decimal("1.0000"), + "secondary", + date(2026, 9, 1), + None, + KNOWN_AT, + None, + ) + + with self.assertRaisesRegex(PeopleMutationIntegrityError, "assignment row is invalid"): + _assignment_from_row(TENANT, row) + + def test_idempotency_digest_includes_assignment_category(self) -> None: + primary = mutation_command_digest(command=assignment_command(category="primary"), authorization=authorization()) + secondary = mutation_command_digest( + command=assignment_command(category="concurrent_secondary"), + authorization=authorization(), + ) + + self.assertNotEqual(primary, secondary) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_mutation_http_route.py b/services/people-api/tests/test_mutation_http_route.py index 941580e40..3fe59fdd2 100644 --- a/services/people-api/tests/test_mutation_http_route.py +++ b/services/people-api/tests/test_mutation_http_route.py @@ -133,13 +133,14 @@ def position_body() -> bytes: def assignment_body() -> bytes: - """Return one canonical assignment command body.""" + """Return one canonical assignment command body with explicit HR classification.""" return json.dumps( { "employment_record_id": str(EMPLOYMENT), "person_record_id": str(PERSON), "position_record_id": str(POSITION), "allocation_ratio": "1.0000", + "assignment_category_code": "primary", "effective_from": "2026-08-18", "decision_reason": "Assign the hired worker to the open seat.", "confirmation_reference": "human_confirmation:review-88", @@ -310,6 +311,7 @@ async def test_post_routes_return_opaque_created_identities(self) -> None: ) self.assertEqual((assignment_status, assignment_payload), (201, {"assignment_record_id": str(ASSIGNMENT)})) self.assertEqual(port.assignment_calls[0][0].allocation_ratio, Decimal("1.0000")) + self.assertEqual(port.assignment_calls[0][0].assignment_category_code, "primary") self.assertEqual(port.assignment_calls[0][0].idempotency_key, "idempotency-key-17xx") async def test_route_header_and_media_input_fail_before_authentication(self) -> None: @@ -494,4 +496,4 @@ async def send(message: dict[str, object]) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/services/people-api/tests/test_people_mutations.py b/services/people-api/tests/test_people_mutations.py index b629355e9..9d8112211 100644 --- a/services/people-api/tests/test_people_mutations.py +++ b/services/people-api/tests/test_people_mutations.py @@ -94,6 +94,7 @@ def assignment_command(**overrides: object) -> AssignmentMutationCommand: "audit_event_record_id": AUDIT_EVENT, "outbox_delivery_record_id": OUTBOX, "allocation_ratio": Decimal("1.0000"), + "assignment_category_code": "primary", "effective_from": EFFECTIVE_FROM, "confirmation_reference": CONFIRMATION, "evidence_version_code": EVIDENCE, diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 0175fc6d4..fc18b9bd8 100644 --- a/services/people-api/tests/test_postgres_people_mutations.py +++ b/services/people-api/tests/test_postgres_people_mutations.py @@ -262,6 +262,7 @@ def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: PERSON, POSITION, Decimal("0.2500"), + "legacy_unspecified", date(2026, 8, 1), date(2026, 8, 10), RECORDED_AT, diff --git a/tests/assignment-category-provenance.test.mjs b/tests/assignment-category-provenance.test.mjs new file mode 100644 index 000000000..f8a8d4e6e --- /dev/null +++ b/tests/assignment-category-provenance.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { REQUIRED_FILES } from '../scripts/foundation-contract-core.mjs'; + +const REQUIRED_ASSIGNMENT_CATEGORY_PROVENANCE = Object.freeze([ + '.github/workflows/assignment-category-quality.yml', + 'docs/adr/0015-explicit-assignment-category.md', + 'tests/assignment-category-provenance.test.mjs' +]); + +test('assignment category decision and permanent quality gate are sealed foundation artifacts', () => { + const manifest = JSON.parse(readFileSync(new URL('../manifest.json', import.meta.url), 'utf8')); + const manifestPaths = new Set(manifest.files.map((entry) => entry.path)); + + for (const artifactPath of REQUIRED_ASSIGNMENT_CATEGORY_PROVENANCE) { + assert.ok(REQUIRED_FILES.includes(artifactPath), `${artifactPath} is missing from the canonical foundation inventory`); + assert.ok(manifestPaths.has(artifactPath), `${artifactPath} is missing from deterministic manifest provenance`); + } +}); diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078da..0b6487773 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -15,10 +15,29 @@ function removeOccurrence(text, fragment, occurrence = 1) { return text.slice(0, searchIndex) + text.slice(searchIndex + fragment.length); } +function schemaBlock(text, schemaName) { + const marker = ` ${schemaName}:\n`; + const start = text.indexOf(marker); + assert.ok(start >= 0, `schema fixture missing: ${schemaName}`); + const remainder = text.slice(start + marker.length); + const nextSchema = remainder.search(/^ {4}\S.*:\s*$/m); + const end = nextSchema >= 0 ? start + marker.length + nextSchema : text.length; + return text.slice(start, end); +} + test('canonical OpenAPI passes structural operation validation', () => { assert.deepEqual(validateOpenApiContract(canonical), []); }); +test('assignment command requires an explicit governed assignment category', () => { + const command = schemaBlock(canonical, 'CreateAssignmentRecordCommand'); + assert.match(command, /\n - assignment_category_code\n/); + assert.match( + command, + /\n assignment_category_code:\n type: string\n pattern: '\^\(primary\|concurrent_secondary\)\$'\n/ + ); +}); + for (const testCase of [ { name: 'createPersonRecord path', @@ -192,4 +211,4 @@ test('structural OpenAPI gate rejects internal trace identifiers anywhere in the test('structural OpenAPI gate rejects an empty-scope OIDC requirement', () => { const errors = validateOpenApiContract(`${canonical}\nkeyverse_oidc: []\n`); assert.ok(errors.some((error) => /empty-scope OIDC/.test(error)), errors.join('\n')); -}); +}); \ No newline at end of file diff --git a/tests/test_assignment_category_postgres.sh b/tests/test_assignment_category_postgres.sh new file mode 100755 index 000000000..30826dd7d --- /dev/null +++ b/tests/test_assignment_category_postgres.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0001_foundation_schema.sql + +# A migration that fails after its first DDL statement must not leave a partial +# assignment-category schema behind. A conflicting trigger-function name forces +# a deterministic late failure after the column and CHECK would otherwise have +# been committed by psql statement autocommit. +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +CREATE FUNCTION public.enforce_assignment_category_write() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN NEW; +END; +$$; +SQL + +set +e +atomicity_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0017_assignment_category_code.sql 2>&1)" +atomicity_status=$? +set -e +if [[ ${atomicity_status} -eq 0 || "${atomicity_output}" != *"enforce_assignment_category_write"* ]]; then + echo "assignment category migration did not hit the deterministic conflict: ${atomicity_output}" >&2 + exit 1 +fi + +partial_column_count="$(psql "${DATABASE_URL}" -Atqc "SELECT count(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='assignment_record' AND column_name='assignment_category_code';")" +partial_constraint_count="$(psql "${DATABASE_URL}" -Atqc "SELECT count(*) FROM pg_constraint WHERE conrelid='public.assignment_record'::regclass AND conname IN ('assignment_record_category_code_check','assignment_record_primary_bitemporal_exclusion');")" +if [[ "${partial_column_count}" != "0" || "${partial_constraint_count}" != "0" ]]; then + echo "failed assignment category migration left partial schema state" >&2 + exit 1 +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DROP FUNCTION public.enforce_assignment_category_write();" + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO tenant_record (tenant_record_id, tenant_reference) +VALUES ('10000000-0000-7000-8000-000000000001', 'tenant_alpha'); +INSERT INTO person_record (tenant_record_id, person_record_id, recorded_from) +VALUES ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000101', TIMESTAMPTZ '2026-09-01 00:00:00+00'); +INSERT INTO employment_record (tenant_record_id, employment_record_id, person_record_id, recorded_from) +VALUES ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000111', '00000000-0000-7000-8000-000000000101', TIMESTAMPTZ '2026-09-01 00:00:00+00'); +INSERT INTO organization_unit (tenant_record_id, organization_unit_id, recorded_from) +VALUES ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000121', TIMESTAMPTZ '2026-09-01 00:00:00+00'); +INSERT INTO job_profile (tenant_record_id, job_profile_id, recorded_from) +VALUES ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000131', TIMESTAMPTZ '2026-09-01 00:00:00+00'); +INSERT INTO position_record (tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from) +VALUES + ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000141', '00000000-0000-7000-8000-000000000121', '00000000-0000-7000-8000-000000000131', TIMESTAMPTZ '2026-09-01 00:00:00+00'), + ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000142', '00000000-0000-7000-8000-000000000121', '00000000-0000-7000-8000-000000000131', TIMESTAMPTZ '2026-09-01 00:00:00+00'), + ('10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000143', '00000000-0000-7000-8000-000000000121', '00000000-0000-7000-8000-000000000131', TIMESTAMPTZ '2026-09-01 00:00:00+00'); + +-- This row predates the assignment-category contract and must survive without +-- inventing a primary/secondary meaning from allocation or row order. +INSERT INTO assignment_record ( + tenant_record_id, assignment_record_id, employment_record_id, person_record_id, + position_record_id, allocation_ratio, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000151', + '00000000-0000-7000-8000-000000000111', '00000000-0000-7000-8000-000000000101', + '00000000-0000-7000-8000-000000000141', 0.5000, DATE '2026-09-01', TIMESTAMPTZ '2026-09-01 00:01:00+00' +); +SQL + +# RED until the forward-only migration exists. +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0017_assignment_category_code.sql + +legacy_category="$(psql "${DATABASE_URL}" -Atqc "SELECT assignment_category_code FROM assignment_record WHERE assignment_record_id='00000000-0000-7000-8000-000000000151';")" +test "${legacy_category}" = "legacy_unspecified" + +# The migration may use NOT VALID to avoid the strongest table lock while adding +# the CHECK, but it must finish by validating every migrated historical row. +# Leaving convalidated=false would publish a weaker catalog invariant even though +# migration 0017 deterministically assigns every pre-contract row the sentinel. +category_constraint_validated="$(psql "${DATABASE_URL}" -Atqc "SELECT convalidated FROM pg_constraint WHERE conrelid='public.assignment_record'::regclass AND conname='assignment_record_category_code_check';")" +test "${category_constraint_validated}" = "t" + +# A pre-contract row is still legitimate system-time history. Closing its +# recorded interval must not force Orgmetra to invent a primary/secondary +# classification merely because PostgreSQL rechecks the CHECK on UPDATE. +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "UPDATE assignment_record SET recorded_to=TIMESTAMPTZ '2026-09-01 00:02:00+00' WHERE assignment_record_id='00000000-0000-7000-8000-000000000151';" +legacy_recorded_to="$(psql "${DATABASE_URL}" -Atqc "SELECT recorded_to FROM assignment_record WHERE assignment_record_id='00000000-0000-7000-8000-000000000151';")" +test "${legacy_recorded_to}" = "2026-09-01 00:02:00+00" + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO assignment_record ( + tenant_record_id, assignment_record_id, employment_record_id, person_record_id, + position_record_id, allocation_ratio, assignment_category_code, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000152', + '00000000-0000-7000-8000-000000000111', '00000000-0000-7000-8000-000000000101', + '00000000-0000-7000-8000-000000000142', 0.2500, 'primary', DATE '2026-09-01', TIMESTAMPTZ '2026-09-01 00:02:00+00' +); +INSERT INTO assignment_record ( + tenant_record_id, assignment_record_id, employment_record_id, person_record_id, + position_record_id, allocation_ratio, assignment_category_code, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000153', + '00000000-0000-7000-8000-000000000111', '00000000-0000-7000-8000-000000000101', + '00000000-0000-7000-8000-000000000143', 0.2500, 'concurrent_secondary', DATE '2026-09-01', TIMESTAMPTZ '2026-09-01 00:02:00+00' +); +SQL + +set +e +missing_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO assignment_record (tenant_record_id, assignment_record_id, employment_record_id, person_record_id, position_record_id, allocation_ratio, effective_from, recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','00000000-0000-7000-8000-000000000154','00000000-0000-7000-8000-000000000111','00000000-0000-7000-8000-000000000101','00000000-0000-7000-8000-000000000143',0.1000,DATE '2026-09-02',TIMESTAMPTZ '2026-09-02 00:00:00+00');" 2>&1)" +missing_status=$? +set -e +if [[ ${missing_status} -eq 0 || "${missing_output}" != *"not-null constraint"* ]]; then + echo "new assignment without category did not fail closed: ${missing_output}" >&2 + exit 1 +fi + +# legacy_unspecified is a migration sentinel for pre-contract rows, not a legal +# value for a newly inserted assignment. The persistence boundary must enforce +# that distinction even if a caller bypasses the People command model. +set +e +legacy_write_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO assignment_record (tenant_record_id, assignment_record_id, employment_record_id, person_record_id, position_record_id, allocation_ratio, assignment_category_code, effective_from, recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','00000000-0000-7000-8000-000000000155','00000000-0000-7000-8000-000000000111','00000000-0000-7000-8000-000000000101','00000000-0000-7000-8000-000000000143',0.1000,'legacy_unspecified',DATE '2026-09-02',TIMESTAMPTZ '2026-09-02 00:01:00+00');" 2>&1)" +legacy_write_status=$? +set -e +if [[ ${legacy_write_status} -eq 0 || "${legacy_write_output}" != *"assignment_record_category_code_check"* ]]; then + echo "new legacy_unspecified assignment did not fail closed: ${legacy_write_output}" >&2 + exit 1 +fi + +# A classified assignment cannot be rewritten back to the historical sentinel. +# This separately exercises the UPDATE branch of the write guard while the +# earlier recorded_to closure proves untouched legacy history remains mutable +# only in system-time bookkeeping. +set +e +legacy_rewrite_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "UPDATE assignment_record SET assignment_category_code='legacy_unspecified' WHERE assignment_record_id='00000000-0000-7000-8000-000000000152';" 2>&1)" +legacy_rewrite_status=$? +set -e +if [[ ${legacy_rewrite_status} -eq 0 || "${legacy_rewrite_output}" != *"assignment_record_category_code_check"* ]]; then + echo "classified assignment was allowed to become legacy_unspecified: ${legacy_rewrite_output}" >&2 + exit 1 +fi + +set +e +duplicate_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO assignment_record (tenant_record_id, assignment_record_id, employment_record_id, person_record_id, position_record_id, allocation_ratio, assignment_category_code, effective_from, recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','00000000-0000-7000-8000-000000000156','00000000-0000-7000-8000-000000000111','00000000-0000-7000-8000-000000000101','00000000-0000-7000-8000-000000000143',0.1000,'primary',DATE '2026-09-01',TIMESTAMPTZ '2026-09-01 00:03:00+00');" 2>&1)" +duplicate_status=$? +set -e +if [[ ${duplicate_status} -eq 0 || "${duplicate_output}" != *"assignment_record_primary_bitemporal_exclusion"* ]]; then + echo "overlapping second primary did not fail closed: ${duplicate_output}" >&2 + exit 1 +fi + +echo "assignment category PostgreSQL contract passed" diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329ff..71c55fbef 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -23,6 +23,7 @@ "NOTICE", "manifest.json", "package.json", + ".github/workflows/assignment-category-quality.yml", ".github/workflows/foundation-ci.yml", ".github/workflows/job-analysis-api-quality.yml", "docs/PRD.md", @@ -55,6 +56,7 @@ "docs/adr/0012-governed-migration-handoff.md", "docs/adr/0013-governed-requisition-review-packet.md", "docs/adr/0014-job-analysis-snapshot-persistence.md", + "docs/adr/0015-explicit-assignment-category.md", "docs/doctoring/REFERENCES.md", "docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md", "docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md", @@ -71,11 +73,13 @@ "database/migrations/0011_criterion_observation_scope.sql", "database/migrations/0012_people_mutation_idempotency.sql", "database/migrations/0013_job_analysis_snapshot.sql", + "database/migrations/0017_assignment_category_code.sql", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", "scripts/foundation-contract-core.mjs", "scripts/foundation-contract.mjs", + "tests/assignment-category-provenance.test.mjs", "tests/dispatcher-inventory.test.mjs", "tests/foundation-contract.test.mjs", "tests/openapi-contract.test.mjs", @@ -92,6 +96,7 @@ "tests/test_criterion_observation_scope_postgres.sh", "tests/test_people_mutation_idempotency_postgres.sh", "tests/test_job_analysis_snapshot_postgres.sh", + "tests/test_assignment_category_postgres.sh", "tests/validate_repository.py", ] @@ -635,4 +640,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file