"""Durable revision-to-intake handoff; never an approval state machine.""" from dataclasses import replace import json import uuid from .intake import KIND, MEMO_KEYS, SubmitterGrant, validate_submission from .memo import Principal, BindingLevel, StepKind from .review import UI_RELEASE from .records import dumps, memo_from from .store import Conflict, EvidenceUnavailable def supports_review(memo): """Structural compatibility only; publication and authorization remain required.""" return (memo.locale == 'en' and memo.binding_level is BindingLevel.ORGANIZATIONAL and memo.step_kind is StepKind.APPROVE and memo.ui_release == UI_RELEASE) def handoff(repository, tenant, memo_id, version): if type(version) is not int or version < 1: raise ValueError('positive revision required') with repository.store._connection() as db: repository._record(db, tenant, memo_id) repository.store._memo(db, memo_id, version) row = db.execute("SELECT id,body FROM lifecycle_events WHERE tenant=? AND memo_id=? " "AND version=? AND kind='review_requested'", (tenant, memo_id, version)).fetchone() if row is None: raise EvidenceUnavailable('review request unavailable') body = json.loads(row['body']) return {'event_id': row['id'], **{k: body[k] for k in ('review_memo_id', 'envelope')}} def review_request(db, review_id): row = db.execute("SELECT * FROM lifecycle_events WHERE kind='review_requested' " "AND json_extract(body,'$.review_memo_id')=?", (review_id,)).fetchone() return row def require_current(db, review_id): row = review_request(db, review_id) if row is not None: current = db.execute('SELECT MAX(version) FROM memos WHERE id=?', (row['memo_id'],)).fetchone()[0] if current != row['version']: raise Conflict('review source revision changed') return row def check_intake(db, envelope, submitter): row = require_current(db, envelope['memo']['id']) if row is not None: body = json.loads(row['body']) if row['tenant'] != 'tenant:platform' or body['submitter'] != submitter or body['envelope'] != envelope: raise Conflict('review handoff does not match the retained request') def prepare(repository, context, memo_id, version, options, *, key, guard): required = {'submitter', 'memo_class', 'review_group', 'approval_intent'} if not isinstance(options, dict) or set(options) != required: raise ValueError('exact review routing required') if context.tenant != 'tenant:platform' or type(version) is not int or version < 1: raise ValueError('platform revision required for intake') if not isinstance(options['submitter'], str) or not options['submitter'].strip() or len(options['submitter']) > 200: raise ValueError('invalid submitter') scope, digest = repository._request(context, 'request_review', key, {'memo_id': memo_id, 'version': version, 'options': options}) with repository.store._transaction() as db: policy_id = guard(db) repository._record(db, context.tenant, memo_id) replay = repository._replay(db, scope, digest) if replay: body = json.loads(db.execute('SELECT body FROM lifecycle_events WHERE id=?', (replay,)).fetchone()[0]) return {'event_id': replay, **{k: body[k] for k in ('review_memo_id', 'envelope')}} memo = repository.store._memo(db, memo_id) if memo.version != version: raise Conflict('review requires the current revision') if not supports_review(memo): raise ValueError('unsupported human review profile') if repository._policy_context(db, context.tenant, memo_id, version)['published_audience'] is None: raise Conflict('publish this revision before requesting review') if db.execute("SELECT 1 FROM lifecycle_events WHERE memo_id=? AND version=? AND kind='review_requested'", (memo_id, version)).fetchone(): raise Conflict('this revision already has a review request') review_id = 'review-' + str(uuid.uuid4()) draft = replace(memo, id=review_id, version=1, binding=replace(memo.binding, principal=Principal('pending-human-session', 'person', 'To be assigned'))) projection = {k: v for k, v in json.loads(dumps(draft)).items() if k in MEMO_KEYS} # Never silently discard content the narrower intake projection cannot carry. if dumps(memo_from(projection)) != dumps(draft): raise ValueError('memo requires a broader intake projection') packet = [] for item in draft.packet: document = db.execute('SELECT media_type,content FROM documents WHERE digest=?', (item.hash,)).fetchone() if document is None: raise EvidenceUnavailable('packet unavailable') packet.append({'item_id': item.item_id, 'media_type': document['media_type'], 'content': bytes(document['content']).decode('utf-8')}) envelope = {'kind': KIND, 'version': 1, 'idempotency_key': review_id, 'memo_class': options['memo_class'], 'review_group': options['review_group'], 'approval_intent': options['approval_intent'], 'memo': projection, 'packet': packet} # Shape validation only. Actual submitter admission is always rechecked by intake. validate_submission(envelope, submitter_id=options['submitter'], grant=SubmitterGrant( options['submitter'], frozenset({options['memo_class']}), frozenset({options['review_group']}))) if len(dumps(envelope).encode()) > 256 * 1024: raise ValueError('review envelope too large') body = {'review_memo_id': review_id, 'submitter': options['submitter'], 'envelope': envelope} event_id = repository._append(db, scope, digest, memo, 'review_requested', body, policy_id) return {'event_id': event_id, 'review_memo_id': review_id, 'envelope': envelope} def outcomes(repository, tenant, memo_id, version): if type(version) is not int or version < 1: raise ValueError('positive revision required') with repository.store._connection() as db: repository._record(db, tenant, memo_id) repository.store._memo(db, memo_id, version) row = db.execute("SELECT body FROM lifecycle_events WHERE tenant=? AND memo_id=? AND version=? " "AND kind='review_requested'", (tenant, memo_id, version)).fetchone() if row is None: return {'review': None, 'dispositions': []} body = json.loads(row['body']) review_id = body['review_memo_id'] ledger = db.execute("SELECT state FROM intake_ledger WHERE memo_id=?", (review_id,)).fetchone() result = {'review': {'memo_id': review_id, 'intake_state': ledger['state'] if ledger else 'not_submitted'}, 'dispositions': []} result['review']['stale'] = repository.store._memo(db, memo_id).version != version if ledger and ledger['state'] == 'ready' and not result['review']['stale']: result['review']['path'] = '/review?memo_id=' + review_id for item in db.execute('SELECT d.body,s.state,s.approved_at FROM dispositions d ' 'JOIN presentations p ON p.id=d.presentation_id ' 'LEFT JOIN submissions s ON s.disposition_id=d.id WHERE p.memo_id=? ORDER BY d.rowid', (review_id,)): result['dispositions'].append({'disposition': json.loads(item['body']), 'submission_state': item['state'], 'approved_at': item['approved_at']}) return result