303 lines
13 KiB
Diff
303 lines
13 KiB
Diff
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/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..be9ab93
|
|
--- /dev/null
|
|
+++ b/workplans/APPROVAL-WP-0006-fractional-consumption-time.md
|
|
@@ -0,0 +1,55 @@
|
|
+---
|
|
+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: todo
|
|
+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.
|