From 269c91147b855700a2636b392887dd5aa976efe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:21:46 +0900 Subject: [PATCH 01/93] test: require explicit assignment category contract --- .../test_assignment_category_contract.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 services/people-api/tests/test_assignment_category_contract.py 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..2e148d65d --- /dev/null +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -0,0 +1,164 @@ +"""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, mutation_command_digest + +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) + + +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_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"): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "assignment_category_code"): + assignment_command(category=invalid) + + 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() From 5160e552b4baffa726a9b834b4733d5616796377 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:25:17 +0900 Subject: [PATCH 02/93] feat(hris): carry explicit assignment category --- packages/hris-kernel/src/orgmetra_hris_kernel/facts.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py b/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py index 3ce020b6b..b169c2007 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py @@ -63,7 +63,13 @@ 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``; callers must never infer a category from allocation + ratio, row order, or position identity. + """ tenant_record_id: UUID assignment_record_id: UUID @@ -73,3 +79,4 @@ class AssignmentFact: allocation_ratio: Decimal effective: DateInterval recorded: RecordedInterval + assignment_category_code: str = "legacy_unspecified" From e794bb083132f24c586aa63501adb04fd30a3910 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:26:20 +0900 Subject: [PATCH 03/93] feat(hris): enforce explicit primary assignment invariant --- .../src/orgmetra_hris_kernel/assignment.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py index 710ecbcd7..81ca54fe4 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py @@ -20,6 +20,7 @@ _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: @@ -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 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( From 67f5fca02741be3b473cc5be39188eb7e8b4391b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:27:13 +0900 Subject: [PATCH 04/93] feat(people): require assignment category on writes --- .../people-api/src/orgmetra_people_api/mutations.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 6baeac684..ebe260a2a 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.""" @@ -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 ( + not isinstance(self.assignment_category_code, 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) From 230736a69be0b2d3e47555207399757c71e93a9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:31:38 +0900 Subject: [PATCH 05/93] chore: apply assignment category integration repair --- .../assignment-category-source-fix.yml | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 .github/workflows/assignment-category-source-fix.yml diff --git a/.github/workflows/assignment-category-source-fix.yml b/.github/workflows/assignment-category-source-fix.yml new file mode 100644 index 000000000..dd1a027d0 --- /dev/null +++ b/.github/workflows/assignment-category-source-fix.yml @@ -0,0 +1,238 @@ +name: Assignment Category Source Fix + +on: + push: + branches: + - feat/explicit-assignment-category + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact writer head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/explicit-assignment-category + fetch-depth: 0 + - name: Apply deterministic source, persistence, API, docs, and provenance repair + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + if text.count(old) != 1: + raise SystemExit(f"{path}: expected exactly one replacement target, found {text.count(old)}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + replace_once( + "services/people-api/src/orgmetra_people_api/mutation_http.py", + ' "allocation_ratio",\n "effective_from",', + ' "allocation_ratio",\n "assignment_category_code",\n "effective_from",', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/mutation_http.py", + ' allocation_ratio=parse_allocation_ratio(payload["allocation_ratio"]),\n effective_from=effective_from,', + ' allocation_ratio=parse_allocation_ratio(payload["allocation_ratio"]),\n assignment_category_code=_require_string_field(payload, "assignment_category_code"),\n effective_from=effective_from,', + ) + + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' assignment.allocation_ratio,\n assignment.effective_from,', + ' assignment.allocation_ratio,\n assignment.assignment_category_code,\n assignment.effective_from,', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' allocation_ratio,\n effective_from,\n recorded_from\n) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)', + ' allocation_ratio,\n assignment_category_code,\n effective_from,\n recorded_from\n) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' if len(row) != 9:\n raise PeopleMutationIntegrityError("assignment row has an invalid shape")', + ' if len(row) != 10:\n raise PeopleMutationIntegrityError("assignment row has an invalid shape")', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' allocation_ratio,\n effective_from,', + ' allocation_ratio,\n assignment_category_code,\n effective_from,', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' or not isinstance(allocation_ratio, Decimal)\n or type(effective_from) is not date', + ' or not isinstance(allocation_ratio, Decimal)\n or assignment_category_code not in {"primary", "concurrent_secondary", "legacy_unspecified"}\n or type(effective_from) is not date', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' allocation_ratio=allocation_ratio,\n effective=DateInterval(effective_from, effective_to if isinstance(effective_to, date) else None),', + ' allocation_ratio=allocation_ratio,\n assignment_category_code=assignment_category_code,\n effective=DateInterval(effective_from, effective_to if isinstance(effective_to, date) else None),', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' allocation_ratio=command.allocation_ratio,\n effective=DateInterval(command.effective_from),', + ' allocation_ratio=command.allocation_ratio,\n assignment_category_code=command.assignment_category_code,\n effective=DateInterval(command.effective_from),', + ) + replace_once( + "services/people-api/src/orgmetra_people_api/postgres_mutations.py", + ' command.allocation_ratio,\n command.effective_from,\n recorded_at,', + ' command.allocation_ratio,\n command.assignment_category_code,\n command.effective_from,\n recorded_at,', + ) + + replace_once( + "services/people-api/tests/test_people_mutations.py", + ' "allocation_ratio": Decimal("1.0000"),\n "effective_from": EFFECTIVE_FROM,', + ' "allocation_ratio": Decimal("1.0000"),\n "assignment_category_code": "primary",\n "effective_from": EFFECTIVE_FROM,', + ) + replace_once( + "services/people-api/tests/test_mutation_http_route.py", + ' "allocation_ratio": "1.0000",\n "effective_from": "2026-08-18",', + ' "allocation_ratio": "1.0000",\n "assignment_category_code": "primary",\n "effective_from": "2026-08-18",', + ) + + migration = Path("database/migrations/0017_assignment_category_code.sql") + if migration.exists(): + raise SystemExit(f"migration already exists: {migration}") + migration.write_text("""-- Record assignment role classification as authoritative HRIS truth.\n-- Historical rows are preserved explicitly; no allocation/order heuristic is allowed.\n\nALTER TABLE public.assignment_record\n ADD COLUMN assignment_category_code text;\n\nUPDATE public.assignment_record\nSET assignment_category_code = 'legacy_unspecified'\nWHERE assignment_category_code IS NULL;\n\nALTER TABLE public.assignment_record\n ALTER COLUMN assignment_category_code SET NOT NULL;\n\nALTER TABLE public.assignment_record\n ADD CONSTRAINT assignment_record_category_code_check\n CHECK (assignment_category_code IN ('primary', 'concurrent_secondary', 'legacy_unspecified'));\n\nALTER TABLE public.assignment_record\n ADD CONSTRAINT assignment_record_primary_bitemporal_exclusion\n EXCLUDE USING gist (\n tenant_record_id WITH =,\n employment_record_id WITH =,\n daterange(effective_from, effective_to, '[)') WITH &&,\n tstzrange(recorded_from, recorded_to, '[)') WITH &&\n )\n WHERE (assignment_category_code = 'primary');\n""", encoding="utf-8") + + postgres_test = Path("tests/test_assignment_category_postgres.sh") + if postgres_test.exists(): + raise SystemExit(f"test already exists: {postgres_test}") + postgres_test.write_text(r'''#!/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 + + 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'); + 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 + + 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" + + 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 + + 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-000000000155','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" + ''', encoding="utf-8") + + replace_once( + ".github/workflows/foundation-ci.yml", + ' - test_job_analysis_snapshot_postgres.sh\n', + ' - test_job_analysis_snapshot_postgres.sh\n - test_assignment_category_postgres.sh\n', + ) + replace_once( + "tests/validate_repository.py", + ' "database/migrations/0013_job_analysis_snapshot.sql",\n', + ' "database/migrations/0013_job_analysis_snapshot.sql",\n "database/migrations/0017_assignment_category_code.sql",\n', + ) + replace_once( + "tests/validate_repository.py", + ' "tests/test_job_analysis_snapshot_postgres.sh",\n', + ' "tests/test_job_analysis_snapshot_postgres.sh",\n "tests/test_assignment_category_postgres.sh",\n', + ) + replace_once( + "tests/validate_repository.py", + ' "CREATE TRIGGER assignment_record_bitemporal_guard",\n', + ' "CREATE TRIGGER assignment_record_bitemporal_guard",\n "assignment_category_code text",\n "CONSTRAINT assignment_record_primary_bitemporal_exclusion",\n', + ) + replace_once( + "tests/validate_repository.py", + ' "human confirmation reference",\n )\n\n decision_block = _yaml_block(openapi, " RecordSelectionDecisionCommand:")', + ' "human confirmation reference",\n )\n if schema_name == "CreateAssignmentRecordCommand":\n _require_in_block(block, schema_name, " - assignment_category_code", "explicit assignment category")\n _require_in_block(block, schema_name, " - concurrent_secondary", "secondary assignment category")\n\n decision_block = _yaml_block(openapi, " RecordSelectionDecisionCommand:")', + ) + + replace_once( + "schemas/openapi.yaml", + ' - allocation_ratio\n - effective_from', + ' - allocation_ratio\n - assignment_category_code\n - effective_from', + ) + replace_once( + "schemas/openapi.yaml", + " allocation_ratio:\n type: string\n pattern: '^(0\\.[0-9]{4}|1\\.0000)$'\n effective_from:", + " allocation_ratio:\n type: string\n pattern: '^(0\\.[0-9]{4}|1\\.0000)$'\n assignment_category_code:\n type: string\n description: Authoritative HR classification recorded by the People workflow; never infer it from allocation ratio or row order.\n enum:\n - primary\n - concurrent_secondary\n effective_from:", + ) + + with Path("docs/DATA_MODEL.md").open("a", encoding="utf-8") as handle: + handle.write("\n\n### Assignment classification\n\n`assignment_record.assignment_category_code` records the reviewed HR relationship classification (`primary` or `concurrent_secondary`) independently of `allocation_ratio`. Migration `0017_assignment_category_code.sql` backfills pre-contract history as `legacy_unspecified`; neither services nor consumers may infer a historical category from FTE, row order, or position identity. At one effective/system-time coordinate, one Employment may expose at most one `primary` assignment, while additional assignments must be explicitly recorded as `concurrent_secondary`.\n") + with Path("docs/adr/0004-employment-position-version-and-assignment-binding.md").open("a", encoding="utf-8") as handle: + handle.write("\n\n## Explicit assignment classification amendment (2026-09-02)\n\nPrimary versus concurrent-secondary assignment status is authoritative People/Employment truth, not a derived property of allocation. New assignment writes therefore require `assignment_category_code` (`primary` or `concurrent_secondary`). Existing pre-contract rows are migrated to `legacy_unspecified` without heuristic relabeling. A bitemporal exclusion constraint prevents two simultaneously visible primary assignments for the same tenant-local Employment. Downstream ContextualWisdomLab consumers may read the published classification but must not reconstruct or overwrite it from allocation percentage, ordering, or graph topology.\n") + with Path("docs/TRACEABILITY.md").open("a", encoding="utf-8") as handle: + handle.write("\n\n## Assignment-category commercialization trace (2026-09-02)\n\n| Requirement | Owner | Persistence/API | Evidence | Status |\n|---|---|---|---|---|\n| Explicit primary versus concurrent-secondary assignment semantics without heuristics | People & Employment / Organization-Job-Position-Assignment context map | `assignment_record.assignment_category_code`; `POST /v1/assignment-records`; migration `0017_assignment_category_code.sql` | `test_assignment_category_contract.py`; `test_assignment_category_postgres.sh`; People API exact coverage | implemented_on_active_pr |\n") + with Path("CHANGELOG.md").open("a", encoding="utf-8") as handle: + handle.write("\n- Active PR #163 adds explicit `assignment_category_code` (`primary` / `concurrent_secondary`) to governed assignment writes and preserves historical rows as `legacy_unspecified` without FTE/order inference; release status remains pending protected-head evidence.\n") + + Path(".github/workflows/assignment-category-source-fix.yml").unlink() + PY + python tests/validate_repository.py --print-manifest > manifest.json + - name: Commit repaired final tree without temporary workflow + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + test ! -e .github/workflows/assignment-category-source-fix.yml + git diff --cached --check + git commit -m "feat: integrate explicit assignment category" + git push origin HEAD:feat/explicit-assignment-category From 2d057372f524ed9231963a0cfed289773da37558 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:24:56 +0900 Subject: [PATCH 06/93] test(assignments): reject new legacy assignment categories --- tests/test_assignment_category_postgres.sh | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100755 tests/test_assignment_category_postgres.sh diff --git a/tests/test_assignment_category_postgres.sh b/tests/test_assignment_category_postgres.sh new file mode 100755 index 000000000..11f854dee --- /dev/null +++ b/tests/test_assignment_category_postgres.sh @@ -0,0 +1,92 @@ +#!/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 + +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" + +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 + +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" From c1cef84904e2541b5d0ad6dd951c8772452411a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:25:26 +0900 Subject: [PATCH 07/93] fix(assignments): reserve legacy category for migrated history --- .../0017_assignment_category_code.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 database/migrations/0017_assignment_category_code.sql diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql new file mode 100644 index 000000000..c9e5a67b6 --- /dev/null +++ b/database/migrations/0017_assignment_category_code.sql @@ -0,0 +1,29 @@ +-- Record assignment role classification as authoritative HRIS truth. +-- Historical rows are preserved explicitly; no allocation/order heuristic is allowed. +-- legacy_unspecified is migration provenance only: the NOT VALID check preserves +-- pre-contract rows while enforcing primary/concurrent_secondary for every new +-- or subsequently rewritten row. + +ALTER TABLE public.assignment_record + ADD COLUMN assignment_category_code text; + +UPDATE public.assignment_record +SET assignment_category_code = 'legacy_unspecified' +WHERE assignment_category_code IS NULL; + +ALTER TABLE public.assignment_record + ALTER COLUMN assignment_category_code SET NOT NULL; + +ALTER TABLE public.assignment_record + ADD CONSTRAINT assignment_record_category_code_check + CHECK (assignment_category_code IN ('primary', 'concurrent_secondary')) NOT VALID; + +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'); From 4863294ed077e66795d58854286da1f801c6c1b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:25:50 +0900 Subject: [PATCH 08/93] chore(assignments): retire temporary source-fix workflow --- .../assignment-category-source-fix.yml | 238 ------------------ 1 file changed, 238 deletions(-) delete mode 100644 .github/workflows/assignment-category-source-fix.yml diff --git a/.github/workflows/assignment-category-source-fix.yml b/.github/workflows/assignment-category-source-fix.yml deleted file mode 100644 index dd1a027d0..000000000 --- a/.github/workflows/assignment-category-source-fix.yml +++ /dev/null @@ -1,238 +0,0 @@ -name: Assignment Category Source Fix - -on: - push: - branches: - - feat/explicit-assignment-category - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout exact writer head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/explicit-assignment-category - fetch-depth: 0 - - name: Apply deterministic source, persistence, API, docs, and provenance repair - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - if text.count(old) != 1: - raise SystemExit(f"{path}: expected exactly one replacement target, found {text.count(old)}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - replace_once( - "services/people-api/src/orgmetra_people_api/mutation_http.py", - ' "allocation_ratio",\n "effective_from",', - ' "allocation_ratio",\n "assignment_category_code",\n "effective_from",', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/mutation_http.py", - ' allocation_ratio=parse_allocation_ratio(payload["allocation_ratio"]),\n effective_from=effective_from,', - ' allocation_ratio=parse_allocation_ratio(payload["allocation_ratio"]),\n assignment_category_code=_require_string_field(payload, "assignment_category_code"),\n effective_from=effective_from,', - ) - - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' assignment.allocation_ratio,\n assignment.effective_from,', - ' assignment.allocation_ratio,\n assignment.assignment_category_code,\n assignment.effective_from,', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' allocation_ratio,\n effective_from,\n recorded_from\n) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)', - ' allocation_ratio,\n assignment_category_code,\n effective_from,\n recorded_from\n) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' if len(row) != 9:\n raise PeopleMutationIntegrityError("assignment row has an invalid shape")', - ' if len(row) != 10:\n raise PeopleMutationIntegrityError("assignment row has an invalid shape")', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' allocation_ratio,\n effective_from,', - ' allocation_ratio,\n assignment_category_code,\n effective_from,', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' or not isinstance(allocation_ratio, Decimal)\n or type(effective_from) is not date', - ' or not isinstance(allocation_ratio, Decimal)\n or assignment_category_code not in {"primary", "concurrent_secondary", "legacy_unspecified"}\n or type(effective_from) is not date', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' allocation_ratio=allocation_ratio,\n effective=DateInterval(effective_from, effective_to if isinstance(effective_to, date) else None),', - ' allocation_ratio=allocation_ratio,\n assignment_category_code=assignment_category_code,\n effective=DateInterval(effective_from, effective_to if isinstance(effective_to, date) else None),', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' allocation_ratio=command.allocation_ratio,\n effective=DateInterval(command.effective_from),', - ' allocation_ratio=command.allocation_ratio,\n assignment_category_code=command.assignment_category_code,\n effective=DateInterval(command.effective_from),', - ) - replace_once( - "services/people-api/src/orgmetra_people_api/postgres_mutations.py", - ' command.allocation_ratio,\n command.effective_from,\n recorded_at,', - ' command.allocation_ratio,\n command.assignment_category_code,\n command.effective_from,\n recorded_at,', - ) - - replace_once( - "services/people-api/tests/test_people_mutations.py", - ' "allocation_ratio": Decimal("1.0000"),\n "effective_from": EFFECTIVE_FROM,', - ' "allocation_ratio": Decimal("1.0000"),\n "assignment_category_code": "primary",\n "effective_from": EFFECTIVE_FROM,', - ) - replace_once( - "services/people-api/tests/test_mutation_http_route.py", - ' "allocation_ratio": "1.0000",\n "effective_from": "2026-08-18",', - ' "allocation_ratio": "1.0000",\n "assignment_category_code": "primary",\n "effective_from": "2026-08-18",', - ) - - migration = Path("database/migrations/0017_assignment_category_code.sql") - if migration.exists(): - raise SystemExit(f"migration already exists: {migration}") - migration.write_text("""-- Record assignment role classification as authoritative HRIS truth.\n-- Historical rows are preserved explicitly; no allocation/order heuristic is allowed.\n\nALTER TABLE public.assignment_record\n ADD COLUMN assignment_category_code text;\n\nUPDATE public.assignment_record\nSET assignment_category_code = 'legacy_unspecified'\nWHERE assignment_category_code IS NULL;\n\nALTER TABLE public.assignment_record\n ALTER COLUMN assignment_category_code SET NOT NULL;\n\nALTER TABLE public.assignment_record\n ADD CONSTRAINT assignment_record_category_code_check\n CHECK (assignment_category_code IN ('primary', 'concurrent_secondary', 'legacy_unspecified'));\n\nALTER TABLE public.assignment_record\n ADD CONSTRAINT assignment_record_primary_bitemporal_exclusion\n EXCLUDE USING gist (\n tenant_record_id WITH =,\n employment_record_id WITH =,\n daterange(effective_from, effective_to, '[)') WITH &&,\n tstzrange(recorded_from, recorded_to, '[)') WITH &&\n )\n WHERE (assignment_category_code = 'primary');\n""", encoding="utf-8") - - postgres_test = Path("tests/test_assignment_category_postgres.sh") - if postgres_test.exists(): - raise SystemExit(f"test already exists: {postgres_test}") - postgres_test.write_text(r'''#!/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 - - 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'); - 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 - - 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" - - 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 - - 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-000000000155','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" - ''', encoding="utf-8") - - replace_once( - ".github/workflows/foundation-ci.yml", - ' - test_job_analysis_snapshot_postgres.sh\n', - ' - test_job_analysis_snapshot_postgres.sh\n - test_assignment_category_postgres.sh\n', - ) - replace_once( - "tests/validate_repository.py", - ' "database/migrations/0013_job_analysis_snapshot.sql",\n', - ' "database/migrations/0013_job_analysis_snapshot.sql",\n "database/migrations/0017_assignment_category_code.sql",\n', - ) - replace_once( - "tests/validate_repository.py", - ' "tests/test_job_analysis_snapshot_postgres.sh",\n', - ' "tests/test_job_analysis_snapshot_postgres.sh",\n "tests/test_assignment_category_postgres.sh",\n', - ) - replace_once( - "tests/validate_repository.py", - ' "CREATE TRIGGER assignment_record_bitemporal_guard",\n', - ' "CREATE TRIGGER assignment_record_bitemporal_guard",\n "assignment_category_code text",\n "CONSTRAINT assignment_record_primary_bitemporal_exclusion",\n', - ) - replace_once( - "tests/validate_repository.py", - ' "human confirmation reference",\n )\n\n decision_block = _yaml_block(openapi, " RecordSelectionDecisionCommand:")', - ' "human confirmation reference",\n )\n if schema_name == "CreateAssignmentRecordCommand":\n _require_in_block(block, schema_name, " - assignment_category_code", "explicit assignment category")\n _require_in_block(block, schema_name, " - concurrent_secondary", "secondary assignment category")\n\n decision_block = _yaml_block(openapi, " RecordSelectionDecisionCommand:")', - ) - - replace_once( - "schemas/openapi.yaml", - ' - allocation_ratio\n - effective_from', - ' - allocation_ratio\n - assignment_category_code\n - effective_from', - ) - replace_once( - "schemas/openapi.yaml", - " allocation_ratio:\n type: string\n pattern: '^(0\\.[0-9]{4}|1\\.0000)$'\n effective_from:", - " allocation_ratio:\n type: string\n pattern: '^(0\\.[0-9]{4}|1\\.0000)$'\n assignment_category_code:\n type: string\n description: Authoritative HR classification recorded by the People workflow; never infer it from allocation ratio or row order.\n enum:\n - primary\n - concurrent_secondary\n effective_from:", - ) - - with Path("docs/DATA_MODEL.md").open("a", encoding="utf-8") as handle: - handle.write("\n\n### Assignment classification\n\n`assignment_record.assignment_category_code` records the reviewed HR relationship classification (`primary` or `concurrent_secondary`) independently of `allocation_ratio`. Migration `0017_assignment_category_code.sql` backfills pre-contract history as `legacy_unspecified`; neither services nor consumers may infer a historical category from FTE, row order, or position identity. At one effective/system-time coordinate, one Employment may expose at most one `primary` assignment, while additional assignments must be explicitly recorded as `concurrent_secondary`.\n") - with Path("docs/adr/0004-employment-position-version-and-assignment-binding.md").open("a", encoding="utf-8") as handle: - handle.write("\n\n## Explicit assignment classification amendment (2026-09-02)\n\nPrimary versus concurrent-secondary assignment status is authoritative People/Employment truth, not a derived property of allocation. New assignment writes therefore require `assignment_category_code` (`primary` or `concurrent_secondary`). Existing pre-contract rows are migrated to `legacy_unspecified` without heuristic relabeling. A bitemporal exclusion constraint prevents two simultaneously visible primary assignments for the same tenant-local Employment. Downstream ContextualWisdomLab consumers may read the published classification but must not reconstruct or overwrite it from allocation percentage, ordering, or graph topology.\n") - with Path("docs/TRACEABILITY.md").open("a", encoding="utf-8") as handle: - handle.write("\n\n## Assignment-category commercialization trace (2026-09-02)\n\n| Requirement | Owner | Persistence/API | Evidence | Status |\n|---|---|---|---|---|\n| Explicit primary versus concurrent-secondary assignment semantics without heuristics | People & Employment / Organization-Job-Position-Assignment context map | `assignment_record.assignment_category_code`; `POST /v1/assignment-records`; migration `0017_assignment_category_code.sql` | `test_assignment_category_contract.py`; `test_assignment_category_postgres.sh`; People API exact coverage | implemented_on_active_pr |\n") - with Path("CHANGELOG.md").open("a", encoding="utf-8") as handle: - handle.write("\n- Active PR #163 adds explicit `assignment_category_code` (`primary` / `concurrent_secondary`) to governed assignment writes and preserves historical rows as `legacy_unspecified` without FTE/order inference; release status remains pending protected-head evidence.\n") - - Path(".github/workflows/assignment-category-source-fix.yml").unlink() - PY - python tests/validate_repository.py --print-manifest > manifest.json - - name: Commit repaired final tree without temporary workflow - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - test ! -e .github/workflows/assignment-category-source-fix.yml - git diff --cached --check - git commit -m "feat: integrate explicit assignment category" - git push origin HEAD:feat/explicit-assignment-category From a2df9878256ce35a32798e4d23ad84334aed3d38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:27:11 +0900 Subject: [PATCH 09/93] ci(assignments): prove category persistence boundary --- .../workflows/assignment-category-quality.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/assignment-category-quality.yml diff --git a/.github/workflows/assignment-category-quality.yml b/.github/workflows/assignment-category-quality.yml new file mode 100644 index 000000000..09c49f46f --- /dev/null +++ b/.github/workflows/assignment-category-quality.yml @@ -0,0 +1,60 @@ +name: Assignment Category Quality + +on: + pull_request: + branches: + - bootstrap + - develop + - main + paths: + - "database/migrations/0017_assignment_category_code.sql" + - "packages/hris-kernel/**" + - "services/people-api/**" + - "tests/test_assignment_category_postgres.sh" + - ".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-latest + 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 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)" From 87023a132eb7819056a8eba2a1c9a23403817d92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:31:27 +0900 Subject: [PATCH 10/93] feat(assignments): require category at HTTP boundary --- services/people-api/src/orgmetra_people_api/mutation_http.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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}" From 7f7c2e8bf8d917b4fabcafd02980c4fdf60e892a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:34:31 +0900 Subject: [PATCH 11/93] test(assignments): require explicit HTTP category --- services/people-api/tests/test_mutation_http_route.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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() From 9fc12cf72582e2c984d0ca05e46fb850700e9fc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:36:13 +0900 Subject: [PATCH 12/93] feat(assignments): persist category through PostgreSQL adapter --- .../src/orgmetra_people_api/postgres_mutations.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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..3f1b901df 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -184,6 +184,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 +205,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 +443,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 +452,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 +464,7 @@ 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 not isinstance(assignment_category_code, str) 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 +486,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 +788,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 +809,7 @@ def create_assignment( command.person_record_id, command.position_record_id, command.allocation_ratio, + command.assignment_category_code, command.effective_from, recorded_at, ), From 248948a03446136ddd34f64e5ad4060fc54660f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:44:09 +0900 Subject: [PATCH 13/93] test(assignments): require category in OpenAPI contract --- tests/openapi-contract.test.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078da..2a5b7b8a1 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -15,10 +15,27 @@ 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 next = text.indexOf('\n ', start + marker.length); + return text.slice(start, next >= 0 ? next : text.length); +} + 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', From 8e3afd7fa9c241b9a97874fa39828b4ed28f3a3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:46:46 +0900 Subject: [PATCH 14/93] feat(assignments): publish category in OpenAPI --- schemas/openapi.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From b4112857168991933e512e5c281421fa208e8a48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:47:54 +0900 Subject: [PATCH 15/93] docs(assignments): record explicit category decision --- docs/adr/0015-explicit-assignment-category.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/adr/0015-explicit-assignment-category.md diff --git a/docs/adr/0015-explicit-assignment-category.md b/docs/adr/0015-explicit-assignment-category.md new file mode 100644 index 000000000..215609258 --- /dev/null +++ b/docs/adr/0015-explicit-assignment-category.md @@ -0,0 +1,72 @@ +# ADR 0015: Explicit assignment category is authoritative HRIS truth + +- Status: Accepted on active implementation branch +- 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 the same assignment can be corrected later and because two assignments may overlap in effective time while being known at different system times. 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 + +The migration backfills pre-contract rows explicitly and then applies a `NOT VALID` forward-write CHECK so old sentinels remain readable while inserted or rewritten rows must use the two governed values. 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. 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. A category correction must follow normal bitemporal correction semantics instead of in-place mutation. The added exclusion constraint introduces conflict detection only where two primary intervals overlap for the same tenant-local employment. + +## 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 From e6bd170bff9627eb6e6d42d17609d1440ec611ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:48:10 +0900 Subject: [PATCH 16/93] docs(assignments): index assignment category ADR --- docs/adr/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a21139..37902979d 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 | Accepted on active implementation branch | From a434a4536e341c373b8dabc7600f108deda13059 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:48:46 +0900 Subject: [PATCH 17/93] docs(assignments): align data model with category contract --- docs/DATA_MODEL.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7d4afd563..f5dd2be13 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`, but the forward-write CHECK rejects that sentinel on new or rewritten rows. 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. From 5bebae566bd875fc31d864603a008ab1c932e186 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:49:06 +0900 Subject: [PATCH 18/93] docs(assignments): publish API category semantics --- docs/API_CONTRACT.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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. From 5984c60943003f05755876384c9a744ca1d41569 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:50:21 +0900 Subject: [PATCH 19/93] docs(assignments): align ERD category invariant --- docs/ERD.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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. From a48cb627ed1531151259417c6e5104b80b98aae1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:51:05 +0900 Subject: [PATCH 20/93] docs(assignments): trace explicit category contract --- docs/TRACEABILITY.md | 2 ++ 1 file changed, 2 insertions(+) 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 | From 2bce218154e07d8f0a7f7ed9b121c8ea063499c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:03:13 +0900 Subject: [PATCH 21/93] fix(foundation): inventory assignment migration contract --- tests/validate_repository.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329ff..037ae98a7 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -71,6 +71,7 @@ "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", @@ -92,6 +93,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 +637,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file From 601152bd7ea7ebb5792e43725533403f43080eca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:05:08 +0900 Subject: [PATCH 22/93] fix(foundation): mirror assignment execution inventory --- scripts/foundation-contract-core.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267c..8cc96fd19 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -68,6 +68,7 @@ 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', @@ -89,6 +90,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 +688,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 From e9f7918c86632f330a2a639ffee78ab136c2dce9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:06:04 +0900 Subject: [PATCH 23/93] chore(foundation): emit exact assignment manifest repair data --- .../assignment-manifest-repair-data.yml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/assignment-manifest-repair-data.yml diff --git a/.github/workflows/assignment-manifest-repair-data.yml b/.github/workflows/assignment-manifest-repair-data.yml new file mode 100644 index 000000000..5e89ae221 --- /dev/null +++ b/.github/workflows/assignment-manifest-repair-data.yml @@ -0,0 +1,49 @@ +name: Assignment Manifest Repair Data + +on: + pull_request: + branches: + - develop + paths: + - .github/workflows/assignment-manifest-repair-data.yml + - database/migrations/0017_assignment_category_code.sql + - docs/API_CONTRACT.md + - docs/DATA_MODEL.md + - docs/ERD.md + - docs/TRACEABILITY.md + - docs/adr/README.md + - schemas/openapi.yaml + - scripts/foundation-contract-core.mjs + - tests/openapi-contract.test.mjs + - tests/test_assignment_category_postgres.sh + - tests/validate_repository.py + - manifest.json + +permissions: + contents: read + +concurrency: + group: assignment-manifest-repair-data-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + print-manifest: + name: Print exact manifest repair data + runs-on: ubuntu-slim + timeout-minutes: 5 + 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: Print deterministic manifest + run: python tests/validate_repository.py --print-manifest + - name: Prove diagnostic is read-only + run: | + git diff --exit-code + test -z "$(git status --porcelain)" From 274bceadd01ab86f3270d6b1e941956902ec80cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:18:12 +0900 Subject: [PATCH 24/93] chore(assignment): finalize manifest and changelog evidence --- .../assignment-manifest-repair-data.yml | 85 +++++++++++++------ 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/.github/workflows/assignment-manifest-repair-data.yml b/.github/workflows/assignment-manifest-repair-data.yml index 5e89ae221..9de5eed45 100644 --- a/.github/workflows/assignment-manifest-repair-data.yml +++ b/.github/workflows/assignment-manifest-repair-data.yml @@ -1,4 +1,4 @@ -name: Assignment Manifest Repair Data +name: Assignment Manifest Finalizer on: pull_request: @@ -6,44 +6,73 @@ on: - develop paths: - .github/workflows/assignment-manifest-repair-data.yml - - database/migrations/0017_assignment_category_code.sql - - docs/API_CONTRACT.md - - docs/DATA_MODEL.md - - docs/ERD.md - - docs/TRACEABILITY.md - - docs/adr/README.md - - schemas/openapi.yaml - - scripts/foundation-contract-core.mjs - - tests/openapi-contract.test.mjs - - tests/test_assignment_category_postgres.sh - - tests/validate_repository.py - - manifest.json permissions: - contents: read + contents: write concurrency: - group: assignment-manifest-repair-data-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: assignment-manifest-finalizer-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false jobs: - print-manifest: - name: Print exact manifest repair data + finalize: + name: Finalize exact assignment manifest runs-on: ubuntu-slim - timeout-minutes: 5 + timeout-minutes: 10 steps: - - name: Checkout exact candidate + - name: Checkout exact writer branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 1 - name: Prove exact candidate checkout env: - ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" - - name: Print deterministic manifest - run: python tests/validate_repository.py --print-manifest - - name: Prove diagnostic is read-only + - name: Record buyer-visible assignment category change run: | - git diff --exit-code - test -z "$(git status --porcelain)" + python - <<'PY' + from pathlib import Path + + path = Path("CHANGELOG.md") + text = path.read_text(encoding="utf-8") + bullet = ( + "- 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." + ) + if bullet not in text: + marker = "### Added\n\n" + if marker not in text: + raise SystemExit("CHANGELOG Added section is missing") + text = text.replace(marker, marker + bullet + "\n", 1) + path.write_text(text, encoding="utf-8") + PY + - name: Reseal deterministic foundation manifest + run: | + python tests/validate_repository.py --print-manifest > manifest.json.next + mv manifest.json.next manifest.json + python tests/validate_repository.py + - name: Run focused foundation contracts + run: node --test tests/foundation-contract.test.mjs tests/openapi-contract.test.mjs + - name: Remove completed one-shot finalizer + run: rm .github/workflows/assignment-manifest-repair-data.yml + - name: Refuse overwrite if another writer advanced + env: + ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + ORGMETRA_WRITER_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + git fetch --no-tags origin "$ORGMETRA_WRITER_BRANCH" + test "$(git rev-parse FETCH_HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" + - name: Commit and push non-force finalization + env: + ORGMETRA_WRITER_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md manifest.json .github/workflows/assignment-manifest-repair-data.yml + git diff --cached --check + git commit -m "chore(assignment): seal final evidence" + git push origin "HEAD:$ORGMETRA_WRITER_BRANCH" From c9f275ffe0236761c6d3ae52452542be5109d0a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:20:10 +0900 Subject: [PATCH 25/93] test(openapi): bound schema fixtures by exact indentation --- tests/openapi-contract.test.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 2a5b7b8a1..0b6487773 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -19,8 +19,10 @@ function schemaBlock(text, schemaName) { const marker = ` ${schemaName}:\n`; const start = text.indexOf(marker); assert.ok(start >= 0, `schema fixture missing: ${schemaName}`); - const next = text.indexOf('\n ', start + marker.length); - return text.slice(start, next >= 0 ? next : text.length); + 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', () => { @@ -209,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 From 2529fd9b15847edcc12185bb4b1c75336b05f82e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:20:36 +0900 Subject: [PATCH 26/93] chore(assignment): retrigger exact-head finalization --- .github/workflows/assignment-manifest-repair-data.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/assignment-manifest-repair-data.yml b/.github/workflows/assignment-manifest-repair-data.yml index 9de5eed45..902f68190 100644 --- a/.github/workflows/assignment-manifest-repair-data.yml +++ b/.github/workflows/assignment-manifest-repair-data.yml @@ -6,6 +6,7 @@ on: - develop paths: - .github/workflows/assignment-manifest-repair-data.yml + - tests/openapi-contract.test.mjs permissions: contents: write From 09a2dd5e7c9143abd29cb4d280da07c9f8d5f81c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:21:46 +0000 Subject: [PATCH 27/93] chore(assignment): seal final evidence --- .../assignment-manifest-repair-data.yml | 79 --- CHANGELOG.md | 1 + manifest.json | 494 +++++++++++++++++- 3 files changed, 494 insertions(+), 80 deletions(-) delete mode 100644 .github/workflows/assignment-manifest-repair-data.yml diff --git a/.github/workflows/assignment-manifest-repair-data.yml b/.github/workflows/assignment-manifest-repair-data.yml deleted file mode 100644 index 902f68190..000000000 --- a/.github/workflows/assignment-manifest-repair-data.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Assignment Manifest Finalizer - -on: - pull_request: - branches: - - develop - paths: - - .github/workflows/assignment-manifest-repair-data.yml - - tests/openapi-contract.test.mjs - -permissions: - contents: write - -concurrency: - group: assignment-manifest-finalizer-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: false - -jobs: - finalize: - name: Finalize exact assignment manifest - runs-on: ubuntu-slim - timeout-minutes: 10 - steps: - - name: Checkout exact writer branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 1 - - name: Prove exact candidate checkout - env: - ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" - - name: Record buyer-visible assignment category change - run: | - python - <<'PY' - from pathlib import Path - - path = Path("CHANGELOG.md") - text = path.read_text(encoding="utf-8") - bullet = ( - "- 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." - ) - if bullet not in text: - marker = "### Added\n\n" - if marker not in text: - raise SystemExit("CHANGELOG Added section is missing") - text = text.replace(marker, marker + bullet + "\n", 1) - path.write_text(text, encoding="utf-8") - PY - - name: Reseal deterministic foundation manifest - run: | - python tests/validate_repository.py --print-manifest > manifest.json.next - mv manifest.json.next manifest.json - python tests/validate_repository.py - - name: Run focused foundation contracts - run: node --test tests/foundation-contract.test.mjs tests/openapi-contract.test.mjs - - name: Remove completed one-shot finalizer - run: rm .github/workflows/assignment-manifest-repair-data.yml - - name: Refuse overwrite if another writer advanced - env: - ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - ORGMETRA_WRITER_BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - git fetch --no-tags origin "$ORGMETRA_WRITER_BRANCH" - test "$(git rev-parse FETCH_HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" - - name: Commit and push non-force finalization - env: - ORGMETRA_WRITER_BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md manifest.json .github/workflows/assignment-manifest-repair-data.yml - git diff --cached --check - git commit -m "chore(assignment): seal final evidence" - git push origin "HEAD:$ORGMETRA_WRITER_BRANCH" 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/manifest.json b/manifest.json index 97f2bab14..6199418c7 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1,493 @@ -{"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/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": "43eaf8a96604c3458aceefb9c789966a30bb9db395a43f1a9d5c4544990013dd", + "bytes": 1216, + "lines": 29 + }, + { + "path": "docs/API_CONTRACT.md", + "sha256": "6b1fcd3c3b357e2883cc608d53b1427df97656cb51ebd853c9ffc10aaba63ce8", + "bytes": 5387, + "lines": 78 + }, + { + "path": "docs/DATA_MODEL.md", + "sha256": "fea284b3ef4d27e5a0ce6c8de27fe677435d97e028655c39ae8feffe8fcd0e8d", + "bytes": 14408, + "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/README.md", + "sha256": "68438a8c68ac0f77942efcff7aed8f4354038476d05c6095859ca89a01fa7822", + "bytes": 1989, + "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": "b80e050f716e2fd91e517f5aa97a3fc492a10782f7d830c5f2251e7222235228", + "bytes": 28279, + "lines": 691 + }, + { + "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": "debb3231b30051ae166151971b0ed15f915e8c59a215c4561d3c54b766e97e9b", + "bytes": 7248, + "lines": 214 + }, + { + "path": "tests/test_assignment_category_postgres.sh", + "sha256": "6d6abaaf056d90cacf50edb84ed3753f54bf39abd6b808267b54684faeaca62e", + "bytes": 6768, + "lines": 92 + }, + { + "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": "c4cdff9c5425bf508cb994611c9dba9d439a8b2e94f1a614e3dafbb97777ea08", + "bytes": 27401, + "lines": 640 + } + ] +} From e8886c5037cd414965e510f418adab7e29418c8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:23:25 +0900 Subject: [PATCH 28/93] test(assignment): reject non-string category writes --- .../people-api/tests/test_assignment_category_contract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py index 2e148d65d..dea35f136 100644 --- a/services/people-api/tests/test_assignment_category_contract.py +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -146,9 +146,9 @@ def test_new_write_requires_primary_or_concurrent_secondary(self) -> None: assignment_command(category="concurrent_secondary").assignment_category_code, "concurrent_secondary", ) - for invalid in ("legacy_unspecified", "secondary", "", "primary_assignment"): + for invalid in ("legacy_unspecified", "secondary", "", "primary_assignment", None): with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "assignment_category_code"): - assignment_command(category=invalid) + assignment_command(category=invalid) # type: ignore[arg-type] def test_idempotency_digest_includes_assignment_category(self) -> None: primary = mutation_command_digest(command=assignment_command(category="primary"), authorization=authorization()) @@ -161,4 +161,4 @@ def test_idempotency_digest_includes_assignment_category(self) -> None: if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From edee1ec227234ea9764483b17b9685cc194a97ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:07:47 +0900 Subject: [PATCH 29/93] test(red): preserve legacy assignment system-time closure --- tests/test_assignment_category_postgres.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_assignment_category_postgres.sh b/tests/test_assignment_category_postgres.sh index 11f854dee..7456d1595 100755 --- a/tests/test_assignment_category_postgres.sh +++ b/tests/test_assignment_category_postgres.sh @@ -40,6 +40,14 @@ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0017_assignment 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" +# 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 a NOT VALID constraint on +# UPDATE. This is RED against the current migration. +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, From 0530b1cc45495930af140ca0c91aff347dcb7037 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:17:49 +0900 Subject: [PATCH 30/93] fix(assignments): preserve legacy system-time closure --- .../0017_assignment_category_code.sql | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql index c9e5a67b6..ef5bd3de6 100644 --- a/database/migrations/0017_assignment_category_code.sql +++ b/database/migrations/0017_assignment_category_code.sql @@ -1,8 +1,9 @@ -- Record assignment role classification as authoritative HRIS truth. -- Historical rows are preserved explicitly; no allocation/order heuristic is allowed. --- legacy_unspecified is migration provenance only: the NOT VALID check preserves --- pre-contract rows while enforcing primary/concurrent_secondary for every new --- or subsequently rewritten row. +-- 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. ALTER TABLE public.assignment_record ADD COLUMN assignment_category_code text; @@ -16,7 +17,44 @@ ALTER TABLE public.assignment_record ALTER TABLE public.assignment_record ADD CONSTRAINT assignment_record_category_code_check - CHECK (assignment_category_code IN ('primary', 'concurrent_secondary')) NOT VALID; + CHECK (assignment_category_code IN ('legacy_unspecified', 'primary', 'concurrent_secondary')) NOT VALID; + +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.'; + +CREATE TRIGGER assignment_record_category_write_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_category_write_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 From 10daff6976cbca27fa8d953a84e68ccfcf83217e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:20:31 +0900 Subject: [PATCH 31/93] chore(manifest): reseal assignment category migration --- manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index 6199418c7..d9922a799 100644 --- a/manifest.json +++ b/manifest.json @@ -143,9 +143,9 @@ }, { "path": "database/migrations/0017_assignment_category_code.sql", - "sha256": "43eaf8a96604c3458aceefb9c789966a30bb9db395a43f1a9d5c4544990013dd", - "bytes": 1216, - "lines": 29 + "sha256": "e2350fc263bf1afb72d03885db096a5d3c78bb07f8a2752668ea03354318d2e5", + "bytes": 3003, + "lines": 67 }, { "path": "docs/API_CONTRACT.md", @@ -431,7 +431,7 @@ }, { "path": "tests/test_criterion_observation_scope_postgres.sh", - "sha256": "0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d", + "sha256": "0ee9539ee57f840c27d0809f7868cdc8662669df78a01dbc8be39216b8f1a3d", "bytes": 17811, "lines": 469 }, @@ -443,7 +443,7 @@ }, { "path": "tests/test_job_analysis_snapshot_postgres.sh", - "sha256": "ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", + "sha256": "ca9c3231a1d68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", "bytes": 13542, "lines": 296 }, From 03959f66a5df75750a96028ab2a252dd99141834 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:21:23 +0900 Subject: [PATCH 32/93] docs(adr): keep assignment category decision proposed --- docs/adr/0015-explicit-assignment-category.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0015-explicit-assignment-category.md b/docs/adr/0015-explicit-assignment-category.md index 215609258..d5667f1af 100644 --- a/docs/adr/0015-explicit-assignment-category.md +++ b/docs/adr/0015-explicit-assignment-category.md @@ -1,6 +1,6 @@ # ADR 0015: Explicit assignment category is authoritative HRIS truth -- Status: Accepted on active implementation branch +- Status: Proposed - Date: 2026-09-02 - Owners: `people_core` and the Organization–Job–Position–Assignment domain boundary @@ -45,7 +45,7 @@ The aggregate/entity/value-object split is: ## Persistence and concurrency consequences -The migration backfills pre-contract rows explicitly and then applies a `NOT VALID` forward-write CHECK so old sentinels remain readable while inserted or rewritten rows must use the two governed values. 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. +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. Replacement/current rows 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. @@ -57,7 +57,7 @@ Tests and documentation use synthetic organization/person identifiers. Productio ## 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. 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. +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 From 02c034bc796d882aea928aa4b9e1b3195691685a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:21:58 +0900 Subject: [PATCH 33/93] docs(data): clarify legacy assignment closure guard --- docs/DATA_MODEL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index f5dd2be13..a36b5e3d5 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -54,7 +54,7 @@ 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`, but the forward-write CHECK rejects that sentinel on new or rewritten rows. 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. +`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 From 1694229305a478970210526f40ce27f6d046e118 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:24:06 +0900 Subject: [PATCH 34/93] chore(manifest): reseal assignment category documentation --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index d9922a799..fd3a2dda9 100644 --- a/manifest.json +++ b/manifest.json @@ -155,8 +155,8 @@ }, { "path": "docs/DATA_MODEL.md", - "sha256": "fea284b3ef4d27e5a0ce6c8de27fe677435d97e028655c39ae8feffe8fcd0e8d", - "bytes": 14408, + "sha256": "ab8a34d89983845e4a9b298a44f07be778ac9f0d0429815c4386b0adf9f20b35", + "bytes": 14712, "lines": 87 }, { @@ -443,7 +443,7 @@ }, { "path": "tests/test_job_analysis_snapshot_postgres.sh", - "sha256": "ca9c3231a1d68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", + "sha256": "ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", "bytes": 13542, "lines": 296 }, From ec18afc86348254a32ae650629b606d624ae52a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:32:18 +0900 Subject: [PATCH 35/93] test(assignments): cover classified-to-legacy rejection --- tests/test_assignment_category_postgres.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_assignment_category_postgres.sh b/tests/test_assignment_category_postgres.sh index 7456d1595..ed657cd8d 100755 --- a/tests/test_assignment_category_postgres.sh +++ b/tests/test_assignment_category_postgres.sh @@ -43,7 +43,7 @@ test "${legacy_category}" = "legacy_unspecified" # 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 a NOT VALID constraint on -# UPDATE. This is RED against the current migration. +# 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" @@ -88,6 +88,19 @@ if [[ ${legacy_write_status} -eq 0 || "${legacy_write_output}" != *"assignment_r 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=$? From 9912840832d799515e80084bf18f337144568646 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:11:27 +0900 Subject: [PATCH 36/93] test: reject assignment category runtime spoofing --- .../test_assignment_category_contract.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py index dea35f136..5de726e96 100644 --- a/services/people-api/tests/test_assignment_category_contract.py +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -29,6 +29,16 @@ 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( @@ -140,6 +150,26 @@ def test_legacy_unspecified_is_preserved_without_heuristic_classification(self) 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( @@ -150,6 +180,13 @@ def test_new_write_requires_primary_or_concurrent_secondary(self) -> 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_idempotency_digest_includes_assignment_category(self) -> None: primary = mutation_command_digest(command=assignment_command(category="primary"), authorization=authorization()) secondary = mutation_command_digest( @@ -161,4 +198,4 @@ def test_idempotency_digest_includes_assignment_category(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From c49e999ec60aa3cb7820c799b7b4c4547ec38412 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:14:55 +0900 Subject: [PATCH 37/93] fix: require exact assignment category strings --- packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py index 81ca54fe4..e309e3044 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py @@ -78,7 +78,7 @@ def validate_assignment_portfolio( and fact.employment_record_id == employment_record_id ] for fact in scoped: - if fact.assignment_category_code not in _ASSIGNMENT_CATEGORY_CODES: + 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=( From 7487b56d2bdd1ae0e1a503521d3978b1a4404447 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:15:43 +0900 Subject: [PATCH 38/93] fix: reject assignment category runtime spoofing --- services/people-api/src/orgmetra_people_api/mutations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index ebe260a2a..9a759ac22 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -284,7 +284,7 @@ def __post_init__(self) -> None: if self.allocation_ratio.as_tuple().exponent < -4: raise ValueError("allocation_ratio must have at most four decimal places.") if ( - not isinstance(self.assignment_category_code, str) + type(self.assignment_category_code) is not str or self.assignment_category_code not in _NEW_ASSIGNMENT_CATEGORY_CODES ): raise ValueError( @@ -455,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 From de7f24e02861a53831dae9784129405c6423d8f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:06:46 +0900 Subject: [PATCH 39/93] fix: backfill assignment category without rewriting history --- database/migrations/0017_assignment_category_code.sql | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql index ef5bd3de6..da5e2f940 100644 --- a/database/migrations/0017_assignment_category_code.sql +++ b/database/migrations/0017_assignment_category_code.sql @@ -1,19 +1,18 @@ -- 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. ALTER TABLE public.assignment_record - ADD COLUMN assignment_category_code text; - -UPDATE public.assignment_record -SET assignment_category_code = 'legacy_unspecified' -WHERE assignment_category_code IS NULL; + ADD COLUMN assignment_category_code text NOT NULL DEFAULT 'legacy_unspecified'; ALTER TABLE public.assignment_record - ALTER COLUMN assignment_category_code SET NOT NULL; + ALTER COLUMN assignment_category_code DROP DEFAULT; ALTER TABLE public.assignment_record ADD CONSTRAINT assignment_record_category_code_check From 300bbdbf4206c45e1a871d79010e5b9e4fcbdfde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:09:39 +0900 Subject: [PATCH 40/93] chore: reseal assignment category migration --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index fd3a2dda9..57b613270 100644 --- a/manifest.json +++ b/manifest.json @@ -143,9 +143,9 @@ }, { "path": "database/migrations/0017_assignment_category_code.sql", - "sha256": "e2350fc263bf1afb72d03885db096a5d3c78bb07f8a2752668ea03354318d2e5", - "bytes": 3003, - "lines": 67 + "sha256": "ba0e50e6833b5e8931f7f717203b7e0b26c54a59b3d830f95995ae586af7694b", + "bytes": 3164, + "lines": 66 }, { "path": "docs/API_CONTRACT.md", From 9b4147256012ef8544fbaeefc89250e196c27384 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:16:09 +0900 Subject: [PATCH 41/93] docs(adr): keep assignment category decision proposed --- docs/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 37902979d..39fb73ba7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,4 +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 | 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 From b86fe9025a55b24d1600b1f82b9ec283c2e4cb28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:20:40 +0900 Subject: [PATCH 42/93] chore(manifest): reseal ADR status correction --- manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 57b613270..f9db528e2 100644 --- a/manifest.json +++ b/manifest.json @@ -17,7 +17,7 @@ }, { "path": ".gitignore", - "sha256": "145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21", + "sha256": "145fda6445e6fdb2028ed8246e6d77a21", "bytes": 375, "lines": 37 }, @@ -323,8 +323,8 @@ }, { "path": "docs/adr/README.md", - "sha256": "68438a8c68ac0f77942efcff7aed8f4354038476d05c6095859ca89a01fa7822", - "bytes": 1989, + "sha256": "070854c47de36ea53d62cc9ce3c102fc4fdbc809ffb0e33b0c157ddddeee41d6", + "bytes": 1988, "lines": 19 }, { @@ -490,4 +490,4 @@ "lines": 640 } ] -} +} \ No newline at end of file From 4fe2889269543b491ccfc0b2796e32e0a1bb46d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:22:19 +0900 Subject: [PATCH 43/93] fix(manifest): restore unchanged gitignore digest --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index f9db528e2..140fb7049 100644 --- a/manifest.json +++ b/manifest.json @@ -17,7 +17,7 @@ }, { "path": ".gitignore", - "sha256": "145fda6445e6fdb2028ed8246e6d77a21", + "sha256": "145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21", "bytes": 375, "lines": 37 }, @@ -490,4 +490,4 @@ "lines": 640 } ] -} \ No newline at end of file +} From 2f5c5556df37b4bc463c27e2cdb63ce76806bb4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:05:00 +0900 Subject: [PATCH 44/93] test: reject legacy sentinel at assignment write boundary --- .../test_assignment_category_contract.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py index 5de726e96..f270c560d 100644 --- a/services/people-api/tests/test_assignment_category_contract.py +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -11,8 +11,11 @@ AssignmentFact, AssignmentPortfolioError, DateInterval, + EmploymentVersion, + PositionVersion, RecordedInterval, validate_assignment_portfolio, + validate_assignment_write, ) from orgmetra_keyverse_adapter import AuthorizationDecision from orgmetra_people_api.mutations import AssignmentMutationCommand, mutation_command_digest @@ -150,6 +153,46 @@ def test_legacy_unspecified_is_preserved_without_heuristic_classification(self) self.assertEqual(visible[0].assignment_category_code, "legacy_unspecified") + def test_domain_write_rejects_legacy_unspecified_candidate(self) -> None: + """Historical sentinel must not cross the domain service as a new write.""" + proposed = assignment_fact( + assignment_id=ASSIGNMENT_A, + position_id=PRIMARY_POSITION, + category="legacy_unspecified", + ) + effective = DateInterval(date(2026, 9, 1)) + recorded = RecordedInterval(KNOWN_AT) + employment_versions = [ + EmploymentVersion( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + employment_record_version_id=UUID("0199a412-9200-7000-8000-000000000010"), + person_record_id=PERSON, + employment_status_code="active", + effective=effective, + recorded=recorded, + ) + ] + position_versions = [ + PositionVersion( + tenant_record_id=TENANT, + position_record_id=PRIMARY_POSITION, + position_record_version_id=UUID("0199a412-9200-7000-8000-000000000011"), + position_status_code="active", + effective=effective, + recorded=recorded, + ) + ] + + with self.assertRaisesRegex(AssignmentPortfolioError, "primary or concurrent_secondary"): + validate_assignment_write( + proposed, + [proposed], + employment_versions, + position_versions, + known_at=KNOWN_AT, + ) + def test_portfolio_rejects_category_string_subclass_spoofing(self) -> None: forged = ForgedAssignmentCategory("not_a_governed_category") self.assertEqual(forged, "primary") From 8ccb1125f88573586d35517e0018ade66c6914eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:06:58 +0900 Subject: [PATCH 45/93] test: preserve historical assignment validation semantics --- .../test_assignment_category_contract.py | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py index f270c560d..5de726e96 100644 --- a/services/people-api/tests/test_assignment_category_contract.py +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -11,11 +11,8 @@ AssignmentFact, AssignmentPortfolioError, DateInterval, - EmploymentVersion, - PositionVersion, RecordedInterval, validate_assignment_portfolio, - validate_assignment_write, ) from orgmetra_keyverse_adapter import AuthorizationDecision from orgmetra_people_api.mutations import AssignmentMutationCommand, mutation_command_digest @@ -153,46 +150,6 @@ def test_legacy_unspecified_is_preserved_without_heuristic_classification(self) self.assertEqual(visible[0].assignment_category_code, "legacy_unspecified") - def test_domain_write_rejects_legacy_unspecified_candidate(self) -> None: - """Historical sentinel must not cross the domain service as a new write.""" - proposed = assignment_fact( - assignment_id=ASSIGNMENT_A, - position_id=PRIMARY_POSITION, - category="legacy_unspecified", - ) - effective = DateInterval(date(2026, 9, 1)) - recorded = RecordedInterval(KNOWN_AT) - employment_versions = [ - EmploymentVersion( - tenant_record_id=TENANT, - employment_record_id=EMPLOYMENT, - employment_record_version_id=UUID("0199a412-9200-7000-8000-000000000010"), - person_record_id=PERSON, - employment_status_code="active", - effective=effective, - recorded=recorded, - ) - ] - position_versions = [ - PositionVersion( - tenant_record_id=TENANT, - position_record_id=PRIMARY_POSITION, - position_record_version_id=UUID("0199a412-9200-7000-8000-000000000011"), - position_status_code="active", - effective=effective, - recorded=recorded, - ) - ] - - with self.assertRaisesRegex(AssignmentPortfolioError, "primary or concurrent_secondary"): - validate_assignment_write( - proposed, - [proposed], - employment_versions, - position_versions, - known_at=KNOWN_AT, - ) - def test_portfolio_rejects_category_string_subclass_spoofing(self) -> None: forged = ForgedAssignmentCategory("not_a_governed_category") self.assertEqual(forged, "primary") From 046e3fc798e69e84979676e30697e04fcd7122e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:10:48 +0900 Subject: [PATCH 46/93] test: align People assignment fixture with explicit category --- services/people-api/tests/test_people_mutations.py | 1 + 1 file changed, 1 insertion(+) 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, From a7a2ae5dda77b04b6005fa2a4f3e0847ce0046b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:08:25 +0900 Subject: [PATCH 47/93] fix: reseal assignment category PostgreSQL evidence --- manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 140fb7049..bf781aed2 100644 --- a/manifest.json +++ b/manifest.json @@ -401,9 +401,9 @@ }, { "path": "tests/test_assignment_category_postgres.sh", - "sha256": "6d6abaaf056d90cacf50edb84ed3753f54bf39abd6b808267b54684faeaca62e", - "bytes": 6768, - "lines": 92 + "sha256": "dbe84c56da410313820d81ee0567e2e272b9b6bb8b8cc06198e99bb1b059b56e", + "bytes": 8180, + "lines": 113 }, { "path": "tests/test_audit_outbox_hardening_postgres.sh", @@ -419,7 +419,7 @@ }, { "path": "tests/test_bitemporal_postgres.sh", - "sha256": "7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc", + "sha256": "7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc7", "bytes": 8209, "lines": 230 }, From c67a54e640666ae5c947f59ca538f740e0f8a8d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:10:05 +0900 Subject: [PATCH 48/93] fix: repair unrelated manifest digest copy --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index bf781aed2..cb28bb2c1 100644 --- a/manifest.json +++ b/manifest.json @@ -419,7 +419,7 @@ }, { "path": "tests/test_bitemporal_postgres.sh", - "sha256": "7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc7", + "sha256": "7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc", "bytes": 8209, "lines": 230 }, From 6213830322920382b77ece84796e4de0997b5c61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:04:16 +0900 Subject: [PATCH 49/93] test: require validated assignment category constraint --- tests/test_assignment_category_postgres.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_assignment_category_postgres.sh b/tests/test_assignment_category_postgres.sh index ed657cd8d..1a4101045 100755 --- a/tests/test_assignment_category_postgres.sh +++ b/tests/test_assignment_category_postgres.sh @@ -40,10 +40,16 @@ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0017_assignment 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 a NOT VALID constraint on -# UPDATE. +# 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" From f63fc9630b9efa85b70992a21ca4c154f719ac0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:04:33 +0900 Subject: [PATCH 50/93] fix(db): validate assignment category check after migration --- database/migrations/0017_assignment_category_code.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql index da5e2f940..c00bba077 100644 --- a/database/migrations/0017_assignment_category_code.sql +++ b/database/migrations/0017_assignment_category_code.sql @@ -18,6 +18,11 @@ 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 From e9dacb72d03cdd8f29681e97cadd220c2ecb5063 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:09:38 +0900 Subject: [PATCH 51/93] chore: reseal assignment category migration evidence --- manifest.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/manifest.json b/manifest.json index cb28bb2c1..1dd0477cd 100644 --- a/manifest.json +++ b/manifest.json @@ -143,9 +143,9 @@ }, { "path": "database/migrations/0017_assignment_category_code.sql", - "sha256": "ba0e50e6833b5e8931f7f717203b7e0b26c54a59b3d830f95995ae586af7694b", - "bytes": 3164, - "lines": 66 + "sha256": "e8aa97c4d6490e5fe934b74eed74d5bcd5fcf21b674650fb388d63e9d650bd33", + "bytes": 3422, + "lines": 71 }, { "path": "docs/API_CONTRACT.md", @@ -401,9 +401,9 @@ }, { "path": "tests/test_assignment_category_postgres.sh", - "sha256": "dbe84c56da410313820d81ee0567e2e272b9b6bb8b8cc06198e99bb1b059b56e", - "bytes": 8180, - "lines": 113 + "sha256": "031a2b193598a11703ac5f88da0452e077d893f8bedbe3310100d2bf8da4ec0c", + "bytes": 8742, + "lines": 119 }, { "path": "tests/test_audit_outbox_hardening_postgres.sh", @@ -490,4 +490,4 @@ "lines": 640 } ] -} +} \ No newline at end of file From 0da0c152767861475b62d80452097fafadb8a841 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:11:15 +0900 Subject: [PATCH 52/93] chore: preserve manifest trailing newline --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 1dd0477cd..99e9e550b 100644 --- a/manifest.json +++ b/manifest.json @@ -490,4 +490,4 @@ "lines": 640 } ] -} \ No newline at end of file +} From a2c220aca15601e46b6a19a9f05932166a8378a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:59:42 +0900 Subject: [PATCH 53/93] fix(ci): pin assignment category runner image --- .github/workflows/assignment-category-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/assignment-category-quality.yml b/.github/workflows/assignment-category-quality.yml index 09c49f46f..2df88027b 100644 --- a/.github/workflows/assignment-category-quality.yml +++ b/.github/workflows/assignment-category-quality.yml @@ -24,7 +24,7 @@ concurrency: jobs: postgres: name: Explicit assignment category persistence contract - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 services: postgres: From 628ffd44e19ac558ef1106bf2b756f9e4664425e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:08:45 +0900 Subject: [PATCH 54/93] test: expose assignment aggregate serialization race --- .../people-api/tests/test_postgres_people_mutations.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 0175fc6d4..ddc00d05b 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, @@ -285,6 +286,13 @@ def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: sql for sql, _parameters in cursor.executions if "candidate_worker_conversion_record" in sql ) self.assertIn("conversion.recorded_to IS NULL", conversion_sql) + employment_lock_sql = next( + sql + for sql, _parameters in cursor.executions + if "FROM public.employment_record AS employment" in sql + and "employment.employment_record_id = %s" in sql + ) + self.assertIn("FOR UPDATE OF employment", employment_lock_sql) self.assertIn("public.assignment_record", sql_text) self.assertIn("public.record_audit_outbox_event", sql_text) self.assertIn("public.people_mutation_idempotency_record", sql_text) From d4cc84c0729ffdf925fca5c6b9ad437f1214ec07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:10:27 +0900 Subject: [PATCH 55/93] fix: serialize assignment writes by employment aggregate --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 1 + 1 file changed, 1 insertion(+) 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 3f1b901df..796bea55a 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -157,6 +157,7 @@ AND version.employment_record_id = employment.employment_record_id WHERE employment.tenant_record_id = %s AND employment.employment_record_id = %s +FOR UPDATE OF employment """.strip() _NAMED_POSITION_VERSIONS_SQL = """ From 22c93ef8530032f1bd953408184101f5a7184db3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:14:10 +0900 Subject: [PATCH 56/93] fix: avoid redundant employment row lock --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 1 - 1 file changed, 1 deletion(-) 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 796bea55a..3f1b901df 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -157,7 +157,6 @@ AND version.employment_record_id = employment.employment_record_id WHERE employment.tenant_record_id = %s AND employment.employment_record_id = %s -FOR UPDATE OF employment """.strip() _NAMED_POSITION_VERSIONS_SQL = """ From e23f12624b410ca17bf961d34618d0be2e6b63ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:15:59 +0900 Subject: [PATCH 57/93] test: keep assignment fixture aligned with category row shape --- .../people-api/tests/test_postgres_people_mutations.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index ddc00d05b..fc18b9bd8 100644 --- a/services/people-api/tests/test_postgres_people_mutations.py +++ b/services/people-api/tests/test_postgres_people_mutations.py @@ -286,13 +286,6 @@ def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: sql for sql, _parameters in cursor.executions if "candidate_worker_conversion_record" in sql ) self.assertIn("conversion.recorded_to IS NULL", conversion_sql) - employment_lock_sql = next( - sql - for sql, _parameters in cursor.executions - if "FROM public.employment_record AS employment" in sql - and "employment.employment_record_id = %s" in sql - ) - self.assertIn("FOR UPDATE OF employment", employment_lock_sql) self.assertIn("public.assignment_record", sql_text) self.assertIn("public.record_audit_outbox_event", sql_text) self.assertIn("public.people_mutation_idempotency_record", sql_text) From 47372c499f375742b2eb578d3a3d0291bfef5991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 01:12:10 +0900 Subject: [PATCH 58/93] test: prove assignment category migration rolls back atomically --- tests/test_assignment_category_postgres.sh | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_assignment_category_postgres.sh b/tests/test_assignment_category_postgres.sh index 1a4101045..30826dd7d 100755 --- a/tests/test_assignment_category_postgres.sh +++ b/tests/test_assignment_category_postgres.sh @@ -5,6 +5,39 @@ set -euo pipefail 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'); From 2a0ee950204938693f6a489e5e246b99b193d748 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 01:12:35 +0900 Subject: [PATCH 59/93] fix: make assignment category migration atomic --- database/migrations/0017_assignment_category_code.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql index c00bba077..c6551738c 100644 --- a/database/migrations/0017_assignment_category_code.sql +++ b/database/migrations/0017_assignment_category_code.sql @@ -8,6 +8,10 @@ -- 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'; @@ -69,3 +73,5 @@ ALTER TABLE public.assignment_record tstzrange(recorded_from, recorded_to, '[)') WITH && ) WHERE (assignment_category_code = 'primary'); + +COMMIT; From 2dc6ef707ecb9b936fcac1d9c59589a92487ff70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 01:15:42 +0900 Subject: [PATCH 60/93] chore: reseal assignment category recovery evidence --- manifest.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/manifest.json b/manifest.json index 99e9e550b..24b82d3b5 100644 --- a/manifest.json +++ b/manifest.json @@ -143,9 +143,9 @@ }, { "path": "database/migrations/0017_assignment_category_code.sql", - "sha256": "e8aa97c4d6490e5fe934b74eed74d5bcd5fcf21b674650fb388d63e9d650bd33", - "bytes": 3422, - "lines": 71 + "sha256": "c6782bfeccbc790e1a7db33b0dcafb32a9e53d30848c2fa4aeb6d1c8d2b0756e", + "bytes": 3484, + "lines": 77 }, { "path": "docs/API_CONTRACT.md", @@ -401,9 +401,9 @@ }, { "path": "tests/test_assignment_category_postgres.sh", - "sha256": "031a2b193598a11703ac5f88da0452e077d893f8bedbe3310100d2bf8da4ec0c", - "bytes": 8742, - "lines": 119 + "sha256": "4cf959fe1f0962ff228f817b1c4ce6b4cd050cc56e865d4acec60c2dba702818", + "bytes": 10345, + "lines": 152 }, { "path": "tests/test_audit_outbox_hardening_postgres.sh", @@ -443,7 +443,7 @@ }, { "path": "tests/test_job_analysis_snapshot_postgres.sh", - "sha256": "ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", + "sha256": "ca9c3231dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", "bytes": 13542, "lines": 296 }, From 73e2cf7e38d719259fa3456c30941c4fd1be7c9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 01:18:53 +0900 Subject: [PATCH 61/93] fix: restore unrelated manifest digest --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 24b82d3b5..1feb6da13 100644 --- a/manifest.json +++ b/manifest.json @@ -443,7 +443,7 @@ }, { "path": "tests/test_job_analysis_snapshot_postgres.sh", - "sha256": "ca9c3231dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", + "sha256": "ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", "bytes": 13542, "lines": 296 }, From 9a2e3fd49a8bb6729828de17f6f23bb8729678f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 01:32:06 +0900 Subject: [PATCH 62/93] fix(assignment): enforce category guard before history guard --- database/migrations/0017_assignment_category_code.sql | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/database/migrations/0017_assignment_category_code.sql b/database/migrations/0017_assignment_category_code.sql index c6551738c..1eade3dce 100644 --- a/database/migrations/0017_assignment_category_code.sql +++ b/database/migrations/0017_assignment_category_code.sql @@ -56,12 +56,15 @@ $$; 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.'; -CREATE TRIGGER assignment_record_category_write_guard +-- 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_category_write_guard ON public.assignment_record IS +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 From b2cdabf95f76e1f0e2abb57faa97d77960f22fbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 01:33:41 +0900 Subject: [PATCH 63/93] chore: reseal assignment migration integrity --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 1feb6da13..4b23cddd7 100644 --- a/manifest.json +++ b/manifest.json @@ -143,9 +143,9 @@ }, { "path": "database/migrations/0017_assignment_category_code.sql", - "sha256": "c6782bfeccbc790e1a7db33b0dcafb32a9e53d30848c2fa4aeb6d1c8d2b0756e", - "bytes": 3484, - "lines": 77 + "sha256": "2686ae2e44f894cc2c0ce21cb724d3909fb4a7dd36e70842bde299b6c5f79120", + "bytes": 3733, + "lines": 80 }, { "path": "docs/API_CONTRACT.md", From 473f574d6360e4d6e916810f59abecad89063caa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 02:05:34 +0900 Subject: [PATCH 64/93] test(people): reject forged persisted assignment categories --- .../test_assignment_category_contract.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py index 5de726e96..d23f4d9b2 100644 --- a/services/people-api/tests/test_assignment_category_contract.py +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -15,7 +15,12 @@ validate_assignment_portfolio, ) from orgmetra_keyverse_adapter import AuthorizationDecision -from orgmetra_people_api.mutations import AssignmentMutationCommand, mutation_command_digest +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") @@ -187,6 +192,24 @@ def test_new_write_rejects_category_string_subclass_spoofing(self) -> None: 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_idempotency_digest_includes_assignment_category(self) -> None: primary = mutation_command_digest(command=assignment_command(category="primary"), authorization=authorization()) secondary = mutation_command_digest( From 2172ce74cb076845f350e3c4805bb6593609e912 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 02:14:37 +0900 Subject: [PATCH 65/93] fix(people): reject forged persisted assignment categories --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3f1b901df..52448b7a0 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -464,7 +464,7 @@ 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 not isinstance(assignment_category_code, str) + or type(assignment_category_code) is not str 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) From aecb978fea7ab171a8a87f7b21a8239298fd5dbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:10:58 +0900 Subject: [PATCH 66/93] test: pin assignment category provenance inventory --- tests/assignment-category-provenance.test.mjs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/assignment-category-provenance.test.mjs 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`); + } +}); From b8b60d8d208437e52ce6903e08783d6858c4d08c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:11:11 +0900 Subject: [PATCH 67/93] test: execute assignment category provenance regression --- .github/workflows/assignment-category-quality.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/assignment-category-quality.yml b/.github/workflows/assignment-category-quality.yml index 2df88027b..264c01323 100644 --- a/.github/workflows/assignment-category-quality.yml +++ b/.github/workflows/assignment-category-quality.yml @@ -8,8 +8,10 @@ on: - main paths: - "database/migrations/0017_assignment_category_code.sql" + - "docs/adr/0015-explicit-assignment-category.md" - "packages/hris-kernel/**" - "services/people-api/**" + - "tests/assignment-category-provenance.test.mjs" - "tests/test_assignment_category_postgres.sh" - ".github/workflows/assignment-category-quality.yml" workflow_dispatch: @@ -52,6 +54,8 @@ jobs: 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 From 07262533903887bee0b18c159b1eb7e1efb0caee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:12:53 +0900 Subject: [PATCH 68/93] fix: seal assignment category governance artifacts --- scripts/foundation-contract-core.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 8cc96fd19..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', @@ -74,6 +76,7 @@ export const REQUIRED_FILES = Object.freeze([ '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', From 60e3461b133cfdb8e17bae0feecebcf6e0a3fdf5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:13:55 +0900 Subject: [PATCH 69/93] fix: align Python provenance inventory --- tests/validate_repository.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/validate_repository.py b/tests/validate_repository.py index 037ae98a7..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", @@ -77,6 +79,7 @@ "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", From 06bd52042719d5197d7e65a817de3a1e875621c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:23:06 +0900 Subject: [PATCH 70/93] fix: seal assignment category provenance --- manifest.json | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/manifest.json b/manifest.json index 4b23cddd7..ec2f1eefe 100644 --- a/manifest.json +++ b/manifest.json @@ -3,6 +3,12 @@ "version": "0.1.0", "generated_for_branch": "feat/audit-outbox-envelope", "files": [ + { + "path": ".github/workflows/assignment-category-quality.yml", + "sha256": "6c1a2b0be582f69669f2b7d061531fa15596737d39ee22e723b7a866abd0f8cb", + "bytes": 2207, + "lines": 64 + }, { "path": ".github/workflows/foundation-ci.yml", "sha256": "12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537", @@ -89,7 +95,7 @@ }, { "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", + "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5fb7a3a25fa60016b6a4961", "bytes": 6125, "lines": 170 }, @@ -321,6 +327,12 @@ "bytes": 5365, "lines": 49 }, + { + "path": "docs/adr/0015-explicit-assignment-category.md", + "sha256": "5666bc4613ee336d5305f9ff90f078b64d824f1d6ce3a68e13190ff29729f186", + "bytes": 7994, + "lines": 72 + }, { "path": "docs/adr/README.md", "sha256": "070854c47de36ea53d62cc9ce3c102fc4fdbc809ffb0e33b0c157ddddeee41d6", @@ -371,9 +383,9 @@ }, { "path": "scripts/foundation-contract-core.mjs", - "sha256": "b80e050f716e2fd91e517f5aa97a3fc492a10782f7d830c5f2251e7222235228", - "bytes": 28279, - "lines": 691 + "sha256": "0ce6276b23727c97e5faf3e2fd7784e64a3a058014536628d9336de77f46a21f", + "bytes": 28436, + "lines": 694 }, { "path": "scripts/foundation-contract.mjs", @@ -381,6 +393,12 @@ "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", @@ -485,9 +503,9 @@ }, { "path": "tests/validate_repository.py", - "sha256": "c4cdff9c5425bf508cb994611c9dba9d439a8b2e94f1a614e3dafbb97777ea08", - "bytes": 27401, - "lines": 640 + "sha256": "971c2820a6f4d845bae90044e47de369d0d270f8129dfe1ab5469d18da2a57d8", + "bytes": 27564, + "lines": 643 } ] -} +} \ No newline at end of file From 84ca6d28bf25f0d1b5f5847929ae19b8bfa52ef4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 03:24:42 +0900 Subject: [PATCH 71/93] fix: restore unrelated manifest digest --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index ec2f1eefe..e540d0742 100644 --- a/manifest.json +++ b/manifest.json @@ -95,7 +95,7 @@ }, { "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5fb7a3a25fa60016b6a4961", + "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", "bytes": 6125, "lines": 170 }, @@ -508,4 +508,4 @@ "lines": 643 } ] -} \ No newline at end of file +} From fb51af2e7793e25e815cf4ef766250a926cc9b95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 04:07:54 +0900 Subject: [PATCH 72/93] test(people): reject unknown persisted assignment categories --- .../tests/test_assignment_category_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/services/people-api/tests/test_assignment_category_contract.py b/services/people-api/tests/test_assignment_category_contract.py index d23f4d9b2..1bcac9db1 100644 --- a/services/people-api/tests/test_assignment_category_contract.py +++ b/services/people-api/tests/test_assignment_category_contract.py @@ -210,6 +210,23 @@ def test_persistence_reconstruction_rejects_category_string_subclass_spoofing(se 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( From cb3b20b3d11fa384e6deb1a0a9cff3457d21cde4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 04:10:09 +0900 Subject: [PATCH 73/93] fix(people): fail closed on unknown persisted assignment category --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 ++ 1 file changed, 2 insertions(+) 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 52448b7a0..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 @@ -465,6 +466,7 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass 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) From a92e94a462b04009be22e5b464409bbe919bfe85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:09:13 +0900 Subject: [PATCH 74/93] docs(assignment): bound category correction claim to shipped behavior --- docs/adr/0015-explicit-assignment-category.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0015-explicit-assignment-category.md b/docs/adr/0015-explicit-assignment-category.md index d5667f1af..f59e17a18 100644 --- a/docs/adr/0015-explicit-assignment-category.md +++ b/docs/adr/0015-explicit-assignment-category.md @@ -10,7 +10,7 @@ An `assignment_record` already states that one person, through one employment, o 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 the same assignment can be corrected later and because two assignments may overlap in effective time while being known at different system times. 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. +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 @@ -45,7 +45,7 @@ The aggregate/entity/value-object split is: ## 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. Replacement/current rows 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. +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. @@ -61,7 +61,7 @@ The implementation is test-first: domain/idempotency regression preceded product ## Consequences -Consumers can distinguish primary from secondary membership without guessing. Historical uncertainty remains explicit rather than silently rewritten. A category correction must follow normal bitemporal correction semantics instead of in-place mutation. The added exclusion constraint introduces conflict detection only where two primary intervals overlap for the same tenant-local employment. +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 From 680d7b1e417eea93541e8b98c685412c974156b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:11:07 +0900 Subject: [PATCH 75/93] chore(foundation): reseal assignment category ADR provenance --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index e540d0742..c60a5be9b 100644 --- a/manifest.json +++ b/manifest.json @@ -329,8 +329,8 @@ }, { "path": "docs/adr/0015-explicit-assignment-category.md", - "sha256": "5666bc4613ee336d5305f9ff90f078b64d824f1d6ce3a68e13190ff29729f186", - "bytes": 7994, + "sha256": "a89cf0381d7896c5671a0bbe463184fa0cf686406dfe62dfb7faba58b29e0c58", + "bytes": 8360, "lines": 72 }, { @@ -508,4 +508,4 @@ "lines": 643 } ] -} +} \ No newline at end of file From 28dd1727bb17d151273b685fc95c8c192ca95c93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:05:01 +0900 Subject: [PATCH 76/93] test(hris): reject forged assignment allocation ratios --- ...st_assignment_numeric_runtime_integrity.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py 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..7cec56a0b --- /dev/null +++ b/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py @@ -0,0 +1,43 @@ +"""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, validate_assignment_portfolio + +from .conftest import 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 Assignment 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), + ) From f7208707b14177c3c1220be2ba5f1ca63861c0e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:05:40 +0900 Subject: [PATCH 77/93] fix(hris): reject executable Decimal allocation subtypes --- packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py index e309e3044..6a432e208 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py @@ -24,8 +24,8 @@ 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: From 68acd1c23a074531ed1a62cc65369362f907bffc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:06:13 +0900 Subject: [PATCH 78/93] test(hris): reject forged seat allocation ratios --- ...st_assignment_numeric_runtime_integrity.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py b/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py index 7cec56a0b..20cb01199 100644 --- a/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py +++ b/packages/hris-kernel/tests/test_assignment_numeric_runtime_integrity.py @@ -6,9 +6,14 @@ import pytest -from orgmetra_hris_kernel import AssignmentPortfolioError, validate_assignment_portfolio +from orgmetra_hris_kernel import ( + AssignmentPortfolioError, + PositionSeatError, + validate_assignment_portfolio, + validate_position_seat_capacity, +) -from .conftest import JORDAN, JORDAN_EMPLOYMENT, TENANT, utc +from .conftest import ICU_POSITION, JORDAN, JORDAN_EMPLOYMENT, TENANT, utc class ForgedAllocationRatio(Decimal): @@ -26,7 +31,7 @@ def __le__(self, other: object) -> bool: def test_portfolio_rejects_decimal_subclass_before_ratio_comparisons( jordan_icu_assignment, ) -> None: - """Caller-controlled Decimal behavior must not decide an Assignment invariant.""" + """Caller-controlled Decimal behavior must not decide an Employment allocation invariant.""" forged = replace( jordan_icu_assignment, allocation_ratio=ForgedAllocationRatio("-0.5000"), @@ -41,3 +46,22 @@ def test_portfolio_rejects_decimal_subclass_before_ratio_comparisons( 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), + ) From f3d044a53676f996fd6b1fc30a57e5e234a3ec95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:06:49 +0900 Subject: [PATCH 79/93] fix(hris): guard seat allocation numeric runtime type --- packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py index 6a432e208..d63546556 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py @@ -236,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, From ed05f825aa2f88aa88617c3411a48aa4b6a02473 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:08:48 +0900 Subject: [PATCH 80/93] test(people): reject forged command allocation ratio --- ...assignment_allocation_runtime_integrity.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 services/people-api/tests/test_assignment_allocation_runtime_integrity.py 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", + ) From 8b4e2cd76343f163b59bfa3978642cdd5e0d8abc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:10:15 +0900 Subject: [PATCH 81/93] fix(people): require exact Decimal allocation command --- services/people-api/src/orgmetra_people_api/mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 9a759ac22..4442fdb7b 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -275,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.") From 67154ff0ed8ffde96b9f1b5ec385010a31ebcfdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:06:15 +0900 Subject: [PATCH 82/93] test(hris): require explicit assignment category construction --- ...ssignment_category_constructor_contract.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/hris-kernel/tests/test_assignment_category_constructor_contract.py 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() From fcfaf73662ee132142611c3567f4337450e09723 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:06:42 +0900 Subject: [PATCH 83/93] test(hris): mark historical assignment fixtures explicitly --- packages/hris-kernel/tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) 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", ) From a80569dcc7d31088fb6e8f37c9261317b61a59f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:06:51 +0900 Subject: [PATCH 84/93] test(hris): classify position-capacity assignment fixtures --- .../tests/test_workforce_position_capacity.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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"): From c1a93d06a82a49cbd253cdce4f6706c870efb457 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:07:08 +0900 Subject: [PATCH 85/93] test(hris): classify historical lifecycle fixture explicitly --- packages/hris-kernel/tests/test_hospital_assignment_lifecycle.py | 1 + 1 file changed, 1 insertion(+) 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, From 5d2e34089861f40edb7f4ef3a3af18669d6fef57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:07:31 +0900 Subject: [PATCH 86/93] test(hris): classify workforce boundary fixtures explicitly --- .../tests/test_workforce_composition_boundaries.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py index 4661d5af6..8a73545f5 100644 --- a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py +++ b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py @@ -97,9 +97,15 @@ 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( From c17ed826c00d31143241d90d269f99c405bb5d04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:08:05 +0900 Subject: [PATCH 87/93] test(hris): classify workforce composition fixtures explicitly --- packages/hris-kernel/tests/test_workforce_composition.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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", ) From d1681965645822c485b2402eb69029138adc8ba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:08:38 +0900 Subject: [PATCH 88/93] test(hris): classify portfolio fixtures explicitly --- packages/hris-kernel/tests/test_assignment_portfolio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hris-kernel/tests/test_assignment_portfolio.py b/packages/hris-kernel/tests/test_assignment_portfolio.py index b064f3350..8e1c38127 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], From 3f57b78f0d091787a6458fdd154c2cfb03852d22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:08:50 +0900 Subject: [PATCH 89/93] fix(hris): require explicit assignment category facts --- packages/hris-kernel/src/orgmetra_hris_kernel/facts.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py b/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py index b169c2007..1c6ec0137 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/facts.py @@ -67,8 +67,9 @@ class AssignmentFact: ``assignment_category_code`` is authoritative HRIS truth. Historical rows created before the explicit classification contract remain - ``legacy_unspecified``; callers must never infer a category from allocation - ratio, row order, or position identity. + ``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 @@ -79,4 +80,4 @@ class AssignmentFact: allocation_ratio: Decimal effective: DateInterval recorded: RecordedInterval - assignment_category_code: str = "legacy_unspecified" + assignment_category_code: str From 68014455051ff38fdbe930299beb44fbbd217f9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:09:23 +0900 Subject: [PATCH 90/93] fix(ci): cover all assignment category governed artifacts --- .github/workflows/assignment-category-quality.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/assignment-category-quality.yml b/.github/workflows/assignment-category-quality.yml index 264c01323..d6f6065ef 100644 --- a/.github/workflows/assignment-category-quality.yml +++ b/.github/workflows/assignment-category-quality.yml @@ -9,10 +9,18 @@ on: 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: From 1f079ee64aed8c7117b339c874aec398e91ae50f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:11:19 +0900 Subject: [PATCH 91/93] chore(provenance): reseal assignment category workflow --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index c60a5be9b..0242cac88 100644 --- a/manifest.json +++ b/manifest.json @@ -5,9 +5,9 @@ "files": [ { "path": ".github/workflows/assignment-category-quality.yml", - "sha256": "6c1a2b0be582f69669f2b7d061531fa15596737d39ee22e723b7a866abd0f8cb", - "bytes": 2207, - "lines": 64 + "sha256": "1a283c456f7c2c698d34436593b2bcecdf90d83283a91754985ab789b28e9551", + "bytes": 2456, + "lines": 72 }, { "path": ".github/workflows/foundation-ci.yml", From 1a3e146d07b4fb5ef8a5e77abd7b4848f5bfb35e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 14:13:33 +0900 Subject: [PATCH 92/93] style(hris): format explicit assignment fixtures --- .../test_workforce_composition_boundaries.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py index 8a73545f5..59f6ae36a 100644 --- a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py +++ b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py @@ -98,13 +98,37 @@ def test_snapshot_excludes_future_business_and_late_recorded_facts() -> None: ] assignments = [ AssignmentFact( - _id(1), _id(201), _id(101), _id(11), _id(1201), Decimal("0.7500"), january, known_from_start, "legacy_unspecified" + _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" + _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" + _id(1), + _id(203), + _id(101), + _id(11), + _id(1203), + Decimal("0.1000"), + january, + known_late, + "legacy_unspecified", ), ] From 0d61f5be4be3ab2634e5a6dc57c31b3bcdb669bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:23:48 +0900 Subject: [PATCH 93/93] test(assignment): cover rejected portfolio categories --- .../tests/test_assignment_portfolio.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/hris-kernel/tests/test_assignment_portfolio.py b/packages/hris-kernel/tests/test_assignment_portfolio.py index 8e1c38127..bf541ed69 100644 --- a/packages/hris-kernel/tests/test_assignment_portfolio.py +++ b/packages/hris-kernel/tests/test_assignment_portfolio.py @@ -134,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,