taskfence 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
taskfence/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """TaskFence: revocation-safe side effects for long-running AI agent tasks."""
2
+
3
+ from .fence import Fence
4
+ from .outcomes import (Admission, Code, Denied, Duplicate, Grant, Receipt, Retry, Revocation, TaskFenceError,
5
+ UsageError)
6
+ from .tokens import Claims, KeyRing
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["Fence", "KeyRing", "Claims", "Admission", "Code", "Denied", "Duplicate", "Grant", "Receipt", "Retry",
11
+ "Revocation", "TaskFenceError", "UsageError"]
taskfence/_hooks.py ADDED
@@ -0,0 +1,32 @@
1
+ """Named hook points inside the library. No-ops unless a test installs a controller.
2
+
3
+ Kept separate from `taskfence.testing` so the core never imports test code.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Protocol
9
+
10
+ GUARD_POINTS = (
11
+ "guard.before_fence", # caller's transaction open, fence row not yet locked
12
+ "guard.after_fence", # fence row locked, every check passed, op row not yet inserted
13
+ "guard.after_insert", # op row inserted; guard() is about to return
14
+ )
15
+
16
+
17
+ class Controller(Protocol):
18
+ def arrive(self, point: str, ctx: dict[str, Any]) -> None: ...
19
+
20
+
21
+ _controller: Controller | None = None
22
+
23
+
24
+ def install(c: Controller | None) -> None:
25
+ global _controller
26
+ _controller = c
27
+
28
+
29
+ def hook(point: str, **ctx: Any) -> None:
30
+ c = _controller
31
+ if c is not None:
32
+ c.arrive(point, ctx)
taskfence/fence.py ADDED
@@ -0,0 +1,200 @@
1
+ """The `Fence` facade: grants, revocation, tokens and the commit-time guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import secrets
7
+ import time
8
+ from contextlib import AbstractContextManager
9
+ from typing import Any, Callable, TypeVar
10
+
11
+ from . import _hooks
12
+ from .outcomes import Admission, Code, Denied, Grant, Receipt, Revocation, UsageError
13
+ from .stores.sqlite import SQLiteStore
14
+ from .tokens import Claims, KeyRing, sign, verify
15
+
16
+ F = TypeVar("F", bound=Callable[..., Any])
17
+ Predicate = Callable[[Any, Claims, "str | None"], bool]
18
+
19
+
20
+ class Fence:
21
+ """Commit-time authorization for long-running agent tasks.
22
+
23
+ The safety property: once a revocation is effective, no effect admitted under
24
+ a revoked epoch commits. In embedded mode a revocation is effective when
25
+ `revoke()` returns (or when the caller's transaction that ran it commits).
26
+ """
27
+
28
+ def __init__(self, keys: KeyRing, store: SQLiteStore | None = None, *,
29
+ clock: Callable[[], float] = time.time, leeway_s: int = 30):
30
+ self.keys = keys
31
+ self.store = store or SQLiteStore()
32
+ self.clock = clock
33
+ self.leeway_s = leeway_s
34
+
35
+ # ------------------------------------------------------------------ setup
36
+
37
+ def migrate(self, conn) -> None:
38
+ """Create the taskfence_* tables in the application's database (idempotent)."""
39
+ self.store.migrate(conn)
40
+
41
+ def transaction(self, conn) -> AbstractContextManager[None]:
42
+ """Open a write transaction suitable for `guard()`; commits on success, rolls back on error."""
43
+ return self.store.transaction(conn)
44
+
45
+ # ------------------------------------------------------------------ control plane (embedded mode)
46
+
47
+ def grant(self, conn, *, principal: str, operation: str, resources: list[str],
48
+ grant_id: str | None = None) -> Grant:
49
+ """Create a grant at epoch 1. `resources` may contain "*" for any resource."""
50
+ if not resources:
51
+ raise ValueError("resources must not be empty; use ['*'] to allow any resource")
52
+ gid = grant_id or "g_" + secrets.token_hex(8)
53
+ with self.store.errors(), self.store.owned_or_joined(conn):
54
+ self.store.create_grant(conn, gid, principal, operation, list(resources))
55
+ return Grant(gid, principal, operation, 1, tuple(sorted(resources)))
56
+
57
+ def revoke(self, conn, grant_id: str) -> Revocation:
58
+ """Kill every token issued for this grant so far. Idempotent.
59
+
60
+ If a guarded transaction for this grant is open, this call waits for it
61
+ (bounded by that transaction, not by the task).
62
+ """
63
+ with self.store.errors(), self.store.owned_or_joined(conn) as owned:
64
+ self._embedded_row(conn, grant_id)
65
+ min_epoch, seq = self.store.revoke(conn, grant_id)
66
+ return Revocation(grant_id, owned, min_epoch, seq)
67
+
68
+ def regrant(self, conn, grant_id: str, *, resources: list[str] | None = None) -> Grant:
69
+ """Start a new epoch. Tokens from earlier epochs stay dead."""
70
+ with self.store.errors(), self.store.owned_or_joined(conn):
71
+ f = self._embedded_row(conn, grant_id)
72
+ new_epoch, _ = self.store.regrant(conn, grant_id, resources) # computed under the lock
73
+ scope = self.store.scope(conn, grant_id)
74
+ return Grant(grant_id, f["principal"], f["operation"], new_epoch, scope)
75
+
76
+ def cancel_task(self, conn, task_id: str) -> int:
77
+ """Fence a task: none of its operations can commit afterwards. Returns the commit seq."""
78
+ with self.store.errors(), self.store.owned_or_joined(conn):
79
+ return self.store.cancel_task(conn, task_id)
80
+
81
+ def cancel_operation(self, conn, operation_id: str, *, task_id: str) -> bool:
82
+ """Tombstone one operation. Returns False if it had already committed."""
83
+ with self.store.errors(), self.store.owned_or_joined(conn):
84
+ return self.store.cancel_operation(conn, operation_id, task_id)
85
+
86
+ def _embedded_row(self, conn, grant_id: str) -> dict[str, Any]:
87
+ f = self.store.fence_row(conn, grant_id)
88
+ if f is None:
89
+ raise KeyError(f"unknown grant {grant_id!r}")
90
+ if f["mode"] != "embedded":
91
+ raise UsageError(f"grant {grant_id!r} is federated; change it through apply_install/apply_fence")
92
+ return f
93
+
94
+ # ------------------------------------------------------------------ tokens
95
+
96
+ def issue(self, conn, grant_id: str, *, task_id: str, ttl_s: int = 3600) -> str:
97
+ """Admit a task: return a signed token bound to the grant's current epoch."""
98
+ f = self._embedded_row(conn, grant_id)
99
+ if f["min_epoch"] > f["epoch"]:
100
+ raise Denied(Code.STALE_EPOCH, "grant is revoked")
101
+ return self.mint(grant_id=grant_id, principal=f["principal"], operation=f["operation"],
102
+ epoch=f["epoch"], task_id=task_id, ttl_s=ttl_s)
103
+
104
+ def mint(self, *, grant_id: str, principal: str, operation: str, epoch: int, task_id: str,
105
+ ttl_s: int = 3600) -> str:
106
+ """Sign a token directly. Used by a grant authority in federated mode."""
107
+ now = int(self.clock())
108
+ return sign(Claims(grant_id, principal, operation, int(epoch), task_id, self.keys.signing_kid,
109
+ now, now + int(ttl_s)), self.keys)
110
+
111
+ def verify(self, token: str) -> Claims:
112
+ return verify(token, self.keys, now=self.clock, leeway_s=self.leeway_s)
113
+
114
+ # ------------------------------------------------------------------ the guard
115
+
116
+ def guard(self, conn, token: str, *, operation: str, resource: str | None, operation_id: str,
117
+ predicate: Predicate | None = None) -> Admission:
118
+ """Authorize an effect inside the transaction that commits it.
119
+
120
+ Call this inside an open transaction, before or after the effect's own
121
+ statements but before COMMIT. It raises `Denied`, `Duplicate` or `Retry`;
122
+ on any of them the caller must roll back. It never commits or rolls back
123
+ by itself.
124
+ """
125
+ self.store.require_transaction(conn)
126
+ claims = self.verify(token)
127
+ if claims.operation != operation:
128
+ raise Denied(Code.OPERATION_MISMATCH, f"token is for {claims.operation!r}")
129
+ ctx = {"task_id": claims.task_id, "operation_id": operation_id}
130
+ _hooks.hook("guard.before_fence", **ctx)
131
+ with self.store.errors():
132
+ code = self.store.lock_and_check_fence(conn, claims.grant_id, claims.principal, operation, claims.epoch)
133
+ if code is not None:
134
+ raise Denied(code)
135
+ if not self.store.in_scope(conn, claims.grant_id, resource):
136
+ raise Denied(Code.RESOURCE_NOT_IN_SCOPE, str(resource))
137
+ if self.store.task_cancelled(conn, claims.task_id):
138
+ raise Denied(Code.TASK_CANCELLED, claims.task_id)
139
+ if predicate is not None and not predicate(conn, claims, resource):
140
+ raise Denied(Code.PREDICATE_FAILED)
141
+ _hooks.hook("guard.after_fence", **ctx)
142
+ seq = self.store.insert_op(conn, operation_id, claims.task_id, claims.grant_id, claims.epoch, resource)
143
+ if isinstance(seq, Code):
144
+ raise Denied(seq, operation_id)
145
+ _hooks.hook("guard.after_insert", **ctx)
146
+ return Admission(operation_id, claims.task_id, claims.grant_id, claims.epoch, resource, seq)
147
+
148
+ def fenced(self, *, operation: str, resource: Callable[..., str | None] | str | None,
149
+ operation_id: Callable[..., str], predicate: Predicate | None = None) -> Callable[[F], F]:
150
+ """Decorator: run `fn(conn, **kwargs)` in a transaction that `guard()` admitted.
151
+
152
+ The wrapped function is called as `fn(conn, token=..., **kwargs)`; `resource`
153
+ and `operation_id` are callables that receive the same keyword arguments
154
+ (without `token`). The function also receives `admission=`.
155
+ """
156
+ def deco(fn: F) -> F:
157
+ @functools.wraps(fn)
158
+ def wrapper(conn, *, token: str, **kwargs: Any) -> Any:
159
+ res = resource(**kwargs) if callable(resource) else resource
160
+ oid = operation_id(**kwargs)
161
+ with self.transaction(conn):
162
+ adm = self.guard(conn, token, operation=operation, resource=res, operation_id=oid,
163
+ predicate=predicate)
164
+ return fn(conn, admission=adm, **kwargs)
165
+ return wrapper # type: ignore[return-value]
166
+ return deco
167
+
168
+ # ------------------------------------------------------------------ federated owner side
169
+
170
+ def apply_install(self, conn, msg: dict[str, Any]) -> Receipt:
171
+ """INSTALL {msg_id, grant_id, principal, operation, epoch, resources} from a grant authority."""
172
+ return self._apply(conn, {**msg, "kind": "INSTALL"})
173
+
174
+ def apply_fence(self, conn, msg: dict[str, Any]) -> Receipt:
175
+ """FENCE {msg_id, grant_id, principal, operation, fence_epoch}: fence_epoch is the new minimum."""
176
+ return self._apply(conn, {**msg, "kind": "FENCE"})
177
+
178
+ def _apply(self, conn, msg: dict[str, Any]) -> Receipt:
179
+ if not isinstance(msg.get("msg_id"), str):
180
+ raise ValueError("msg_id is required")
181
+ with self.store.errors(), self.store.owned_or_joined(conn):
182
+ r = self.store.apply_message(conn, msg)
183
+ return Receipt(r["msg_id"], r["kind"], r["grant_id"], r["epoch"], bool(r["applied"]), r["min_epoch"],
184
+ r["duplicate"])
185
+
186
+ # ------------------------------------------------------------------ outbox
187
+
188
+ def enqueue(self, conn, operation_id: str, topic: str, payload: Any) -> int:
189
+ """Record an external call to make after commit. Exists iff the guarded transaction commits."""
190
+ return self.store.outbox_enqueue(conn, operation_id, topic, payload)
191
+
192
+ def drain_outbox(self, conn, handler: Callable[[dict[str, Any]], None], *, limit: int = 100) -> int:
193
+ """Deliver pending outbox entries at least once. `handler` must be idempotent."""
194
+ n = 0
195
+ for entry in self.store.outbox_pending(conn, limit):
196
+ handler(entry)
197
+ with self.store.owned_or_joined(conn):
198
+ self.store.outbox_mark_delivered(conn, entry["id"])
199
+ n += 1
200
+ return n
taskfence/outcomes.py ADDED
@@ -0,0 +1,93 @@
1
+ """Outcome types and denial codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class Code(StrEnum):
10
+ BAD_TOKEN = "BAD_TOKEN" # malformed token
11
+ BAD_SIGNATURE = "BAD_SIGNATURE"
12
+ UNKNOWN_KEY = "UNKNOWN_KEY" # kid not in the key ring (e.g. removed after compromise)
13
+ EXPIRED = "EXPIRED"
14
+ NO_GRANT = "NO_GRANT" # no fence row for this grant
15
+ STALE_EPOCH = "STALE_EPOCH" # token epoch below the fence: revoked or superseded
16
+ FUTURE_EPOCH = "FUTURE_EPOCH" # token epoch above any epoch issued here
17
+ PRINCIPAL_MISMATCH = "PRINCIPAL_MISMATCH"
18
+ OPERATION_MISMATCH = "OPERATION_MISMATCH"
19
+ RESOURCE_NOT_IN_SCOPE = "RESOURCE_NOT_IN_SCOPE"
20
+ TASK_CANCELLED = "TASK_CANCELLED"
21
+ OPERATION_CANCELLED = "OPERATION_CANCELLED"
22
+ PREDICATE_FAILED = "PREDICATE_FAILED" # application predicate returned False
23
+
24
+
25
+ class TaskFenceError(Exception):
26
+ """Base class for all TaskFence errors."""
27
+
28
+
29
+ class Denied(TaskFenceError):
30
+ """The effect must not commit. Roll back the transaction."""
31
+
32
+ def __init__(self, code: Code, detail: str = ""):
33
+ self.code = Code(code)
34
+ self.detail = detail
35
+ super().__init__(f"{self.code}{': ' + detail if detail else ''}")
36
+
37
+
38
+ class Duplicate(TaskFenceError):
39
+ """This operation_id already committed. Roll back; the earlier effect stands."""
40
+
41
+ def __init__(self, operation_id: str):
42
+ self.operation_id = operation_id
43
+ super().__init__(f"operation already committed: {operation_id}")
44
+
45
+
46
+ class Retry(TaskFenceError):
47
+ """Transient database conflict (lock timeout, serialization failure). Safe to retry."""
48
+
49
+
50
+ class UsageError(TaskFenceError):
51
+ """The library was called in a way that would void its guarantee."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Admission:
56
+ """Returned by `guard()`. Provisional until the caller's transaction commits."""
57
+
58
+ operation_id: str
59
+ task_id: str
60
+ grant_id: str
61
+ epoch: int
62
+ resource: str | None
63
+ seq: int
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Grant:
68
+ grant_id: str
69
+ principal: str
70
+ operation: str
71
+ epoch: int
72
+ resources: tuple[str, ...]
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Revocation:
77
+ grant_id: str
78
+ effective: bool # embedded mode: True once this call returns
79
+ min_epoch: int
80
+ seq: int
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Receipt:
85
+ """Owner acknowledgment of a federated INSTALL or FENCE message."""
86
+
87
+ msg_id: str
88
+ kind: str
89
+ grant_id: str
90
+ epoch: int
91
+ applied: bool
92
+ min_epoch: int
93
+ duplicate: bool
@@ -0,0 +1,3 @@
1
+ from .sqlite import SQLiteStore, connect
2
+
3
+ __all__ = ["SQLiteStore", "connect"]
@@ -0,0 +1,328 @@
1
+ """PostgreSQL store (psycopg 3). Not part of the paper's evaluation; see docs/DESIGN.md §6.
2
+
3
+ Locking, per grant:
4
+
5
+ * ``lock="shared"`` (default). A guard takes a *shared* transaction-level
6
+ advisory lock on the grant, then reads the fence row ``FOR SHARE``. Revoke and
7
+ regrant take the *exclusive* advisory lock, then UPDATE the fence row. Guards
8
+ for one grant run in parallel. Advisory locks queue fairly, so a steady stream
9
+ of guards cannot starve a revocation (plain ``FOR SHARE`` row locks can).
10
+ * ``lock="exclusive"``. A guard reads the fence row ``FOR UPDATE``. Guards for
11
+ one grant serialize, as in D5.
12
+
13
+ Either way, a revocation waits for in-flight guarded transactions of that grant,
14
+ and any guard that locks after the revocation commits reads the new floor. Under
15
+ READ COMMITTED each statement takes a fresh snapshot after the lock is granted.
16
+ Under REPEATABLE READ and SERIALIZABLE, locking a row changed after the snapshot
17
+ raises a serialization failure, which surfaces as ``Retry``.
18
+
19
+ Cancellation: the guard upserts the task's row in ``taskfence_tasks`` (row lock
20
+ held until commit) and reads its ``cancelled`` flag. ``cancel_task`` upserts the
21
+ same row, so the two serialize.
22
+
23
+ ``seq`` comes from a sequence. It is unique but is not commit order. The auditor
24
+ uses commit timestamps instead (requires ``track_commit_timestamp = on``).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from contextlib import contextmanager
31
+ from typing import Any, Iterator, Literal
32
+
33
+ try:
34
+ import psycopg
35
+ from psycopg import errors as pgerr
36
+ from psycopg.pq import TransactionStatus
37
+ except ImportError as e: # pragma: no cover
38
+ raise ImportError("PostgresStore needs psycopg 3: pip install 'taskfence[postgres]'") from e
39
+
40
+ from ..outcomes import Code, Duplicate, Retry, UsageError
41
+
42
+ SCHEMA = """
43
+ CREATE SEQUENCE IF NOT EXISTS taskfence_seq;
44
+ CREATE TABLE IF NOT EXISTS taskfence_fences (
45
+ grant_id text PRIMARY KEY,
46
+ principal text NOT NULL,
47
+ operation text NOT NULL,
48
+ mode text NOT NULL CHECK (mode IN ('embedded', 'federated')),
49
+ epoch bigint, -- highest issued epoch; NULL in federated mode
50
+ min_epoch bigint NOT NULL, -- tokens below this are dead; only ever increases
51
+ updated_at timestamptz NOT NULL DEFAULT now());
52
+ CREATE TABLE IF NOT EXISTS taskfence_scope (
53
+ grant_id text NOT NULL REFERENCES taskfence_fences ON DELETE CASCADE,
54
+ resource text NOT NULL, PRIMARY KEY (grant_id, resource));
55
+ CREATE TABLE IF NOT EXISTS taskfence_fence_log (
56
+ seq bigint PRIMARY KEY, grant_id text NOT NULL, kind text NOT NULL,
57
+ epoch bigint, min_epoch bigint NOT NULL, at timestamptz NOT NULL DEFAULT now());
58
+ CREATE TABLE IF NOT EXISTS taskfence_ops (
59
+ operation_id text PRIMARY KEY,
60
+ state text NOT NULL CHECK (state IN ('COMMITTED', 'CANCELLED')),
61
+ task_id text NOT NULL,
62
+ grant_id text,
63
+ epoch bigint,
64
+ resource text,
65
+ seq bigint NOT NULL UNIQUE,
66
+ created_at timestamptz NOT NULL DEFAULT now());
67
+ CREATE TABLE IF NOT EXISTS taskfence_tasks (
68
+ task_id text PRIMARY KEY, cancelled boolean NOT NULL DEFAULT false, cancelled_seq bigint);
69
+ CREATE TABLE IF NOT EXISTS taskfence_cancel_log ( -- append-only; the auditor reads its commit timestamps
70
+ seq bigint PRIMARY KEY, task_id text NOT NULL, at timestamptz NOT NULL DEFAULT now());
71
+ CREATE TABLE IF NOT EXISTS taskfence_receipts (
72
+ msg_id text PRIMARY KEY, kind text NOT NULL, grant_id text NOT NULL, epoch bigint NOT NULL,
73
+ applied boolean NOT NULL, min_epoch bigint NOT NULL, seq bigint NOT NULL);
74
+ CREATE TABLE IF NOT EXISTS taskfence_outbox (
75
+ id bigserial PRIMARY KEY,
76
+ operation_id text NOT NULL REFERENCES taskfence_ops(operation_id),
77
+ topic text NOT NULL, payload jsonb NOT NULL, delivered_at timestamptz);
78
+ """
79
+
80
+ _RETRYABLE = (pgerr.SerializationFailure, pgerr.DeadlockDetected, pgerr.LockNotAvailable,
81
+ pgerr.QueryCanceled)
82
+ # Advisory-lock namespace: hashtext('taskfence') would also do; a constant keeps it obvious.
83
+ _LOCK_CLASS = 0x7446 # "tF"
84
+
85
+
86
+ def connect(dsn: str, **kwargs: Any) -> "psycopg.Connection":
87
+ """Open a connection configured the way TaskFence expects (autocommit; explicit transactions)."""
88
+ return psycopg.connect(dsn, autocommit=True, **kwargs)
89
+
90
+
91
+ @contextmanager
92
+ def translate_errors() -> Iterator[None]:
93
+ try:
94
+ yield
95
+ except _RETRYABLE as e:
96
+ raise Retry(f"{type(e).__name__}: {e}") from e
97
+
98
+
99
+ def _status(conn) -> TransactionStatus:
100
+ return conn.info.transaction_status
101
+
102
+
103
+ class PostgresStore:
104
+ name = "postgres"
105
+
106
+ def __init__(self, lock: Literal["shared", "exclusive"] = "shared"):
107
+ if lock not in ("shared", "exclusive"):
108
+ raise ValueError("lock must be 'shared' or 'exclusive'")
109
+ self.lock = lock
110
+
111
+ @staticmethod
112
+ def errors():
113
+ return translate_errors()
114
+
115
+ # ---------------------------------------------------------------- transactions
116
+
117
+ def migrate(self, conn) -> None:
118
+ if _status(conn) != TransactionStatus.IDLE:
119
+ raise UsageError("migrate() must not run inside an open transaction")
120
+ with conn.transaction():
121
+ conn.execute(SCHEMA)
122
+
123
+ @contextmanager
124
+ def transaction(self, conn) -> Iterator[None]:
125
+ if _status(conn) != TransactionStatus.IDLE:
126
+ raise UsageError("a transaction is already open on this connection")
127
+ with translate_errors(), conn.transaction():
128
+ yield
129
+
130
+ @contextmanager
131
+ def owned_or_joined(self, conn) -> Iterator[bool]:
132
+ if _status(conn) == TransactionStatus.INTRANS:
133
+ yield False
134
+ else:
135
+ with self.transaction(conn):
136
+ yield True
137
+
138
+ @staticmethod
139
+ def require_transaction(conn) -> None:
140
+ if _status(conn) != TransactionStatus.INTRANS:
141
+ raise UsageError("guard() must run inside the transaction that commits the effect; "
142
+ "use fence.transaction(conn)")
143
+
144
+ @staticmethod
145
+ def bump(conn) -> int:
146
+ return conn.execute("SELECT nextval('taskfence_seq')").fetchone()[0]
147
+
148
+ def _log(self, conn, grant_id: str, kind: str, epoch: int | None, min_epoch: int) -> int:
149
+ seq = self.bump(conn)
150
+ conn.execute("INSERT INTO taskfence_fence_log (seq, grant_id, kind, epoch, min_epoch)"
151
+ " VALUES (%s, %s, %s, %s, %s)", (seq, grant_id, kind, epoch, min_epoch))
152
+ return seq
153
+
154
+ @staticmethod
155
+ def _grant_lock(conn, grant_id: str, *, exclusive: bool) -> None:
156
+ fn = "pg_advisory_xact_lock" if exclusive else "pg_advisory_xact_lock_shared"
157
+ conn.execute(f"SELECT {fn}(%s, hashtext(%s))", (_LOCK_CLASS, grant_id))
158
+
159
+ def _writer_lock(self, conn, grant_id: str) -> None:
160
+ """Taken by every control-plane change to a fence row."""
161
+ if self.lock == "shared":
162
+ self._grant_lock(conn, grant_id, exclusive=True)
163
+
164
+ # ---------------------------------------------------------------- reads
165
+
166
+ @staticmethod
167
+ def fence_row(conn, grant_id: str, *, lock: str = "") -> dict[str, Any] | None:
168
+ r = conn.execute("SELECT grant_id, principal, operation, mode, epoch, min_epoch FROM taskfence_fences"
169
+ f" WHERE grant_id = %s {lock}", (grant_id,)).fetchone()
170
+ return None if r is None else dict(zip(("grant_id", "principal", "operation", "mode", "epoch",
171
+ "min_epoch"), r))
172
+
173
+ @staticmethod
174
+ def scope(conn, grant_id: str) -> tuple[str, ...]:
175
+ rows = conn.execute("SELECT resource FROM taskfence_scope WHERE grant_id = %s ORDER BY resource",
176
+ (grant_id,)).fetchall()
177
+ return tuple(r[0] for r in rows)
178
+
179
+ # ---------------------------------------------------------------- embedded admin
180
+
181
+ def create_grant(self, conn, grant_id: str, principal: str, operation: str, resources: list[str]) -> int:
182
+ conn.execute("INSERT INTO taskfence_fences (grant_id, principal, operation, mode, epoch, min_epoch)"
183
+ " VALUES (%s, %s, %s, 'embedded', 1, 1)", (grant_id, principal, operation))
184
+ with conn.cursor() as cur:
185
+ cur.executemany("INSERT INTO taskfence_scope VALUES (%s, %s)", [(grant_id, r) for r in resources])
186
+ return self._log(conn, grant_id, "GRANT", 1, 1)
187
+
188
+ def revoke(self, conn, grant_id: str) -> tuple[int, int]:
189
+ self._writer_lock(conn, grant_id)
190
+ cur = conn.execute("UPDATE taskfence_fences SET min_epoch = GREATEST(min_epoch, epoch + 1),"
191
+ " updated_at = now() WHERE grant_id = %s RETURNING epoch, min_epoch", (grant_id,))
192
+ e, floor = cur.fetchone()
193
+ return floor, self._log(conn, grant_id, "REVOKE", e, floor)
194
+
195
+ def regrant(self, conn, grant_id: str, resources: list[str] | None) -> tuple[int, int]:
196
+ self._writer_lock(conn, grant_id)
197
+ new_epoch = conn.execute("UPDATE taskfence_fences SET epoch = GREATEST(epoch, min_epoch - 1) + 1,"
198
+ " min_epoch = GREATEST(epoch, min_epoch - 1) + 1, updated_at = now()"
199
+ " WHERE grant_id = %s RETURNING epoch", (grant_id,)).fetchone()[0]
200
+ if resources is not None:
201
+ self._replace_scope(conn, grant_id, resources)
202
+ return new_epoch, self._log(conn, grant_id, "REGRANT", new_epoch, new_epoch)
203
+
204
+ @staticmethod
205
+ def _replace_scope(conn, grant_id: str, resources: list[str]) -> None:
206
+ conn.execute("DELETE FROM taskfence_scope WHERE grant_id = %s", (grant_id,))
207
+ with conn.cursor() as cur:
208
+ cur.executemany("INSERT INTO taskfence_scope VALUES (%s, %s)", [(grant_id, r) for r in resources])
209
+
210
+ def cancel_task(self, conn, task_id: str) -> int:
211
+ # Lock first, then take seq, so seq order matches commit order against conflicting guards.
212
+ conn.execute("INSERT INTO taskfence_tasks (task_id, cancelled) VALUES (%s, true)"
213
+ " ON CONFLICT (task_id) DO UPDATE SET cancelled = true WHERE NOT taskfence_tasks.cancelled",
214
+ (task_id,))
215
+ seq = self.bump(conn)
216
+ conn.execute("UPDATE taskfence_tasks SET cancelled_seq = COALESCE(cancelled_seq, %s) WHERE task_id = %s",
217
+ (seq, task_id))
218
+ conn.execute("INSERT INTO taskfence_cancel_log (seq, task_id) VALUES (%s, %s)", (seq, task_id))
219
+ return seq
220
+
221
+ def cancel_operation(self, conn, operation_id: str, task_id: str) -> bool:
222
+ seq = self.bump(conn)
223
+ r = conn.execute("INSERT INTO taskfence_ops (operation_id, state, task_id, seq)"
224
+ " VALUES (%s, 'CANCELLED', %s, %s) ON CONFLICT (operation_id) DO NOTHING RETURNING 1",
225
+ (operation_id, task_id, seq)).fetchone()
226
+ if r is not None:
227
+ return True
228
+ prior = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = %s", (operation_id,)).fetchone()
229
+ return prior is not None and prior[0] == "CANCELLED"
230
+
231
+ # ---------------------------------------------------------------- guard steps
232
+
233
+ def lock_and_check_fence(self, conn, grant_id: str, principal: str, operation: str, epoch: int) -> Code | None:
234
+ if self.lock == "shared":
235
+ self._grant_lock(conn, grant_id, exclusive=False)
236
+ # Federated: an authentic newer epoch proves a regrant, so raise the floor (D5 semantics).
237
+ adv = conn.execute("UPDATE taskfence_fences SET min_epoch = %s, updated_at = now()"
238
+ " WHERE grant_id = %s AND mode = 'federated' AND principal = %s AND operation = %s"
239
+ " AND min_epoch < %s", (epoch, grant_id, principal, operation, epoch))
240
+ if adv.rowcount == 1:
241
+ self._log(conn, grant_id, "ADVANCE", epoch, epoch)
242
+ f = self.fence_row(conn, grant_id, lock="FOR SHARE" if self.lock == "shared" else "FOR UPDATE")
243
+ if f is None:
244
+ return Code.NO_GRANT
245
+ if principal != f["principal"]:
246
+ return Code.PRINCIPAL_MISMATCH
247
+ if operation != f["operation"]:
248
+ return Code.OPERATION_MISMATCH
249
+ if epoch < f["min_epoch"]:
250
+ return Code.STALE_EPOCH
251
+ if f["epoch"] is not None and epoch > f["epoch"]:
252
+ return Code.FUTURE_EPOCH
253
+ return None
254
+
255
+ @staticmethod
256
+ def in_scope(conn, grant_id: str, resource: str | None) -> bool:
257
+ return conn.execute(
258
+ "SELECT 1 FROM taskfence_scope WHERE grant_id = %s AND (resource = '*' OR resource = %s)",
259
+ (grant_id, resource)).fetchone() is not None
260
+
261
+ @staticmethod
262
+ def task_cancelled(conn, task_id: str) -> bool:
263
+ # The row lock is held until commit, so cancel_task() (which writes this row) serializes with us.
264
+ conn.execute("INSERT INTO taskfence_tasks (task_id) VALUES (%s) ON CONFLICT (task_id) DO NOTHING", (task_id,))
265
+ return conn.execute("SELECT cancelled FROM taskfence_tasks WHERE task_id = %s FOR SHARE",
266
+ (task_id,)).fetchone()[0]
267
+
268
+ def insert_op(self, conn, operation_id: str, task_id: str, grant_id: str, epoch: int,
269
+ resource: str | None) -> int | Code:
270
+ seq = self.bump(conn)
271
+ r = conn.execute("INSERT INTO taskfence_ops (operation_id, state, task_id, grant_id, epoch, resource, seq)"
272
+ " VALUES (%s, 'COMMITTED', %s, %s, %s, %s, %s) ON CONFLICT (operation_id) DO NOTHING"
273
+ " RETURNING seq", (operation_id, task_id, grant_id, epoch, resource, seq)).fetchone()
274
+ if r is not None:
275
+ return seq
276
+ prior = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = %s", (operation_id,)).fetchone()
277
+ if prior is not None and prior[0] == "CANCELLED":
278
+ return Code.OPERATION_CANCELLED
279
+ raise Duplicate(operation_id)
280
+
281
+ # ---------------------------------------------------------------- federated owner side
282
+
283
+ def apply_message(self, conn, msg: dict[str, Any]) -> dict[str, Any]:
284
+ cols = ("msg_id", "kind", "grant_id", "epoch", "applied", "min_epoch", "seq")
285
+ kind, g = msg["kind"], msg["grant_id"]
286
+ self._writer_lock(conn, g)
287
+ prior = conn.execute(f"SELECT {', '.join(cols)} FROM taskfence_receipts WHERE msg_id = %s",
288
+ (msg["msg_id"],)).fetchone()
289
+ if prior is not None:
290
+ return {**dict(zip(cols, prior)), "duplicate": True}
291
+ e = int(msg["epoch"] if kind == "INSTALL" else msg["fence_epoch"])
292
+ cur = self.fence_row(conn, g, lock="FOR UPDATE")
293
+ if cur is not None and cur["mode"] != "federated":
294
+ raise UsageError(f"grant {g!r} is an embedded grant; federated messages cannot change it")
295
+ applied = cur is None or e > cur["min_epoch"]
296
+ if cur is None:
297
+ conn.execute("INSERT INTO taskfence_fences (grant_id, principal, operation, mode, epoch, min_epoch)"
298
+ " VALUES (%s, %s, %s, 'federated', NULL, %s)", (g, msg["principal"], msg["operation"], e))
299
+ elif applied:
300
+ conn.execute("UPDATE taskfence_fences SET min_epoch = %s, updated_at = now() WHERE grant_id = %s", (e, g))
301
+ if kind == "INSTALL" and applied:
302
+ self._replace_scope(conn, g, list(msg.get("resources", [])))
303
+ floor = self.fence_row(conn, g)["min_epoch"]
304
+ seq = self._log(conn, g, kind if applied else f"{kind}_NOOP", e, floor)
305
+ conn.execute("INSERT INTO taskfence_receipts VALUES (%s, %s, %s, %s, %s, %s, %s)",
306
+ (msg["msg_id"], kind, g, e, applied, floor, seq))
307
+ return {"msg_id": msg["msg_id"], "kind": kind, "grant_id": g, "epoch": e, "applied": applied,
308
+ "min_epoch": floor, "seq": seq, "duplicate": False}
309
+
310
+ # ---------------------------------------------------------------- outbox
311
+
312
+ @staticmethod
313
+ def outbox_enqueue(conn, operation_id: str, topic: str, payload: Any) -> int:
314
+ r = conn.execute("SELECT state FROM taskfence_ops WHERE operation_id = %s", (operation_id,)).fetchone()
315
+ if r is None or r[0] != "COMMITTED":
316
+ raise UsageError("enqueue only after guard() admitted this operation_id, in the same transaction")
317
+ return conn.execute("INSERT INTO taskfence_outbox (operation_id, topic, payload) VALUES (%s, %s, %s)"
318
+ " RETURNING id", (operation_id, topic, json.dumps(payload))).fetchone()[0]
319
+
320
+ @staticmethod
321
+ def outbox_pending(conn, limit: int) -> list[dict[str, Any]]:
322
+ rows = conn.execute("SELECT id, operation_id, topic, payload FROM taskfence_outbox"
323
+ " WHERE delivered_at IS NULL ORDER BY id LIMIT %s", (limit,)).fetchall()
324
+ return [{"id": r[0], "operation_id": r[1], "topic": r[2], "payload": r[3]} for r in rows]
325
+
326
+ @staticmethod
327
+ def outbox_mark_delivered(conn, entry_id: int) -> None:
328
+ conn.execute("UPDATE taskfence_outbox SET delivered_at = now() WHERE id = %s", (entry_id,))