diff --git a/adapters/go/approvalfact/README.md b/adapters/go/approvalfact/README.md index fdffd1f..1b67b64 100644 --- a/adapters/go/approvalfact/README.md +++ b/adapters/go/approvalfact/README.md @@ -36,7 +36,8 @@ Canonical float/string formatting follows Python source, including negative floating zero, exponent switching/padding, HTML and U+2028/U+2029. Duplicate object keys, nonfinite/overflow numbers, invalid UTF-8 and lone surrogates refuse. Inputs are bounded to 1 MiB and 128 container levels. Timestamp precision is at -most nanoseconds with strict zoned RFC3339; current producer emits whole seconds. +most nanoseconds with strict zoned RFC3339; the precision-repaired Python +producer preserves microseconds, while historical whole-second strings remain valid. These explicit parser/resource limits fail closed, never normalize authority. `go test -race ./...` checks the portable producer vectors, typed fact pairing, diff --git a/approval_engine/store.py b/approval_engine/store.py index eb02a24..e9444d9 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -103,11 +103,11 @@ def utc_now(clock: Callable[[], datetime] | None = None) -> datetime: now = datetime.now(timezone.utc) if now.tzinfo is None: now = now.replace(tzinfo=timezone.utc) - return now.astimezone(timezone.utc).replace(microsecond=0) + return now.astimezone(timezone.utc) def iso(ts: datetime) -> str: - return ts.astimezone(timezone.utc).replace(microsecond=0).isoformat() + return ts.astimezone(timezone.utc).isoformat() def parse_iso(value: str) -> datetime: @@ -768,7 +768,6 @@ class Engine: ): raise Unprocessable("decision_id must be a non-empty string") conn = self._conn() - now = iso(self.now()) idempotent = False try: conn.execute("BEGIN IMMEDIATE") @@ -794,7 +793,11 @@ class Engine: if row["status"] != "approved": conn.rollback() raise Conflict(f"cannot consume from status {row['status']}") - if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]): + # Lock acquisition can wait past a validity boundary. One post-lock + # instant is authoritative for both the check and its durable proof. + consumed_at = self.now() + now = iso(consumed_at) + if consumed_at < parse_iso(row["not_before"]) or consumed_at >= parse_iso(row["expires_at"]): conn.rollback() raise Conflict("cannot consume outside validity window") if row["human_control"]: @@ -819,6 +822,7 @@ class Engine: "use", approval_id, actor=row["actor"], + observed_at=consumed_at, extra={ "binding_digest": row["binding_digest"], "binding": json.loads(row["binding_json"]), @@ -911,13 +915,14 @@ class Engine: *, actor: str | None, extra: dict[str, Any] | None = None, + observed_at: datetime | None = None, ) -> str: if self.fail_outbox: raise StoreUnavailable("outbox insert failed") if event_class not in EVENT_CLASSES: raise Unprocessable(f"unknown event class {event_class}") event_id = str(uuid4()) - created = iso(self.now()) + created = iso(observed_at if observed_at is not None else self.now()) resource = f"approval:{approval_id}" if approval_id else "approval-engine:heartbeat" details = {"class": event_class, **(extra or {})} if approval_id: diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index f85ab28..45cbb96 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -42,6 +42,24 @@ after consumption, the approval remains spent and a retry needs a new approval. The response is mutation evidence, not a permission decision. It contains no `effect`, `allow`, `deny`, or decision result. +## Authoritative time and fractional precision + +New source preserves the actual aware UTC clock's microseconds rather than +rounding down to whole seconds. The first consume samples one instant after +`BEGIN IMMEDIATE` acquires its write lock, then uses that instant for the +not-before/inclusive and expiry/exclusive check, persisted `consumed_at` and +`updated_at`, and use-event occurrence/creation time. Lock wait cannot borrow +an earlier validity instant or stamp evidence before actual admission. + +Existing whole-second ISO strings remain valid and are never backfilled with +invented fractions. Idempotent responses and object readback retain the original +recorded timestamp/decision exactly, even after expiry; they do not renew +execution authority. The store schema, API keys, CAS/digest correspondence and +no-unconsume rule are unchanged. Claim observation/freshness and other new +source timestamps also retain actual clock precision. Consumers must parse +aware ISO/RFC3339 timestamps including fractional seconds instead of assuming +a fixed-width whole-second string. No clock skew or early-use tolerance is added. + ## The claim response is not an `ActionAuthorization` `GET /v1/approvals/{id}/claim` serves the **approval-claim** envelope defined in diff --git a/docs/reviews/2026-10-16-fractional-consumption-temporal-approval-owner-review.json b/docs/reviews/2026-10-16-fractional-consumption-temporal-approval-owner-review.json new file mode 100644 index 0000000..4a0b3e3 --- /dev/null +++ b/docs/reviews/2026-10-16-fractional-consumption-temporal-approval-owner-review.json @@ -0,0 +1,46 @@ +{ + "reviewer": "approval-engine-owner (agent approval-engine, codex)", + "decision": "approved", + "comment": "INTENT Purpose (valid right now), The Claim Shape and Consumption Ordering: approved for bounded Approval source time precision repair and Approval-owned fact/temporal correspondence in exact Access R3 source. Work APPROVAL-WP-0006-T01/T02. Repair patch SHA256 sha256:74eb0ff67d14508c2a2cee14d055790321ee171a96d5290ad6d037ce8f61dbcd against 3485415b823faf4f3b682079195e186666b0ed2b; store.py SHA256 sha256:866b275ed2d844d0aa9090ce9899ce955c6bfe32a493c2645c86e72efa343e86. Access R3 full patch SHA256 sha256:46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6, delta sha256:17ecf399c50c07845c545b31cad4022200eb20dfb760595aa76a271e9cee7521; all 31 frozen source hashes matched. This review does not assent to Access policy/other-owner behavior, native credentials/trust, deployment or effects.", + "reviewed_repair_patch_sha256": "sha256:74eb0ff67d14508c2a2cee14d055790321ee171a96d5290ad6d037ce8f61dbcd", + "reviewed_access_full_r3_sha256": "sha256:46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6", + "reviewed_access_r2_to_r3_sha256": "sha256:17ecf399c50c07845c545b31cad4022200eb20dfb760595aa76a271e9cee7521", + "approval_time_findings": [ + "utc_now and iso preserve actual aware microseconds; no schema or binding algorithm change", + "First consume samples one authoritative time only after BEGINIMMEDIATE obtains the lock; identical instant checks validity, persists receipt/update and creates use outbox timestamp", + "Expiry remains exclusive; actual clock .750 refuses expiry .500; post-lock expiry cannot borrow pre-lock admission", + "Whole-second old rows, readback and idempotent original decision/timestamp are preserved without fabricated precision; no unconsume, retry authority or tolerance", + "No Access NotBefore backdating, caller wait/sleep acceptance or consumer timing waiver is needed" + ], + "access_fact_temporal_findings": [ + "Signed claim-free DENY normalization receipt supplies existing producer-evaluated native act and approval-free digest before issue, never permission; requester consumer independently authenticates it", + "Actual retained registry normalization is source-owned and number-preserving; final join compares the approved act/PDP digest and refuses drift instead of refreshing old context", + "Original and newly fetched facts are validated separately; no mutation of original claim/context or either request digest", + "Exact immutable operation deadline must fit caller, original/fetched freshness and approval validity; decision is precise post-evaluation NotBefore to exact operation deadline", + "Entire remaining interval must cover declared backendTTL plus 5-second cleanup reserve and source maximum 120; this review does not approve that policy or native budget", + "Default Approval freshness 30 cannot satisfy minimum 30+5; fixture freshness 120 is explicit synthetic configuration, not a producer default flip or native grant" + ], + "validation": { + "repair_focused_passed": 89, + "repair_full_passed": 298, + "repair_full_failed_preexisting": 1, + "baseline_counterexamples_failed": 5, + "access_independent_command": "go test -race -count=1 ./internal/decision ./cmd/access-engine -run TestTrusted", + "access_independent_result": "passed" + }, + "retained_failures": [ + "coordinator-counterexample.json documents canonical expired fractional approval consumed", + "Initial repair expiry test attempted already-expired issuance; repaired engine correctly refused; fixture corrected to issue before expiry then advance", + "Existing deployment pin/historical release evidence mismatch remains unchanged" + ], + "prior_evidence": "WP0005 closure and6079edR1review remain exact historical evidence; this is a new precision task and new exactR3 review", + "remaining_gates": [ + "Coordinator independent source integration/publication/synchronization at exact repair and Access/Secrets successor source pins", + "Actual immediate strict CAS/whole-path remeasurement must use unchanged precise signed NotBefore and no sleep/waiver", + "Native issuer/profile, CA/source/read credential/store tenant admission and schema/image/config rollout remain existing WP0002T01", + "Actual use event/audit receiver reconciliation remains WP0002T04", + "Served PEP original decision/request CAS before effect, live one-shot journal, deadlines and confirmed cleanup remain WP0002T05 and existing Secrets/Platform work" + ], + "native_accepted": false, + "authority_promotion": false +} diff --git a/tests/test_timestamp_precision.py b/tests/test_timestamp_precision.py new file mode 100644 index 0000000..42fb5bc --- /dev/null +++ b/tests/test_timestamp_precision.py @@ -0,0 +1,130 @@ +"""Actual source clock, locking and original immutable receipt precision.""" +from datetime import timedelta, timezone +import sqlite3 +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from approval_engine.errors import Conflict, StoreUnavailable +from approval_engine.store import Engine, iso, utc_now +from tests.conftest import FROZEN, binding + +DIGEST = "sha256:" + "ab" * 32 + + +def issue(engine, start, end): + obj = engine.create(binding(), {"not_before": start.isoformat(), "expires_at": end.isoformat()}) + return engine.add_entry(obj.id, "human", principal_type="human") + + +def uses(engine): + return [row for row in engine.undrained() if row["class"] == "use"] + + +def test_clock_and_iso_preserve_aware_precision_and_legacy_seconds(): + now = FROZEN.replace(microsecond=750123) + offset = now.astimezone(timezone(timedelta(hours=2))) + assert utc_now(lambda: offset) == now + assert iso(offset) == "2026-08-29T12:00:00.750123+00:00" + assert iso(FROZEN) == "2026-08-29T12:00:00+00:00" + + +def test_fractional_expiry_refuses_actual_canonical_counterexample(): + engine = Engine(clock=lambda: FROZEN) + obj = issue(engine, FROZEN-timedelta(seconds=1), FROZEN.replace(microsecond=500000)) + engine.clock = lambda: FROZEN.replace(microsecond=750000) + assert engine.claim(obj.id)["valid_now"] is False + with pytest.raises(Conflict): + engine.consume(obj.id, DIGEST, decision_id="decision:original") + assert uses(engine) == [] + + +def test_immediate_fractional_receipt_restart_and_outbox_use_one_instant(tmp_path): + current = [FROZEN.replace(microsecond=750123)] + path = tmp_path/"approval.sqlite" + engine = Engine(path, clock=lambda: current[0]) + obj = issue(engine, FROZEN.replace(microsecond=700000), FROZEN+timedelta(seconds=1)) + samples = [] + def clock(): + value = current[0] + timedelta(microseconds=len(samples)) + samples.append(value) + return value + engine.clock = clock + result = engine.consume(obj.id, DIGEST, decision_id="decision:original") + assert samples == [current[0]] + assert result["consumed_at"] == current[0].isoformat() + assert result["consumed_at"] >= FROZEN.replace(microsecond=700001).isoformat() + event = uses(engine)[0] + assert event["created_at"] == event["payload"]["observed_at"] == result["consumed_at"] + engine.close() + engine = Engine(path, clock=lambda: FROZEN+timedelta(days=1), auto_migrate=False) + assert engine.get(obj.id).as_dict()["consumption"]["consumed_at"] == result["consumed_at"] + retry = engine.consume(obj.id, DIGEST, decision_id="decision:new") + assert retry == {**result, "idempotent": True} + assert uses(engine) == [event] + engine.close() + + +@pytest.mark.parametrize("after,accepted", [(450000, True), (750000, False)]) +def test_consume_samples_after_real_sqlite_lock_wait(tmp_path, after, accepted): + current = [FROZEN.replace(microsecond=250000)] + path = tmp_path/"approval.sqlite" + engine = Engine(path, clock=lambda: current[0]) + obj = issue(engine, FROZEN-timedelta(seconds=1), FROZEN.replace(microsecond=500000)) + entered = threading.Event() + initialized = threading.Event() + start = threading.Event() + samples = [] + def consume(): + conn = engine._conn() + conn.set_trace_callback(lambda statement: entered.set() if statement == "BEGIN IMMEDIATE" else None) + initialized.set() + assert start.wait(2) + def clock(): + samples.append(current[0]) + return current[0] + engine.clock = clock + try: + return engine.consume(obj.id, DIGEST, decision_id="decision:original") + finally: + engine.close() + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(consume) + assert initialized.wait(2) + lock = sqlite3.connect(path) + lock.execute("BEGIN IMMEDIATE") + start.set() + assert entered.wait(2) + current[0] = FROZEN.replace(microsecond=after) + lock.commit() + lock.close() + if accepted: + assert future.result(timeout=2)["consumed_at"] == current[0].isoformat() + else: + with pytest.raises(Conflict): + future.result(timeout=2) + assert samples == [current[0]] + assert len(uses(engine)) == int(accepted) + engine.close() + + +def test_old_second_precision_receipt_and_payload_are_never_backfilled(tmp_path): + engine = Engine(tmp_path/"old.sqlite", clock=lambda: FROZEN) + obj = issue(engine, FROZEN-timedelta(seconds=1), FROZEN+timedelta(seconds=1)) + original = engine.consume(obj.id, DIGEST, decision_id="decision:old") + event = uses(engine)[0] + assert original["consumed_at"] == "2026-08-29T12:00:00+00:00" + engine.clock = lambda: FROZEN + timedelta(days=1, microseconds=123456) + assert engine.consume(obj.id, DIGEST, decision_id="decision:new") == {**original, "idempotent": True} + assert uses(engine) == [event] + + +def test_fractional_use_outbox_failure_rolls_back(engine): + engine.clock = lambda: FROZEN.replace(microsecond=123456) + obj = issue(engine, FROZEN, FROZEN+timedelta(seconds=1)) + engine.fail_outbox = True + with pytest.raises(StoreUnavailable): + engine.consume(obj.id, DIGEST) + assert engine.get(obj.id).consumed_at is None + assert uses(engine) == [] diff --git a/workplans/APPROVAL-WP-0006-fractional-consumption-time.md b/workplans/APPROVAL-WP-0006-fractional-consumption-time.md new file mode 100644 index 0000000..f798744 --- /dev/null +++ b/workplans/APPROVAL-WP-0006-fractional-consumption-time.md @@ -0,0 +1,65 @@ +--- +id: APPROVAL-WP-0006 +type: workplan +title: "Preserve fractional authoritative approval and consumption time" +domain: infotech +repo: approval-engine +status: active +flavor: implementation +owner: codex +topic_slug: netkingdom +created: "2026-10-16" +updated: "2026-10-16" +depends_on: [] +--- + +Explicit CUST-WP-0080 precision defect demand: actual clock .750 was rounded to +.000, accepting consume beyond a .500 expiry and recording evidence earlier than +the actual signed decision. INTENT basis: valid right now, exact-bound atomic +consumption and faithful source evidence. Fix source time, not Access backdating +or a consumer waiver. This does not promote native issuer/deployment authority. + +## Preserve clock precision and one post-lock consumption instant + +```task +id: APPROVAL-WP-0006-T01 +status: done +priority: high +``` + +Preserve aware UTC microseconds through clock/ISO formatting. After acquisition +of the SQLite consume lock, sample one time for validity, stored consumption +receipt/update and use-event occurrence. Preserve old second-precision rows, +original idempotent receipt/decision and local transactional atomicity. + +Seven added regressions: baseline five failed/two passed; repaired 89 focused +checks pass. Full source suite: 298 passed, one pre-existing deployment pin vs +historical release-evidence mismatch retained. One initial test attempted issue +after expiry and was corrected to issue before expiry, then advance the clock; +the repaired source correctly refused that expired setup. Source change needs no +schema migration and preserves old rows/idempotent timestamps exactly. + +## Review temporal source successor and hand off independently pinned repair + +```task +id: APPROVAL-WP-0006-T02 +status: progress +priority: high +``` + +Supply exact patch/test/source review evidence for coordinator integration and +Access/Secrets immediate strict CAS remeasurement. Review only Approval facts, +normalized preapproval correspondence and temporal bounds on the exact successor +source. Existing WP0005 closure/R1 review remain historical; native issuer, +audit receiver, served-PEP/CAS/effect and cleanup gates remain WP0002 T01/T04/T05 +and the existing Access/Secrets/Platform records. No native gate is closed here. + +Source owner review and handoff are prepared in +`docs/reviews/2026-10-16-fractional-consumption-temporal-approval-owner-review.json`. +Standalone repair patch is frozen separately from the Access R3 fact review; +89 focused source checks and 298 full checks pass, with one known image-evidence +mismatch. Access precise NotBefore remains unchanged and original claim freshness +is never renewed. Exact R3 normalized-preapproval/temporal source review covers +Approval facts only; native freshness120 configuration is not granted. Independent +integration/publication and actual immediate strict CAS successor remeasurement +remain pending coordinator/consumer evidence, not a reason to backdate or waive.