From 3f29f97cce853ce7ffe018ac4ada091a21d274b1 Mon Sep 17 00:00:00 2001 From: snapsynapse <57973674+snapsynapse@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:01:30 -0600 Subject: [PATCH] fix: keep obligations created by draft terms in the draft state A concrete obligation took its lifecycle from the shared obligation definition before its creating term. The draft JIA's first-session duty therefore projected lifecycle, operative and enforcement status as unknown, because the shared definition declares "operative", even though its only creating term is a draft. Joined into a consumer answer, that duty could read as binding. When the creating term is draft or proposed, the concrete obligation now takes the term's lifecycle and derives operative and enforcement status from it, ignoring the definition's generic values. Issued creators are unchanged. One record changes: the JIA duty now projects draft, future, unsignaled, matching its term. Adds a projection test with draft, proposed and issued-creator controls, and regenerates the Obligation-First fingerprint (one Requirement moves from unknown to populated on three fields). Part of PUB-2 (EV02). Co-Authored-By: Claude Opus 5.5 --- MANIFEST.yaml | 4 +-- docs/MANIFEST.yaml | 4 +-- docs/api/v1/of/obligations.json | 6 ++-- ...ssion-disclose-genai-on-first-session.json | 6 ++-- ...ssion-disclose-genai-on-first-session.json | 6 ++-- scripts/lib/obligation-first.js | 11 +++++-- tests/fixtures/of-contract-fingerprint.json | 12 ++++---- tests/source-admission.test.js | 29 +++++++++++++++++++ 8 files changed, 56 insertions(+), 22 deletions(-) diff --git a/MANIFEST.yaml b/MANIFEST.yaml index 6f8d6656..9f14fb4c 100644 --- a/MANIFEST.yaml +++ b/MANIFEST.yaml @@ -813,7 +813,7 @@ files: - path: scripts/lib/obligation-first.js role: tooling - hash: sha256:8ea2c5010e3ff91c3cda8e9398a40c4c2ac83baa712337b1b88250c32f11c041 + hash: sha256:868310c404cbd0eab87e045541de95c23a2b9e67194495bc0efa130c1e3319fd note: scripts/lib/obligation-first.js - path: scripts/lib/temporal-status.js @@ -1038,7 +1038,7 @@ files: - path: tests/source-admission.test.js role: code - hash: sha256:d2ec25e0a18c9c83b9ec505fedecf776f2b5971b4f4de24102273681467f45b2 + hash: sha256:0d13f401800e2b95eea010905c6eea6c1c8081ed10d518dc8836931b5c4a6106 note: Source admission policy and producer-gate regressions - path: tests/publication-state.test.js diff --git a/docs/MANIFEST.yaml b/docs/MANIFEST.yaml index 6f8d6656..9f14fb4c 100644 --- a/docs/MANIFEST.yaml +++ b/docs/MANIFEST.yaml @@ -813,7 +813,7 @@ files: - path: scripts/lib/obligation-first.js role: tooling - hash: sha256:8ea2c5010e3ff91c3cda8e9398a40c4c2ac83baa712337b1b88250c32f11c041 + hash: sha256:868310c404cbd0eab87e045541de95c23a2b9e67194495bc0efa130c1e3319fd note: scripts/lib/obligation-first.js - path: scripts/lib/temporal-status.js @@ -1038,7 +1038,7 @@ files: - path: tests/source-admission.test.js role: code - hash: sha256:d2ec25e0a18c9c83b9ec505fedecf776f2b5971b4f4de24102273681467f45b2 + hash: sha256:0d13f401800e2b95eea010905c6eea6c1c8081ed10d518dc8836931b5c4a6106 note: Source admission policy and producer-gate regressions - path: tests/publication-state.test.js diff --git a/docs/api/v1/of/obligations.json b/docs/api/v1/of/obligations.json index af6a200c..f673b349 100644 --- a/docs/api/v1/of/obligations.json +++ b/docs/api/v1/of/obligations.json @@ -20,9 +20,9 @@ "anchors": [ "https://everyailaw.com/obligation-category/transparency.json" ], - "lifecycle_status": "unknown", - "operative_status": "unknown", - "enforcement_status": "unknown", + "lifecycle_status": "draft", + "operative_status": "future", + "enforcement_status": "unsignaled", "jurisdiction": { "@type": "of:Jurisdiction", "territorial_scope": [ diff --git a/docs/api/v1/of/records/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json b/docs/api/v1/of/records/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json index 4d6b3683..2357d56a 100644 --- a/docs/api/v1/of/records/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json +++ b/docs/api/v1/of/records/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json @@ -16,9 +16,9 @@ "anchors": [ "https://everyailaw.com/obligation-category/transparency.json" ], - "lifecycle_status": "unknown", - "operative_status": "unknown", - "enforcement_status": "unknown", + "lifecycle_status": "draft", + "operative_status": "future", + "enforcement_status": "unsignaled", "jurisdiction": { "@type": "of:Jurisdiction", "territorial_scope": [ diff --git a/docs/obligation/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json b/docs/obligation/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json index 4d6b3683..2357d56a 100644 --- a/docs/obligation/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json +++ b/docs/obligation/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json @@ -16,9 +16,9 @@ "anchors": [ "https://everyailaw.com/obligation-category/transparency.json" ], - "lifecycle_status": "unknown", - "operative_status": "unknown", - "enforcement_status": "unknown", + "lifecycle_status": "draft", + "operative_status": "future", + "enforcement_status": "unsignaled", "jurisdiction": { "@type": "of:Jurisdiction", "territorial_scope": [ diff --git a/scripts/lib/obligation-first.js b/scripts/lib/obligation-first.js index 8ea5ec59..553d2e95 100644 --- a/scripts/lib/obligation-first.js +++ b/scripts/lib/obligation-first.js @@ -347,7 +347,12 @@ function buildObligationRecords(config, data) { const detail = provisionDetails.get(mapping.id); const provision = detail ? detail.provision : {}; const recordId = concreteObligationId(mapping.id, obligationId); - const lifecycle = primary.lifecycle_status || provision.status || container?.status; + // A concrete obligation created only by a draft or proposed term is + // itself unissued: the term's state overrides the definition's + // generic lifecycle so a draft-derived duty never reads as operative. + const termStatus = provision.status || container?.status; + const draftCreated = ['draft', 'proposed'].includes(normalizeStatus(termStatus)); + const lifecycle = draftCreated ? termStatus : primary.lifecycle_status || termStatus; records.push(withEvidenceBoundary(compact({ '@context': recordContext(config), '@type': obligationType(primary.group), @@ -359,8 +364,8 @@ function buildObligationRecords(config, data) { applicability: provision.scope ? [`scope:${provision.scope}`] : undefined, anchors: sourceOwnedAnchors(mapping).obligationAnchors.length ? sourceOwnedAnchors(mapping).obligationAnchors : undefined, lifecycle_status: normalizeStatus(lifecycle), - operative_status: primary.operative_status || container?.operative_status || operativeStatus(lifecycle, force), - enforcement_status: primary.enforcement_status || container?.enforcement_status || enforcementStatus({ status: lifecycle, type: container?.type }, force), + operative_status: (draftCreated ? undefined : primary.operative_status) || container?.operative_status || operativeStatus(lifecycle, force), + enforcement_status: (draftCreated ? undefined : primary.enforcement_status) || container?.enforcement_status || enforcementStatus({ status: lifecycle, type: container?.type }, force), jurisdiction: typedJurisdiction(container?.jurisdiction), ...provenance(config, container?.official_url, provision.sections || mapping.source_heading, primary.last_verified || provision.verified || container?.last_verified, instrumentCitation(container || {})), 'pub:primary_id': obligationId, diff --git a/tests/fixtures/of-contract-fingerprint.json b/tests/fixtures/of-contract-fingerprint.json index f3faef7a..178f39e3 100644 --- a/tests/fixtures/of-contract-fingerprint.json +++ b/tests/fixtures/of-contract-fingerprint.json @@ -1894,9 +1894,9 @@ "present": 35 }, "enforcement_status": { - "explicit_unknown": 31, + "explicit_unknown": 30, "missing": 0, - "populated": 4, + "populated": 5, "present": 35 }, "evidence_type": { @@ -1910,15 +1910,15 @@ "present": 35 }, "lifecycle_status": { - "explicit_unknown": 28, + "explicit_unknown": 27, "missing": 0, - "populated": 7, + "populated": 8, "present": 35 }, "operative_status": { - "explicit_unknown": 31, + "explicit_unknown": 30, "missing": 0, - "populated": 4, + "populated": 5, "present": 35 }, "projection_basis": { diff --git a/tests/source-admission.test.js b/tests/source-admission.test.js index 6d8c1160..aee1e927 100644 --- a/tests/source-admission.test.js +++ b/tests/source-admission.test.js @@ -421,3 +421,32 @@ test('Obligation-First issuance determinations exclude original drafts even when const ids = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/api/v1/of/determinations.json'), 'utf8')).determinations.map(record => record['pub:id']); assert.equal(ids.includes('us-ut-oaip-jia-2026-001-issuance'), false); }); + +test('an obligation created only by a draft or proposed term keeps the draft state', () => { + const config = { url: 'https://publedge.org/' }; + const evidence = path => ({ kind: 'instrument', native_path: path, native_file_sha256: '1'.repeat(64), canonical_unit: null, canonical_sha256: '2'.repeat(64), admission_status: 'legacy-unreviewed', review_packet_sha256: null, retained_primary_sha256: null, unresolved_review_state: 'unknown', unresolved: null }); + // The shared definition declares an operative lifecycle; only the creating term decides whether this concrete duty is issued. + const primary = { id: 'example-duty', name: 'Example duty', group: 'requirement', lifecycle_status: 'operative', operative_status: 'operative', enforcement_status: 'enforced', _body: '## Summary\n\nExample.\n', _evidence_input: { ...evidence('data/examples/obligations/example-duty.md'), kind: 'obligation-definition' } }; + const project = container => { + const mapping = { id: 'example-mapping', regulation: container.id, obligations: ['example-duty'], source_heading: 'Summary', _evidence_input: { ...evidence('data/examples/mapping/index.yml'), kind: 'mapping-entry' } }; + return obligationFirst.buildObligationFirstRecords(config, { containers: [container], primaries: [primary], authorities: [], mappingIndex: [mapping] }).obligations[0]; + }; + const base = { id: 'us-ut-oaip-jia-2099-001', type: 'jia', jurisdiction: 'us-ut', authority: 'utah-oaip', _evidence_input: evidence(JIA_FILE) }; + + for (const status of ['draft', 'proposed']) { + const duty = project({ ...base, status }); + assert.equal(duty.lifecycle_status, status, `${status} creator must keep the duty ${status}`); + assert.notEqual(duty.operative_status, 'operative'); + assert.notEqual(duty.enforcement_status, 'enforced'); + } + + // Negative control: an issued creator leaves the definition's own state in place. + const issued = project({ ...base, type: 'rma', status: 'enforcing' }); + assert.ok(!['draft', 'proposed'].includes(issued.lifecycle_status)); + assert.equal(issued.operative_status, 'operative'); + assert.equal(issued.enforcement_status, 'enforced'); + + // The emitted JIA duty matches its draft term. + const emitted = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/api/v1/of/records/utah-mental-health-chatbot-disclosure-2026q2-first-session-disclose-genai-on-first-session.json'), 'utf8')); + assert.deepEqual([emitted.lifecycle_status, emitted.operative_status, emitted.enforcement_status], ['draft', 'future', 'unsignaled']); +});