From e516eb24a3eb5551bdf9ecd270c1ccda8061ebac Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 04:29:12 +0800 Subject: [PATCH 1/4] feat: Add non-executing automation setup intent contract --- docs/README.md | 2 + ...strategy-release-runtime-contract.zh-CN.md | 56 +++ .../common/automation_setup.py | 267 +++++++++++ .../automation-setup-intent.v1.schema.json | 420 ++++++++++++++++++ tests/test_automation_setup_contract.py | 196 ++++++++ 5 files changed, 941 insertions(+) create mode 100644 src/quant_platform_kit/common/automation_setup.py create mode 100644 src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json create mode 100644 tests/test_automation_setup_contract.py diff --git a/docs/README.md b/docs/README.md index ef5f68ee..fccf7d05 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ Read documents at the library revision used by your consumer. These are source-l ## Runtime contracts +- [One-time setup intent](strategy-release-runtime-contract.zh-CN.md#一次确认的自动化-setup-intent): immutable, non-executing owner scope and Decisions handoff; no runtime adoption or activation authority. + - [Platform notification outcomes](platform-notification-outcomes.md): credential-free intents, receipts and log redaction. - [Notification routing policy](notification-routing-policy.md): durable daily facts and urgent incidents. diff --git a/docs/strategy-release-runtime-contract.zh-CN.md b/docs/strategy-release-runtime-contract.zh-CN.md index 8815480d..06c0e3bf 100644 --- a/docs/strategy-release-runtime-contract.zh-CN.md +++ b/docs/strategy-release-runtime-contract.zh-CN.md @@ -12,3 +12,59 @@ 插件 V2 可以把影子信号提供给策略做展示、诊断和研究。共享库注入快照的副本会把所有下单、仓位和 allocation 授权明确降为 `false`,原始 artifact 仍由 `StrategyPluginSignal.payload` 保留用于审计。插件自身不能借由 metadata 授权策略改仓。 跨平台发布必须依次满足:所有目标预装同一 release、所有运行时回传相同 identity、统一交易日生效、观察期完成。任何一个目标缺回执或 digest 不符,发布不得进入 ACTIVE。 + +## 一次确认的自动化 setup intent + +`common.automation_setup.SetupIntent` 和随包发布的 +`schemas/automation-setup-intent.v1.schema.json` 提供独立、非执行的意图合同。 +现有 RuntimeTarget、RuntimeControl、候选晋级和 research mandate 接口不变。 +消费者采用前须固定经审查的 QPK 版本;本合同尚未接入 QRS 或执行器。 + +`SetupIntent.create(subject, decision=None)` 验证并冻结合同; +`to_dict()` / `from_dict()` 用于受保护的合同传输与往返验证,不能直接用于公开列表。 +schema 负责结构检查;Python 合同还检查摘要、金额上下限、目标与控制一致性、 +有效期顺序。JSON 反序列化不能续期或恢复已撤销意图。 + +subject 明确记录不透明的账户引用列表、币种、十进制字符串金额及 cap、 +`one_time` 或带不可变 plan_ref 的 `standing_plan`、费用及储备处理、 +instrument/action 范围、policy_ref 和显式环境。账户真实映射由下游受保护存储负责; +禁止传入账户选择器、账户别名、主机、凭据或自由文本。 +目标和 release 使用既有公开构建器校验,control 使用既有 RuntimeControl 校验, +且必须 disabled。控制储备不是确认的预算。LIVE 只能表示请求意图,不能启用。 +初始 revision 为 1;后续 revision 必须引用前一 subject 摘要。 +摘要绑定内容,不认证所有者,也不匿名化真实资源。 + +decision 记录同一 subject 摘要和 revision、human actor_ref、decision_ref、 +confirmed/rejected、reviewed/effective/expires 时间及 state_version/epoch。 +本版本要求明确、有限且带时区的有效期;无默认无限期。 +不嵌入候选 PromotionDecision,也不把 setup 伪装成候选或消耗 research nonce。 + +`classify_setup_intent(intent, now=..., current=..., proposed_subject=...)` 是纯描述函数。 +current 必须每次从现有认证来源读取,包含 setup/revision/digest、decision_ref、 +state_version/epoch、source_ref、checked_at/valid_until、current/revoked/superseded、 +独立 prerequisites 和 activation_readback。缺失、非法、失配或不在有效区间的 +current 产生阻塞;当前撤销和替代状态不能被旧 confirmed decision 覆盖。 +函数不认证来源、不保存状态、不锁定跨进程写入。 + +返回值只有 intent_status、requires_owner_setup、blocker_codes、changed_fields、 +resume_condition、activation_readback 和两个恒 false 的 authority 字段。 +公开列表及通知摘要只使用这份允许字段投影;不输出账户引用、原始 subject 或 actor。 +同一有效且当前的 confirmed revision 重复分类时 requires_owner_setup=false, +不为每笔交易或每个周期创建确认。账户、金额、环境、release、policy、instrument 或 +plan 等变更列入 changed_fields,须由下游创建并确认后继 revision,不能扩大原范围。 +拒绝、过期或撤销不会自动触发重试审批。 + +Paper minimum 必须是整数 3、4 或 5;这是需求值,不是完成证明。 +本模块不能核验真实完整交易日、较长候选观察窗、风险、对账或告警证据, +因此相关 blockers 始终保留;自报 pass、模拟 session 和通知成功均不能清除它们。 +activation_readback 始终投影为 unknown,和所有者意图分开。 +LIVE 另列 LIVE_ACTIVATION_OWNER_NEEDED。两项 authority 标志在所有路径恒为 false; +没有 may_submit、executor、预算计算或生产开关写入。 + +QRS 后续在现有 Decisions/list/store 中适配 automation_setup subject: +认证会话提供 actor,写入时原子比较 subject 摘要、revision、state_version/epoch, +持久化 request_id 的相同请求幂等结果并拒绝同 key 不同内容;重启或旧进程不能覆盖撤销。 +list/read 返回意图、阻塞、独立应用回执和通知状态;应用证据须由认证后端精确绑定, +完成须有必要 readback 和显式 acknowledgement。通知和网站点击都不能表示运行中。 +本补丁不增加 HTTP 路由、数据库、outbox、任务平台、真实审批或应用接口。 +账户、金额、激活范围的真实一次确认和任何受保护 apply 仍须各自所有者授权。 diff --git a/src/quant_platform_kit/common/automation_setup.py b/src/quant_platform_kit/common/automation_setup.py new file mode 100644 index 00000000..fc098984 --- /dev/null +++ b/src/quant_platform_kit/common/automation_setup.py @@ -0,0 +1,267 @@ +"""Immutable owner setup intent. No execution capability or persistence. + +Snapshots are injected by the authenticated consumer on every read. This +module cannot authenticate their source and never qualifies Paper evidence. +""" +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from decimal import Decimal +from uuid import UUID + +from quant_platform_kit.notifications._redaction import redact_sensitive_data, redact_sensitive_text +from .runtime_control import RuntimeControl +from .runtime_target import build_runtime_target +from .strategy_release import build_strategy_release_identity + +VERSION = 'automation_setup_intent.v1' +_RESUME = { + 'PROPOSED_SUBJECT_INVALID': 'Supply a valid proposed subject before confirming changed scope.', + 'SUCCESSOR_CONFIRMATION_REQUIRED': 'Record an authenticated owner confirmation for a successor revision bound to every changed field.', + 'OWNER_SETUP_REQUIRED': 'Record one authenticated owner decision bound to the exact accounts, amount and activation scope.', + 'REJECTED': 'Do not retry this rejected decision; a genuinely new scope needs separately authorized setup.', + 'NOT_EFFECTIVE': 'Wait until the recorded effective time and re-read the current decision.', + 'EXPIRED': 'Do not renew or replay this expired decision.', + 'CURRENT_SOURCE_UNAVAILABLE': 'Restore an authenticated readable current snapshot within its checked-at and valid-until interval.', + 'CURRENT_BINDING_MISMATCH': 'Re-read the matching current subject, decision, revision, state version and epoch.', + 'REVOKED': 'Do not reuse this revoked intent.', + 'SUPERSEDED': 'Read the confirmed successor revision; do not reuse this superseded intent.', + 'PAPER_EVIDENCE_UNQUALIFIED': 'The Paper evidence owner must qualify authentic complete-session receipts and any longer candidate window.', + 'RISK_RELEASE_NOT_VERIFIED': 'The risk/release owner must authenticate current evidence bound to the reviewed release and policy.', + 'RECONCILIATION_NOT_VERIFIED': 'The reconciliation owner must authenticate complete current reconciliation evidence.', + 'ALERTS_NOT_VERIFIED': 'The alert owner must authenticate resolution of outstanding blocking alerts.', + 'ACTIVATION_NOT_VERIFIED': 'Obtain separately authorized application evidence and authenticated exact-subject readback.', + 'LIVE_ACTIVATION_OWNER_NEEDED': 'A separate exact owner authorization and protected activation/readback are required.', +} +_SUBJECT = {'setup_id', 'revision', 'predecessor_sha256', 'account_refs', 'target', 'control', + 'currency', 'amount', 'cap', 'amount_basis', 'plan_ref', 'fee_treatment', + 'reserve_treatment', 'instruments', 'actions', 'policy_ref', 'paper_min_sessions'} +_TARGET = {'platform_id', 'strategy_profile', 'dry_run_only', 'execution_environment', 'strategy_release'} +_RELEASE = {'release_id', 'manifest_sha256', 'strategy_revision', 'config_sha256', 'risk_policy_sha256', + 'evidence_sha256', 'plugin_bundle_sha256', 'effective_session'} +_CONTROL = {'target_id', 'platform_id', 'strategy_profile', 'enabled', 'mode', 'risk_limits_ref', 'cash_policy'} +_DECISION = {'decision_ref', 'subject_sha256', 'revision', 'actor_ref', 'actor_type', 'outcome', + 'reviewed_at', 'effective_at', 'expires_at', 'state_version', 'epoch'} +_ENVELOPE = {'schema_version', 'subject', 'subject_sha256', 'decision', 'grants_live', 'grants_execution_authority'} +_CURRENT = {'setup_id', 'revision', 'subject_sha256', 'state_version', 'epoch', 'decision_ref', + 'status', 'source_ref', 'checked_at', 'valid_until', 'prerequisites', 'activation_readback'} + + +def _require(condition): + if not condition: + raise ValueError('invalid setup intent') + + +def _object(value, fields): + _require(type(value) is dict and set(value) == fields) + + +def _integer(value): + _require(type(value) is int and value > 0) + + +def _ref(value): + _require(isinstance(value, str) and str(UUID(value)) == value) + + +def _sha(value): + _require(isinstance(value, str) and re.fullmatch('[a-f0-9]{64}', value) is not None) + + +def _label(value): + # Contract labels, never account selectors, URLs, free text or hostnames. + _require(isinstance(value, str) and re.fullmatch('[A-Za-z][A-Za-z0-9_-]{0,63}', value) is not None) + _require(redact_sensitive_text(value) == value) + + +def _time(value): + _require(isinstance(value, str)) + result = datetime.fromisoformat(value.replace('Z', '+00:00')) + _require(result.tzinfo is not None and result.utcoffset() is not None) + return result.astimezone(timezone.utc) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False) + + +def _digest(value): + return hashlib.sha256(_canonical(value).encode()).hexdigest() + + +def _subject(value): + _object(value, _SUBJECT) + _ref(value['setup_id']); _integer(value['revision']); _ref(value['policy_ref']) + if value['revision'] == 1: + _require(value['predecessor_sha256'] is None) + else: + _sha(value['predecessor_sha256']) + accounts = value['account_refs'] + _require(type(accounts) is list and len(accounts) > 0 and len(set(accounts)) == len(accounts)) + for ref in accounts: _ref(ref) + t = value['target']; _object(t, _TARGET) + for key in ('platform_id', 'strategy_profile'): _label(t[key]) + _require(type(t['dry_run_only']) is bool) + _require(t['execution_environment'] in ('dry_run', 'paper', 'live')) + release = t['strategy_release']; _object(release, _RELEASE) + for key in ('release_id', 'strategy_revision'): _label(release[key]) + for key in _RELEASE - {'release_id', 'strategy_revision', 'effective_session'}: _sha(release[key]) + identity = build_strategy_release_identity(release) + build_runtime_target(**{**t, 'strategy_release': identity}) + c = value['control']; _object(c, _CONTROL) + _ref(c['target_id']); _ref(c['risk_limits_ref']) + _require(c['enabled'] is False) + _require(c['platform_id'] == t['platform_id'] and c['strategy_profile'] == t['strategy_profile']) + _require(c['mode'] == t['execution_environment']) + _require(type(c['cash_policy']) is dict and not (set(c['cash_policy']) - { + 'cash_only', 'reserved_cash_ratio', 'reserved_cash_floor_usd'})) + _require(all(type(v) in (bool, int, float) for v in c['cash_policy'].values())) + RuntimeControl.from_mapping(c) + _require(isinstance(value['currency'], str) and re.fullmatch('[A-Z]{3}', value['currency']) is not None) + for key in ('amount', 'cap'): + _require(isinstance(value[key], str) and re.fullmatch(r'(0|[1-9][0-9]*)(\.[0-9]+)?', value[key]) is not None) + _require(Decimal(value['amount']) <= Decimal(value['cap'])) + _require(value['amount_basis'] in ('one_time', 'standing_plan')) + if value['amount_basis'] == 'standing_plan': _ref(value['plan_ref']) + else: _require(value['plan_ref'] is None) + _require(value['fee_treatment'] in ('included', 'outside_amount')) + _require(value['reserve_treatment'] in ('included', 'outside_amount')) + _require(type(value['paper_min_sessions']) is int and 3 <= value['paper_min_sessions'] <= 5) + for key in ('instruments', 'actions'): + items = value[key] + _require(type(items) is list and len(items) > 0 and len(set(items)) == len(items)) + for item in items: _label(item) + _require(set(value['actions']) <= {'rebalance', 'buy', 'sell', 'dca'}) + + +def _decision(value, subject, digest): + _object(value, _DECISION) + for key in ('decision_ref', 'actor_ref', 'epoch'): _ref(value[key]) + _integer(value['revision']); _integer(value['state_version']) + _require(value['subject_sha256'] == digest and value['revision'] == subject['revision']) + _require(value['actor_type'] == 'human' and value['outcome'] in ('confirmed', 'rejected')) + _require(_time(value['reviewed_at']) <= _time(value['effective_at']) < _time(value['expires_at'])) + + +@dataclass(frozen=True, repr=False) +class SetupIntent: + """Canonical JSON storage prevents nested mutation; each read returns a copy. + + ``to_dict`` is a protected transport envelope, not a public list view. + Use ``classify_setup_intent`` for the allowlisted public projection. + """ + _json: str + + def __post_init__(self): + try: + data = json.loads(self._json) + _object(data, _ENVELOPE) + _require(data['schema_version'] == VERSION) + _require(data['grants_live'] is False and data['grants_execution_authority'] is False) + _subject(data['subject']) + _require(data['subject_sha256'] == _digest(data['subject'])) + if data['decision'] is not None: + _decision(data['decision'], data['subject'], data['subject_sha256']) + object.__setattr__(self, '_json', _canonical(data)) + except (ValueError, TypeError, KeyError, OverflowError, AttributeError): + raise ValueError(redact_sensitive_text('invalid setup intent')) from None + + @classmethod + def create(cls, subject: dict, decision: dict | None = None) -> SetupIntent: + try: + return cls(_canonical(dict(schema_version=VERSION, subject=subject, subject_sha256=_digest(subject), + decision=decision, grants_live=False, grants_execution_authority=False))) + except (ValueError, TypeError, OverflowError): + raise ValueError(redact_sensitive_text('invalid setup intent')) from None + + @classmethod + def from_dict(cls, data: dict) -> SetupIntent: + try: + return cls(_canonical(data)) + except (ValueError, TypeError, OverflowError): + raise ValueError(redact_sensitive_text('invalid setup intent')) from None + + @property + def subject_sha256(self) -> str: + return self.to_dict()['subject_sha256'] + + def to_dict(self) -> dict: + return json.loads(self._json) + + +def _changed_fields(before, after): + result = [] + for key in sorted(before): + if before[key] == after.get(key): + continue + if type(before[key]) is dict and type(after.get(key)) is dict: + result.extend(key + '.' + path for path in _changed_fields(before[key], after[key])) + else: + result.append(key) + return result + + +def classify_setup_intent(intent: SetupIntent, *, now: datetime, current, proposed_subject=None): + """Describe intent only; current is a fresh read, never cached by this module. + + Consumer must authenticate the source and atomically compare persisted + epoch/version on writes. No value here attests Paper/risk/runtime readiness. + """ + _require(isinstance(now, datetime) and now.tzinfo is not None and now.utcoffset() is not None) + data = intent.to_dict(); s = data['subject']; d = data['decision'] + blockers = []; changed = []; status = 'pending_owner'; needs_owner = d is None + if proposed_subject is not None: + try: + if type(proposed_subject) is dict: + changed = _changed_fields(s, proposed_subject) + _subject(proposed_subject) + except (ValueError, TypeError, KeyError, OverflowError): + blockers.append('PROPOSED_SUBJECT_INVALID') + needs_owner = True + if changed: + blockers.append('SUCCESSOR_CONFIRMATION_REQUIRED'); needs_owner = True + if d is None: + blockers.append('OWNER_SETUP_REQUIRED') + elif d['outcome'] == 'rejected': + status = 'rejected'; blockers.append('REJECTED') + elif now < _time(d['effective_at']): + status = 'blocked'; blockers.append('NOT_EFFECTIVE') + elif now >= _time(d['expires_at']): + status = 'expired'; blockers.append('EXPIRED') + else: + status = 'confirmed_intent' + source_ok = False + try: + _object(current, _CURRENT) + for key in ('setup_id', 'epoch', 'decision_ref', 'source_ref'): _ref(current[key]) + for key in ('revision', 'state_version'): _integer(current[key]) + _sha(current['subject_sha256']) + _require(_time(current['checked_at']) <= now < _time(current['valid_until'])) + _require(current['status'] in ('current', 'revoked', 'superseded')) + _object(current['prerequisites'], {'paper', 'risk_release', 'reconciliation', 'alerts'}) + _require(all(v in ('unknown', 'unqualified', 'pass', 'failed') for v in current['prerequisites'].values())) + _require(current['activation_readback'] in ('unknown', 'pending', 'observed', 'failed')) + source_ok = True + except (ValueError, TypeError, KeyError, OverflowError): + blockers.append('CURRENT_SOURCE_UNAVAILABLE'); status = 'blocked' + if source_ok: + expected = dict(setup_id=s['setup_id'], revision=s['revision'], subject_sha256=data['subject_sha256']) + if d is not None: expected.update(state_version=d['state_version'], epoch=d['epoch'], decision_ref=d['decision_ref']) + if any(current[k] != v for k, v in expected.items()): + blockers.append('CURRENT_BINDING_MISMATCH'); status = 'blocked' + if current['status'] != 'current': + status = current['status']; blockers.append(status.upper()) + if changed or 'PROPOSED_SUBJECT_INVALID' in blockers: status = 'blocked' + # These prerequisites require their owning authenticated evidence consumer. + # Caller-supplied counts/pass strings are deliberately never qualification. + blockers.extend(['PAPER_EVIDENCE_UNQUALIFIED', 'RISK_RELEASE_NOT_VERIFIED', + 'RECONCILIATION_NOT_VERIFIED', 'ALERTS_NOT_VERIFIED', 'ACTIVATION_NOT_VERIFIED']) + if s['target']['execution_environment'] == 'live': blockers.append('LIVE_ACTIVATION_OWNER_NEEDED') + return redact_sensitive_data(dict(intent_status=status, requires_owner_setup=needs_owner, + blocker_codes=blockers, changed_fields=changed, + resume_condition=' '.join(code + ': ' + _RESUME[code] for code in blockers), + activation_readback='unknown', grants_execution_authority=False, grants_live=False)) diff --git a/src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json b/src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json new file mode 100644 index 00000000..8d8cd7e1 --- /dev/null +++ b/src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json @@ -0,0 +1,420 @@ +{ + "type": "object", + "properties": { + "schema_version": { + "const": "automation_setup_intent.v1" + }, + "subject": { + "type": "object", + "properties": { + "setup_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "predecessor_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "account_refs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "minItems": 1, + "uniqueItems": true + }, + "target": { + "type": "object", + "properties": { + "platform_id": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "strategy_profile": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "dry_run_only": { + "type": "boolean" + }, + "execution_environment": { + "enum": [ + "dry_run", + "paper", + "live" + ] + }, + "strategy_release": { + "type": "object", + "properties": { + "release_id": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "strategy_revision": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "effective_session": { + "type": "string", + "format": "date" + }, + "manifest_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "config_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "risk_policy_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "evidence_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "plugin_bundle_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "release_id", + "strategy_revision", + "effective_session", + "manifest_sha256", + "config_sha256", + "risk_policy_sha256", + "evidence_sha256", + "plugin_bundle_sha256" + ], + "additionalProperties": false + } + }, + "required": [ + "platform_id", + "strategy_profile", + "dry_run_only", + "execution_environment", + "strategy_release" + ], + "additionalProperties": false + }, + "control": { + "type": "object", + "properties": { + "target_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "platform_id": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "strategy_profile": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "enabled": { + "const": false + }, + "mode": { + "enum": [ + "dry_run", + "paper", + "live" + ] + }, + "risk_limits_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "cash_policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "cash_only": { + "type": "boolean" + }, + "reserved_cash_ratio": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reserved_cash_floor_usd": { + "type": "number", + "minimum": 0 + } + } + } + }, + "required": [ + "target_id", + "platform_id", + "strategy_profile", + "enabled", + "mode", + "risk_limits_ref", + "cash_policy" + ], + "additionalProperties": false + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "amount": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)(\\.[0-9]+)?$" + }, + "cap": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)(\\.[0-9]+)?$" + }, + "amount_basis": { + "enum": [ + "one_time", + "standing_plan" + ] + }, + "plan_ref": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + { + "type": "null" + } + ] + }, + "fee_treatment": { + "enum": [ + "included", + "outside_amount" + ] + }, + "reserve_treatment": { + "enum": [ + "included", + "outside_amount" + ] + }, + "instruments": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" + }, + "minItems": 1, + "uniqueItems": true + }, + "actions": { + "type": "array", + "items": { + "enum": [ + "rebalance", + "buy", + "sell", + "dca" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "policy_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "paper_min_sessions": { + "type": "integer", + "enum": [ + 3, + 4, + 5 + ] + } + }, + "required": [ + "setup_id", + "revision", + "predecessor_sha256", + "account_refs", + "target", + "control", + "currency", + "amount", + "cap", + "amount_basis", + "plan_ref", + "fee_treatment", + "reserve_treatment", + "instruments", + "actions", + "policy_ref", + "paper_min_sessions" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "revision": { + "const": 1 + } + } + }, + "then": { + "properties": { + "predecessor_sha256": { + "type": "null" + } + } + }, + "else": { + "properties": { + "predecessor_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + { + "if": { + "properties": { + "amount_basis": { + "const": "one_time" + } + } + }, + "then": { + "properties": { + "plan_ref": { + "type": "null" + } + } + }, + "else": { + "properties": { + "plan_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + } + } + } + } + ] + }, + "subject_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "decision": { + "anyOf": [ + { + "type": "object", + "properties": { + "decision_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "subject_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "actor_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "actor_type": { + "const": "human" + }, + "outcome": { + "enum": [ + "confirmed", + "rejected" + ] + }, + "reviewed_at": { + "type": "string", + "format": "date-time", + "pattern": "(Z|[+-][0-9]{2}:[0-9]{2})$" + }, + "effective_at": { + "type": "string", + "format": "date-time", + "pattern": "(Z|[+-][0-9]{2}:[0-9]{2})$" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "(Z|[+-][0-9]{2}:[0-9]{2})$" + }, + "state_version": { + "type": "integer", + "minimum": 1 + }, + "epoch": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + } + }, + "required": [ + "decision_ref", + "subject_sha256", + "revision", + "actor_ref", + "actor_type", + "outcome", + "reviewed_at", + "effective_at", + "expires_at", + "state_version", + "epoch" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "grants_live": { + "const": false + }, + "grants_execution_authority": { + "const": false + } + }, + "required": [ + "schema_version", + "subject", + "subject_sha256", + "decision", + "grants_live", + "grants_execution_authority" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Non-executing automation setup intent v1", + "description": "Structure only. Use SetupIntent for digest, amount, target/control and temporal consistency. A decision or caller evidence is never execution authority." +} diff --git a/tests/test_automation_setup_contract.py b/tests/test_automation_setup_contract.py new file mode 100644 index 00000000..8d16ed92 --- /dev/null +++ b/tests/test_automation_setup_contract.py @@ -0,0 +1,196 @@ +"""Synthetic, offline contract checks; no authentic authority is simulated.""" +import copy +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator + +from quant_platform_kit.common.automation_setup import SetupIntent, classify_setup_intent + +NOW = datetime(2026, 11, 3, tzinfo=timezone.utc) +REF = '00000000-0000-4000-8000-000000000001' + + +def subject(): + release = dict(release_id='fake-release', strategy_revision='fake-revision', effective_session='2026-11-01', + **{k: 'a' * 64 for k in ('manifest_sha256', 'config_sha256', 'risk_policy_sha256', 'evidence_sha256', 'plugin_bundle_sha256')}) + return dict(setup_id=REF, revision=1, predecessor_sha256=None, account_refs=[REF], + target=dict(platform_id='fake-platform', strategy_profile='fake-profile', dry_run_only=True, + execution_environment='dry_run', strategy_release=release), + control=dict(target_id=REF, platform_id='fake-platform', strategy_profile='fake-profile', + enabled=False, mode='dry_run', risk_limits_ref=REF, cash_policy={}), + currency='USD', amount='100.00', cap='100.00', amount_basis='one_time', plan_ref=None, + fee_treatment='included', reserve_treatment='outside_amount', instruments=['FAKE'], + actions=['rebalance'], policy_ref=REF, paper_min_sessions=5) + + +def intent(decision=True, **changes): + s = subject(); s.update(changes) + obj = SetupIntent.create(s) + if not decision: + return obj + d = dict(decision_ref=REF, subject_sha256=obj.subject_sha256, revision=s['revision'], + actor_ref=REF, actor_type='human', outcome='confirmed', reviewed_at='2026-11-01T00:00:00Z', + effective_at='2026-11-02T00:00:00Z', expires_at='2026-12-01T00:00:00Z', state_version=1, epoch=REF) + return SetupIntent.create(s, d) + + +def snapshot(obj): + return dict(setup_id=REF, revision=obj.to_dict()['subject']['revision'], subject_sha256=obj.subject_sha256, + state_version=1, epoch=REF, decision_ref=REF, status='current', source_ref=REF, + checked_at='2026-11-03T00:00:00Z', valid_until='2026-11-04T00:00:00Z', + prerequisites={k: 'unknown' for k in ('paper', 'risk_release', 'reconciliation', 'alerts')}, + activation_readback='unknown') + + +def classify(obj, current=None): + return classify_setup_intent(obj, now=NOW, current=snapshot(obj) if current is None else current) + + +def test_roundtrip_and_routine_no_reapproval(): + obj = intent(); encoded = obj.to_dict() + assert SetupIntent.from_dict(encoded).to_dict() == encoded + for _ in range(3): + p = classify(obj) + assert p['intent_status'] == 'confirmed_intent' + assert p['requires_owner_setup'] is False + assert p['grants_live'] is p['grants_execution_authority'] is False + assert 'PAPER_EVIDENCE_UNQUALIFIED' in p['blocker_codes'] + encoded['subject']['account_refs'].append(REF) + assert len(obj.to_dict()['subject']['account_refs']) == 1 + + +@pytest.mark.parametrize('value', [None, True, '3', 3.0, 2, 6]) +def test_invalid_paper_requirement(value): + with pytest.raises(ValueError): intent(paper_min_sessions=value) + + +@pytest.mark.parametrize('value', [3, 4, 5]) +def test_requirement_is_not_qualification(value): + assert 'PAPER_EVIDENCE_UNQUALIFIED' in classify(intent(paper_min_sessions=value))['blocker_codes'] + + +@pytest.mark.parametrize('value', [True, 100, 1.5, '-1', 'NaN', 'Infinity', '1e2', ' 1', '01']) +def test_exact_decimal_only(value): + with pytest.raises(ValueError): intent(amount=value) + + +@pytest.mark.parametrize('field,value', [('amount', '99'), ('account_refs', ['00000000-0000-4000-8000-000000000002']), + ('instruments', ['OTHER']), ('policy_ref', '00000000-0000-4000-8000-000000000002'), + ('plan_ref', REF)]) +def test_scope_changes_invalidate_decision(field, value): + old = intent(); new = old.to_dict(); new['subject'][field] = value + with pytest.raises(ValueError): SetupIntent.from_dict(new) + p = classify_setup_intent(old, now=NOW, current=snapshot(old), proposed_subject=new['subject']) + assert field in p['changed_fields'] + assert p['requires_owner_setup'] + + +@pytest.mark.parametrize('key,value,code', [('state_version', 2, 'CURRENT_BINDING_MISMATCH'), + ('epoch', '00000000-0000-4000-8000-000000000002', 'CURRENT_BINDING_MISMATCH'), + ('subject_sha256', 'b'*64, 'CURRENT_BINDING_MISMATCH'), + ('checked_at', '2026-11-04T00:00:00Z', 'CURRENT_SOURCE_UNAVAILABLE'), + ('valid_until', '2026-11-02T00:00:00Z', 'CURRENT_SOURCE_UNAVAILABLE'), + ('status', 'revoked', 'REVOKED'), ('status', 'superseded', 'SUPERSEDED')]) +def test_current_snapshot_dominates(key, value, code): + obj = intent(); s = snapshot(obj); s[key] = value + assert code in classify(obj, s)['blocker_codes'] + + +@pytest.mark.parametrize('field,value', [('effective_at', '2026-11-04T00:00:00Z'), + ('expires_at', '2026-11-02T12:00:00Z'), ('outcome', 'rejected')]) +def test_decision_time_and_rejection(field, value): + data = intent().to_dict(); data['decision'][field] = value + assert classify(SetupIntent.from_dict(data))['intent_status'] != 'confirmed_intent' + + +def test_missing_source_and_self_reported_pass_do_not_qualify(): + obj = intent() + assert classify_setup_intent(obj, now=NOW, current=None)['intent_status'] == 'blocked' + s = snapshot(obj); s['prerequisites'] = {k: 'pass' for k in s['prerequisites']} + assert 'PAPER_EVIDENCE_UNQUALIFIED' in classify(obj, s)['blocker_codes'] + + +def test_live_is_owner_needed_only(): + s = subject(); s['target'].update(execution_environment='live', dry_run_only=False) + s['control']['mode'] = 'live' + obj = intent(target=s['target'], control=s['control']) + assert 'LIVE_ACTIVATION_OWNER_NEEDED' in classify(obj)['blocker_codes'] + assert classify(obj)['activation_readback'] == 'unknown' + + +@pytest.mark.parametrize('field', ['account_selector', 'account_identity', 'host', 'token']) +def test_sensitive_unknown_fields_never_echo(field): + fake = 'fake-account.example.invalid?token=fake-secret-value' + s = subject(); s['target'][field] = fake + with pytest.raises(ValueError) as err: SetupIntent.create(s) + assert fake not in str(err.value) + assert err.value.__cause__ is None + assert fake not in json.dumps(classify(intent())) + + +def test_reject_unknown_authority_and_tampered_digest(): + for key, value in [('may_submit', True), ('grants_live', True), ('subject_sha256', 'b'*64)]: + data = intent().to_dict(); data[key] = value + with pytest.raises(ValueError): SetupIntent.from_dict(data) + + +def test_disabled_control_explicit_environment_and_bindings(): + for mutate in [lambda s: s['target'].pop('execution_environment'), + lambda s: s['control'].update(enabled=True), + lambda s: s['control'].update(platform_id='other'), + lambda s: s.update(cap='99.00'), + lambda s: s.update(amount_basis='standing_plan', plan_ref=None)]: + s = subject(); mutate(s) + with pytest.raises(ValueError): SetupIntent.create(s) + + +def test_schema_matches_serialized_contract(): + schema = json.loads((Path(__file__).parents[1] / 'src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json').read_text()) + validator = Draft202012Validator(schema) + assert not list(validator.iter_errors(intent().to_dict())) + for mutate in [lambda x: x.update(grants_live=True), lambda x: x['subject'].update(may_submit=True), + lambda x: x['subject']['target'].update(account_selector=['fake']), + lambda x: x['decision'].update(expires_at=None)]: + data = copy.deepcopy(intent().to_dict()); mutate(data) + assert list(validator.iter_errors(data)) + + +def test_successor_reconfirmation_and_scope_details(): + old = intent(); s = subject() + s.update(revision=2, predecessor_sha256=old.subject_sha256, amount_basis='standing_plan', plan_ref=REF) + s['target'].update(execution_environment='paper', dry_run_only=False) + s['control']['mode'] = 'paper' + s['target']['strategy_release']['config_sha256'] = 'b' * 64 + changed = classify_setup_intent(old, now=NOW, current=snapshot(old), proposed_subject=s) + assert set(changed['changed_fields']) == {'revision', 'predecessor_sha256', 'amount_basis', 'plan_ref', 'target.execution_environment', + 'target.dry_run_only', 'target.strategy_release.config_sha256', 'control.mode'} + new = SetupIntent.create(s) + d = old.to_dict()['decision'] + d.update(subject_sha256=new.subject_sha256, revision=2, state_version=2) + confirmed = SetupIntent.create(s, d) + current = snapshot(confirmed); current['state_version'] = 2 + assert not classify(confirmed, current)['requires_owner_setup'] + assert classify(confirmed, snapshot(old))['intent_status'] == 'blocked' + + +@pytest.mark.parametrize('field,value', [('reviewed_at', '2026-11-01T00:00:00'), ('expires_at', None), + ('state_version', True), ('actor_type', 'bot'), ('actor_ref', 'fake-account.example.invalid')]) +def test_invalid_decision_cannot_replay(field, value): + data = intent().to_dict(); data['decision'][field] = value + with pytest.raises(ValueError): SetupIntent.from_dict(data) + + +def test_serialized_and_summary_views_exclude_sensitive_input(): + obj = intent(); fake = 'fake-host.example.invalid?token=fake-secret-value' + for section, field in [('subject', 'account_refs'), ('decision', 'actor_ref')]: + data = obj.to_dict() + data[section][field] = [fake] if field == 'account_refs' else fake + with pytest.raises(ValueError) as error: SetupIntent.from_dict(data) + assert fake not in str(error.value) + assert fake not in json.dumps(obj.to_dict()) + assert 'account_refs' not in classify(obj) + assert 'actor_ref' not in classify(obj) + assert fake not in repr(obj) From 1823227812bd41d6f36ce7271d83218e52f32854 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 04:48:15 +0800 Subject: [PATCH 2/4] fix: Reject invalid setup intent scalars before hashing --- .../common/automation_setup.py | 10 ++- tests/test_automation_setup_contract.py | 65 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/quant_platform_kit/common/automation_setup.py b/src/quant_platform_kit/common/automation_setup.py index fc098984..edd6e0eb 100644 --- a/src/quant_platform_kit/common/automation_setup.py +++ b/src/quant_platform_kit/common/automation_setup.py @@ -9,7 +9,7 @@ import json import re from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import date, datetime, timezone from decimal import Decimal from uuid import UUID @@ -110,6 +110,9 @@ def _subject(value): release = t['strategy_release']; _object(release, _RELEASE) for key in ('release_id', 'strategy_revision'): _label(release[key]) for key in _RELEASE - {'release_id', 'strategy_revision', 'effective_session'}: _sha(release[key]) + session = release['effective_session'] + _require(isinstance(session, str) and re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}', session) is not None) + _require(date.fromisoformat(session).isoformat() == session) identity = build_strategy_release_identity(release) build_runtime_target(**{**t, 'strategy_release': identity}) c = value['control']; _object(c, _CONTROL) @@ -120,6 +123,8 @@ def _subject(value): _require(type(c['cash_policy']) is dict and not (set(c['cash_policy']) - { 'cash_only', 'reserved_cash_ratio', 'reserved_cash_floor_usd'})) _require(all(type(v) in (bool, int, float) for v in c['cash_policy'].values())) + if 'cash_only' in c['cash_policy']: + _require(type(c['cash_policy']['cash_only']) is bool) RuntimeControl.from_mapping(c) _require(isinstance(value['currency'], str) and re.fullmatch('[A-Z]{3}', value['currency']) is not None) for key in ('amount', 'cap'): @@ -173,9 +178,10 @@ def __post_init__(self): @classmethod def create(cls, subject: dict, decision: dict | None = None) -> SetupIntent: try: + _subject(subject) return cls(_canonical(dict(schema_version=VERSION, subject=subject, subject_sha256=_digest(subject), decision=decision, grants_live=False, grants_execution_authority=False))) - except (ValueError, TypeError, OverflowError): + except (ValueError, TypeError, KeyError, OverflowError, AttributeError): raise ValueError(redact_sensitive_text('invalid setup intent')) from None @classmethod diff --git a/tests/test_automation_setup_contract.py b/tests/test_automation_setup_contract.py index 8d16ed92..1c060190 100644 --- a/tests/test_automation_setup_contract.py +++ b/tests/test_automation_setup_contract.py @@ -194,3 +194,68 @@ def test_serialized_and_summary_views_exclude_sensitive_input(): assert 'account_refs' not in classify(obj) assert 'actor_ref' not in classify(obj) assert fake not in repr(obj) + + +@pytest.mark.parametrize('field,value', [ + ('cash_only', 0), ('cash_only', 1), ('cash_only', 0.0), + ('cash_only', 1.0), ('cash_only', None), ('cash_only', 'true'), + ('effective_session', 20261101), ('effective_session', None), + ('effective_session', '20261101'), ('effective_session', '2026-W44-7'), + ('effective_session', '2026-11-01T00:00:00'), + ('effective_session', '2026-02-30'), +]) +def test_invalid_scalar_rejected_before_hashing(field, value, monkeypatch): + from quant_platform_kit.common import automation_setup + + s = subject() + if field == 'cash_only': + s['control']['cash_policy'][field] = value + else: + s['target']['strategy_release'][field] = value + hashed = [] + original = automation_setup._digest + + def track_digest(data): + hashed.append(data) + return original(data) + + monkeypatch.setattr(automation_setup, '_digest', track_digest) + with pytest.raises(ValueError, match='^invalid setup intent$') as error: + SetupIntent.create(s) + assert error.value.__cause__ is None + assert hashed == [] + + +@pytest.mark.parametrize('cash_only', [False, True]) +@pytest.mark.parametrize('effective_session', ['2026-11-01', '2024-02-29']) +def test_constructor_scalar_schema_parity(cash_only, effective_session): + schema = json.loads((Path(__file__).parents[1] / 'src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json').read_text()) + s = subject() + s['control']['cash_policy']['cash_only'] = cash_only + s['target']['strategy_release']['effective_session'] = effective_session + obj = SetupIntent.create(s) + encoded = obj.to_dict() + Draft202012Validator(schema).validate(encoded) + assert encoded['subject'] == s + assert SetupIntent.from_dict(encoded).to_dict() == encoded + assert encoded['grants_live'] is encoded['grants_execution_authority'] is False + + +@pytest.mark.parametrize('field,value', [ + ('cash_only', 0), ('cash_only', 1), ('effective_session', 20261101), + ('effective_session', '20261101'), ('effective_session', '2026-W44-7'), +]) +def test_schema_invalid_scalars_rejected_on_read(field, value): + import hashlib + + encoded = intent(decision=False).to_dict() + s = encoded['subject'] + if field == 'cash_only': + s['control']['cash_policy'][field] = value + else: + s['target']['strategy_release'][field] = value + encoded['subject_sha256'] = hashlib.sha256( + json.dumps(s, sort_keys=True, separators=(',', ':'), allow_nan=False).encode() + ).hexdigest() + with pytest.raises(ValueError, match='^invalid setup intent$'): + SetupIntent.from_dict(encoded) From a6d3a0e9c616541a561a92739f86a0fa972375e6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 05:01:06 +0800 Subject: [PATCH 3/4] fix: reject schema-invalid setup decision offsets --- .../common/automation_setup.py | 3 ++ tests/test_automation_setup_contract.py | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/quant_platform_kit/common/automation_setup.py b/src/quant_platform_kit/common/automation_setup.py index edd6e0eb..173e4be5 100644 --- a/src/quant_platform_kit/common/automation_setup.py +++ b/src/quant_platform_kit/common/automation_setup.py @@ -149,6 +149,9 @@ def _decision(value, subject, digest): _integer(value['revision']); _integer(value['state_version']) _require(value['subject_sha256'] == digest and value['revision'] == subject['revision']) _require(value['actor_type'] == 'human' and value['outcome'] in ('confirmed', 'rejected')) + # Match the bundled schema before freezing the original timestamp strings. + for key in ('reviewed_at', 'effective_at', 'expires_at'): + _require(isinstance(value[key], str) and re.search(r'(Z|[+-][0-9]{2}:[0-9]{2})$', value[key]) is not None) _require(_time(value['reviewed_at']) <= _time(value['effective_at']) < _time(value['expires_at'])) diff --git a/tests/test_automation_setup_contract.py b/tests/test_automation_setup_contract.py index 1c060190..7da4bbd9 100644 --- a/tests/test_automation_setup_contract.py +++ b/tests/test_automation_setup_contract.py @@ -259,3 +259,36 @@ def test_schema_invalid_scalars_rejected_on_read(field, value): ).hexdigest() with pytest.raises(ValueError, match='^invalid setup intent$'): SetupIntent.from_dict(encoded) + + +@pytest.mark.parametrize('field', ['reviewed_at', 'effective_at', 'expires_at']) +@pytest.mark.parametrize('path', ['create', 'read']) +@pytest.mark.parametrize('suffix', ['+0000', '', '+00', '+00:00:00']) +def test_schema_invalid_decision_timestamp_rejected(field, path, suffix): + schema = json.loads((Path(__file__).parents[1] / 'src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json').read_text()) + encoded = intent().to_dict() + value = encoded['decision'][field][:-1] + suffix + encoded['decision'][field] = value + assert list(Draft202012Validator(schema).iter_errors(encoded)) + with pytest.raises(ValueError, match='^invalid setup intent$') as error: + if path == 'create': + SetupIntent.create(encoded['subject'], encoded['decision']) + else: + SetupIntent.from_dict(encoded) + assert value not in str(error.value) + assert error.value.__cause__ is None + + +@pytest.mark.parametrize('field', ['reviewed_at', 'effective_at', 'expires_at']) +@pytest.mark.parametrize('suffix', ['Z', '+00:00', '+01:00', '-04:00']) +def test_decision_timestamp_constructor_read_schema_parity(field, suffix): + schema = json.loads((Path(__file__).parents[1] / 'src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json').read_text()) + encoded = intent().to_dict() + encoded['decision'][field] = encoded['decision'][field][:-1] + suffix + validator = Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER) + validator.validate(encoded) + obj = SetupIntent.create(encoded['subject'], encoded['decision']) + assert obj.to_dict() == encoded + validator.validate(obj.to_dict()) + assert SetupIntent.from_dict(encoded).to_dict() == encoded + assert obj.to_dict()['grants_live'] is obj.to_dict()['grants_execution_authority'] is False From 2d49ffc730a3d0c25519de016ef8953fcac83fba Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 05:18:42 +0800 Subject: [PATCH 4/4] fix: Validate complete setup decision date-times --- .github/workflows/ci.yml | 2 +- pyproject.toml | 1 + .../common/automation_setup.py | 4 +- tests/test_automation_setup_contract.py | 85 ++++++++++++++++++- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d3cfbb5..1cfdbe9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: | set -euo pipefail python -m pip install --upgrade pip - python -m pip install -e . numpy pandas pytest pytest-cov requests ruff "jsonschema>=4.18,<5" + python -m pip install -e . numpy pandas pytest pytest-cov requests ruff "jsonschema>=4.18,<5" "rfc3339-validator>=0.1.4,<1" - name: Validate dependency constraint refs run: | diff --git a/pyproject.toml b/pyproject.toml index af61adbd..f724daef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "requests", "ruff", "jsonschema>=4.18,<5", + "rfc3339-validator>=0.1.4,<1", "numpy", "pandas", ] diff --git a/src/quant_platform_kit/common/automation_setup.py b/src/quant_platform_kit/common/automation_setup.py index 173e4be5..5c1644c2 100644 --- a/src/quant_platform_kit/common/automation_setup.py +++ b/src/quant_platform_kit/common/automation_setup.py @@ -151,7 +151,9 @@ def _decision(value, subject, digest): _require(value['actor_type'] == 'human' and value['outcome'] in ('confirmed', 'rejected')) # Match the bundled schema before freezing the original timestamp strings. for key in ('reviewed_at', 'effective_at', 'expires_at'): - _require(isinstance(value[key], str) and re.search(r'(Z|[+-][0-9]{2}:[0-9]{2})$', value[key]) is not None) + _require(isinstance(value[key], str) and re.fullmatch( + r'[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt](?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]' + r'(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])', value[key]) is not None) _require(_time(value['reviewed_at']) <= _time(value['effective_at']) < _time(value['expires_at'])) diff --git a/tests/test_automation_setup_contract.py b/tests/test_automation_setup_contract.py index 7da4bbd9..e056f8c8 100644 --- a/tests/test_automation_setup_contract.py +++ b/tests/test_automation_setup_contract.py @@ -5,10 +5,19 @@ from pathlib import Path import pytest -from jsonschema import Draft202012Validator +from jsonschema import Draft202012Validator, FormatChecker from quant_platform_kit.common.automation_setup import SetupIntent, classify_setup_intent +FORMAT_CHECKER = FormatChecker() +assert 'date-time' in FORMAT_CHECKER.checkers + + +def decision_schema_validator(): + schema = json.loads((Path(__file__).parents[1] / 'src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json').read_text()) + return Draft202012Validator(schema, format_checker=FORMAT_CHECKER) + + NOW = datetime(2026, 11, 3, tzinfo=timezone.utc) REF = '00000000-0000-4000-8000-000000000001' @@ -282,10 +291,80 @@ def test_schema_invalid_decision_timestamp_rejected(field, path, suffix): @pytest.mark.parametrize('field', ['reviewed_at', 'effective_at', 'expires_at']) @pytest.mark.parametrize('suffix', ['Z', '+00:00', '+01:00', '-04:00']) def test_decision_timestamp_constructor_read_schema_parity(field, suffix): - schema = json.loads((Path(__file__).parents[1] / 'src/quant_platform_kit/schemas/automation-setup-intent.v1.schema.json').read_text()) encoded = intent().to_dict() encoded['decision'][field] = encoded['decision'][field][:-1] + suffix - validator = Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER) + validator = decision_schema_validator() + validator.validate(encoded) + obj = SetupIntent.create(encoded['subject'], encoded['decision']) + assert obj.to_dict() == encoded + validator.validate(obj.to_dict()) + assert SetupIntent.from_dict(encoded).to_dict() == encoded + assert obj.to_dict()['grants_live'] is obj.to_dict()['grants_execution_authority'] is False + + +@pytest.mark.parametrize('field', ['reviewed_at', 'effective_at', 'expires_at']) +@pytest.mark.parametrize('path', ['create', 'read']) +@pytest.mark.parametrize('form', [ + 'basic_date', 'space_separator', 'missing_seconds', 'week_date', + 'unicode_separator', 'comma_fraction', 'offset_minutes', 'invalid_date', + 'hour_24', 'minute_60', 'second_60', 'offset_hour_24', 'trailing_newline', +]) +def test_complete_decision_datetime_rejected(field, path, form): + encoded = intent().to_dict() + original = encoded['decision'][field] + forms = { + 'basic_date': original[:10].replace('-', '') + original[10:], + 'space_separator': original.replace('T', ' '), + 'missing_seconds': original.replace('T00:00:00', 'T00:00'), + 'week_date': '2026-W44-7' + original[10:], + 'unicode_separator': original.replace('T', '\u2603'), + 'comma_fraction': original.replace('00:00:00', '00:00:00,5'), + 'offset_minutes': original[:-1] + '+00:60', + 'invalid_date': '2026-02-30' + original[10:], + 'hour_24': original.replace('T00', 'T24'), + 'minute_60': original.replace('00:00:00', '00:60:00'), + 'second_60': original.replace('00:00:00', '00:00:60'), + 'offset_hour_24': original[:-1] + '+24:00', + 'trailing_newline': original + '\n', + } + value = forms[form] + encoded['decision'][field] = value + validator = decision_schema_validator() + errors = list(validator.iter_errors(encoded)) + # rfc3339-validator's end anchor permits a final newline; the boundary must not. + if form != 'trailing_newline': + assert errors + assert not validator.format_checker.conforms(value, 'date-time') + with pytest.raises(ValueError, match='^invalid setup intent$') as error: + if path == 'create': + SetupIntent.create(encoded['subject'], encoded['decision']) + else: + SetupIntent.from_dict(encoded) + assert value not in str(error.value) + assert error.value.__cause__ is None + + +def test_datetime_oracle_is_active(): + validator = decision_schema_validator() + assert 'date-time' in validator.format_checker.checkers + assert validator.format_checker.conforms('2026-11-01T00:00:00Z', 'date-time') + assert not validator.format_checker.conforms('20261101T00:00:00Z', 'date-time') + assert not validator.format_checker.conforms('2026-11-01 00:00:00Z', 'date-time') + assert not validator.format_checker.conforms('2026-11-01T00:00Z', 'date-time') + + +@pytest.mark.parametrize('field', ['reviewed_at', 'effective_at', 'expires_at']) +@pytest.mark.parametrize('form', ['fraction', 'lower_t', 'long_fraction', 'fraction_offset']) +def test_complete_decision_datetime_preserves_valid_strings(field, form): + encoded = intent().to_dict() + original = encoded['decision'][field] + encoded['decision'][field] = { + 'fraction': original[:-1] + '.123Z', + 'lower_t': original.replace('T', 't'), + 'long_fraction': original[:-1] + '.123456789Z', + 'fraction_offset': original[:-1] + '.5+01:30', + }[form] + validator = decision_schema_validator() validator.validate(encoded) obj = SetupIntent.create(encoded['subject'], encoded['decision']) assert obj.to_dict() == encoded