@passioncode-ai/fabric-agent-adapter 0.18.0 → 0.19.0

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.
@@ -90,6 +90,7 @@ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
90
90
  _LOCAL_PATH = re.compile(r"^(~[\\/]|/(?!/)|[A-Za-z]:[\\/])")
91
91
  # DEC-0032: the supervisor per system and the field that names its job.
92
92
  MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
93
+ _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
93
94
  _UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
94
95
  _TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
95
96
  WINDOWS = os.name == "nt"
@@ -709,6 +710,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
709
710
  problems.append("paths must be local paths, never a network share: %r" % value)
710
711
  header = auth.get("header", "Authorization")
711
712
  scheme = auth.get("scheme", "Bearer")
713
+ if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
714
+ problems.append("auth.header must match %s" % _AUTH_HEADER.pattern)
715
+ if scheme not in ("Bearer", "none"):
716
+ problems.append("auth.scheme is Bearer or none")
712
717
  if header != "Authorization" and scheme != "none":
713
718
  problems.append("a custom auth header carries the raw token: scheme must be none")
714
719
  life = descriptor.get("lifecycle") or {}
@@ -1102,11 +1107,19 @@ def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
1102
1107
 
1103
1108
  def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
1104
1109
  now: Optional[_dt.datetime] = None,
1105
- budget: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1110
+ budget: Optional[Dict[str, Any]] = None,
1111
+ budgets: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
1106
1112
  """The `service-usage.schema.json` answer: the last 31 UTC days, oldest first, per provider
1107
1113
  and model. Days without calls are omitted. A row whose calls are all unpriced has
1108
- `costUsd: None`; totals are the sums of the model rows (FAC-SEM-025)."""
1114
+ `costUsd: None`; totals are the sums of the model rows (FAC-SEM-025).
1115
+
1116
+ ``budgets`` (DEC-0027) lists every spending limit the service applies, enforced or not. A
1117
+ machine limit with a calendar ``period`` and no ``spentUsd`` is counted here from the receipts,
1118
+ the way ``budget`` is, so the two never disagree; a per-order limit (``per_job``, ``approval``)
1119
+ gets ``spentUsd: None``; every other limit states its own spend. A report whose limits break
1120
+ FAC-SEM-031 is refused (``usage_limit_problems``)."""
1109
1121
  now = now or _dt.datetime.now(_dt.timezone.utc)
1122
+ receipts = list(receipts)
1110
1123
  first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
1111
1124
  last = now.strftime("%Y-%m-%d")
1112
1125
  days: Dict[str, Dict[str, Dict[str, Any]]] = {}
@@ -1145,14 +1158,528 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
1145
1158
  if budget is not None:
1146
1159
  if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
1147
1160
  raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
1148
- prefix = last if budget["period"] == "day" else last[:7]
1149
- window = [d for d in out_days if d["date"].startswith(prefix)]
1150
- unknown = bool(window) and all(d["costUsd"] is None for d in window)
1151
- spent = None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
1152
- report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]), "spentUsd": spent}
1161
+ report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]),
1162
+ "spentUsd": _spent_in(out_days, budget["period"], last)}
1163
+ if budgets is not None:
1164
+ limits = []
1165
+ for given in budgets:
1166
+ if not isinstance(given, dict):
1167
+ raise ServiceError("each limit in budgets is an object")
1168
+ limit = dict(given)
1169
+ if "spentUsd" not in limit:
1170
+ if limit.get("kind") in PER_ORDER_KINDS:
1171
+ limit["spentUsd"] = None
1172
+ elif limit.get("scope") == "machine" and limit.get("period") in ("day", "month"):
1173
+ limit["spentUsd"] = _spent_in(out_days, limit["period"], last)
1174
+ elif limit.get("scope") == "machine" and _number(limit.get("windowSeconds")):
1175
+ limit["spentUsd"] = _spent_since(receipts, now - _dt.timedelta(seconds=limit["windowSeconds"]), now)
1176
+ if isinstance(limit.get("limitUsd"), int) and not isinstance(limit.get("limitUsd"), bool):
1177
+ limit["limitUsd"] = float(limit["limitUsd"])
1178
+ limits.append(limit)
1179
+ report["budgets"] = limits
1180
+ problems = usage_limit_problems(report)
1181
+ if problems:
1182
+ raise ServiceError("the report's limits are not coherent: " + "; ".join(problems))
1153
1183
  return report
1154
1184
 
1155
1185
 
1186
+ def _spent_since(receipts: List[Dict[str, Any]], start: _dt.datetime, end: _dt.datetime) -> Optional[float]:
1187
+ """Spend in a rolling window, from the receipts' own times; None when every call in it is unpriced."""
1188
+ lo, hi = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
1189
+ inside = [r for r in receipts if lo < str(r.get("at", "")) <= hi]
1190
+ if inside and all(r.get("costUsd") is None for r in inside):
1191
+ return None
1192
+ return round(sum(r.get("costUsd") or 0.0 for r in inside), 6)
1193
+
1194
+
1195
+ def _spent_in(out_days: List[Dict[str, Any]], period: str, last: str) -> Optional[float]:
1196
+ """Spend in the current UTC day or month; None when every call in it is unpriced."""
1197
+ prefix = last if period == "day" else last[:7]
1198
+ window = [d for d in out_days if d["date"].startswith(prefix)]
1199
+ unknown = bool(window) and all(d["costUsd"] is None for d in window)
1200
+ return None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
1201
+
1202
+
1203
+ # --- every spending limit (DEC-0027, FAC-SEM-031) ---------------------------------
1204
+
1205
+ LIMIT_SCOPES = ("machine", "project", "pool", "job")
1206
+ PER_ORDER_KINDS = ("per_job", "approval")
1207
+ _LIMIT_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,63}$")
1208
+ _LIMIT_KIND = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
1209
+ _LIMIT_FIELDS = ("id", "scope", "subject", "kind", "period", "windowSeconds", "since", "limitUsd", "spentUsd", "enforced", "tripped")
1210
+ _TIMESTAMP = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
1211
+
1212
+
1213
+ def _is_timestamp(value: Any) -> bool:
1214
+ """A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset."""
1215
+ m = _TIMESTAMP.fullmatch(value) if isinstance(value, str) else None
1216
+ if not m:
1217
+ return False
1218
+ year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
1219
+ try:
1220
+ _dt.date(year, month, day)
1221
+ except ValueError:
1222
+ return False
1223
+ offset_ok = m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
1224
+ return hour <= 23 and minute <= 59 and second <= 60 and offset_ok
1225
+
1226
+
1227
+ def _number(value: Any) -> bool:
1228
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
1229
+
1230
+
1231
+ def usage_limit_problems(report: Dict[str, Any]) -> List[str]:
1232
+ """The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031: unique
1233
+ ids, a subject exactly for project and pool scope, one window that agrees with the kind, per-order limits with
1234
+ no window and no spend, only a relative limit with a null `limitUsd`, a tripped limit enforced, and a legacy
1235
+ `budget` that repeats one enforced machine limit. A breach (spent above the limit) is not a problem: a report
1236
+ truthfully shows one, and a host shows it first. An unknown kind is never refused."""
1237
+ problems: List[str] = []
1238
+ limits = report.get("budgets")
1239
+ if limits is None:
1240
+ return problems
1241
+ if not isinstance(limits, list) or len(limits) > 64:
1242
+ return ["budgets is a list of at most 64 limits"]
1243
+ seen = set()
1244
+ for i, limit in enumerate(limits):
1245
+ at = "/budgets/%d" % i
1246
+ if not isinstance(limit, dict):
1247
+ problems.append("%s is not an object" % at)
1248
+ continue
1249
+ for key in limit:
1250
+ if key not in _LIMIT_FIELDS:
1251
+ problems.append("%s/%s is not a limit field" % (at, key))
1252
+ for key in ("id", "scope", "kind", "limitUsd", "spentUsd", "enforced"):
1253
+ if key not in limit:
1254
+ problems.append("%s is missing %s" % (at, key))
1255
+ if "id" in limit and not (isinstance(limit["id"], str) and _LIMIT_ID.fullmatch(limit["id"])):
1256
+ problems.append("%s/id must match %s" % (at, _LIMIT_ID.pattern))
1257
+ if "scope" in limit and limit["scope"] not in LIMIT_SCOPES:
1258
+ problems.append("%s/scope must be one of %s" % (at, ", ".join(LIMIT_SCOPES)))
1259
+ if "subject" in limit and not (isinstance(limit["subject"], str) and 1 <= len(limit["subject"]) <= 128):
1260
+ problems.append("%s/subject is 1 to 128 characters" % at)
1261
+ if "kind" in limit and not (isinstance(limit["kind"], str) and _LIMIT_KIND.fullmatch(limit["kind"])):
1262
+ problems.append("%s/kind must match %s" % (at, _LIMIT_KIND.pattern))
1263
+ if "period" in limit and limit["period"] not in ("day", "month"):
1264
+ problems.append("%s/period is day or month" % at)
1265
+ if "windowSeconds" in limit and not (isinstance(limit["windowSeconds"], int) and not isinstance(limit["windowSeconds"], bool)
1266
+ and limit["windowSeconds"] >= 1):
1267
+ problems.append("%s/windowSeconds is a whole number of seconds, at least 1" % at)
1268
+ if "since" in limit and not _is_timestamp(limit["since"]):
1269
+ problems.append("%s/since is a UTC timestamp" % at)
1270
+ if "limitUsd" in limit and not (limit["limitUsd"] is None or (_number(limit["limitUsd"]) and limit["limitUsd"] > 0)):
1271
+ problems.append("%s/limitUsd is a dollar amount above zero, or null" % at)
1272
+ if "spentUsd" in limit and not (limit["spentUsd"] is None or (_number(limit["spentUsd"]) and limit["spentUsd"] >= 0)):
1273
+ problems.append("%s/spentUsd is a dollar amount, zero or more, or null" % at)
1274
+ for key in ("enforced", "tripped"):
1275
+ if key in limit and not isinstance(limit[key], bool):
1276
+ problems.append("%s/%s is true or false" % (at, key))
1277
+
1278
+ # FAC-SEM-031
1279
+ ident = str(limit.get("id", ""))
1280
+ if ident in seen:
1281
+ problems.append("FAC-SEM-031 %s/id: limit id %s appears twice" % (at, ident))
1282
+ seen.add(ident)
1283
+ scope = limit.get("scope")
1284
+ named = scope in ("project", "pool")
1285
+ if named and "subject" not in limit:
1286
+ problems.append("FAC-SEM-031 %s/subject: a %s limit names its %s in subject" % (at, scope, scope))
1287
+ if not named and "subject" in limit:
1288
+ problems.append("FAC-SEM-031 %s/subject: a %s limit has no subject" % (at, scope))
1289
+ windows = [f for f in ("period", "windowSeconds", "since") if f in limit]
1290
+ if len(windows) > 1:
1291
+ problems.append("FAC-SEM-031 %s/%s: a limit has one window, not %s" % (at, windows[1], " and ".join(windows)))
1292
+ kind = str(limit.get("kind", ""))
1293
+ seconds = limit.get("windowSeconds") if _number(limit.get("windowSeconds")) else None
1294
+ if kind in PER_ORDER_KINDS:
1295
+ if windows:
1296
+ problems.append("FAC-SEM-031 %s/%s: %s bounds one order and has no window" % (at, windows[0], kind))
1297
+ if limit.get("spentUsd", 0) is not None:
1298
+ problems.append("FAC-SEM-031 %s/spentUsd: %s bounds one order, so nothing is spent against it (null)" % (at, kind))
1299
+ elif kind == "daily":
1300
+ if not (limit.get("period") == "day" or seconds == 86400):
1301
+ problems.append("FAC-SEM-031 %s/period: a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)" % at)
1302
+ elif kind == "monthly":
1303
+ if not (limit.get("period") == "month" or (seconds is not None and 28 * 86400 <= seconds <= 31 * 86400)):
1304
+ problems.append("FAC-SEM-031 %s/period: a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)" % at)
1305
+ elif kind == "velocity":
1306
+ if seconds is None:
1307
+ problems.append("FAC-SEM-031 %s/windowSeconds: a velocity limit counts a rolling window (windowSeconds)" % at)
1308
+ elif kind in ("pool", "emergency"):
1309
+ if not windows:
1310
+ problems.append("FAC-SEM-031 %s/period: a %s limit names the window it counts (period, windowSeconds or since)" % (at, kind))
1311
+ known = kind in PER_ORDER_KINDS or kind in ("daily", "monthly", "pool", "emergency")
1312
+ if known and "limitUsd" in limit and limit["limitUsd"] is None:
1313
+ problems.append("FAC-SEM-031 %s/limitUsd: a %s limit is a dollar amount; only a relative limit (velocity) may leave it null" % (at, kind))
1314
+ if limit.get("tripped") is True and limit.get("enforced") is not True:
1315
+ problems.append("FAC-SEM-031 %s/tripped: a limit that is not enforced cannot have stopped work" % at)
1316
+
1317
+ budget = report.get("budget")
1318
+ if isinstance(budget, dict) and limits:
1319
+ kind = "daily" if budget.get("period") == "day" else "monthly"
1320
+
1321
+ def same(x: Any, y: Any) -> bool:
1322
+ if x is None or y is None:
1323
+ return x is y
1324
+ return _number(x) and _number(y) and abs(x - y) <= 0.000001
1325
+
1326
+ twin = any(isinstance(l, dict) and l.get("scope") == "machine" and l.get("enforced") is True and l.get("kind") == kind
1327
+ and l.get("period") == budget.get("period") and same(l.get("limitUsd"), budget.get("limitUsd"))
1328
+ and same(l.get("spentUsd"), budget.get("spentUsd")) for l in limits)
1329
+ if not twin:
1330
+ problems.append("FAC-SEM-031 /budget: budget repeats one enforced machine %s limit with period %s in budgets[], so old "
1331
+ "and new readers agree" % (kind, budget.get("period")))
1332
+ return problems
1333
+
1334
+
1335
+ # --- the feed client (DEC-0025, FAC-SEM-036) --------------------------------------
1336
+
1337
+ def token_header(auth: Any, token: str) -> Tuple[str, str]:
1338
+ """The one header a client sends the service token in, as the descriptor's ``auth`` declares it: ``header``
1339
+ defaults to Authorization and ``scheme`` to Bearer; ``none`` carries the raw token. Never assume
1340
+ ``Authorization: Bearer`` (a feed client that did was refused on every poll and dropped every notify event)."""
1341
+ declared = auth if isinstance(auth, dict) else {}
1342
+ name = declared.get("header") if isinstance(declared.get("header"), str) else "Authorization"
1343
+ scheme = declared.get("scheme") if isinstance(declared.get("scheme"), str) else "Bearer"
1344
+ return name, token if scheme == "none" else "Bearer " + token
1345
+
1346
+
1347
+ # --- settings backups (DEC-0025, FAC-SEM-035) -------------------------------------
1348
+
1349
+ SETTINGS_BACKUP_FORMAT = "fabric-settings-backup/1"
1350
+ SETTINGS_BACKUP_KEEP = 14
1351
+ SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60
1352
+ _STALE_TEMP_SECONDS = 60 * 60
1353
+ _SAFE_INTEGER = 2 ** 53 - 1
1354
+ _TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
1355
+ _SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$")
1356
+ _COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
1357
+ _SECRET_COLUMN = re.compile(r"^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$")
1358
+ _REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
1359
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
1360
+ _SURROGATE = re.compile("[\ud800-\udfff]")
1361
+ _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S*")
1362
+ _MAX_CELL_CHARS = 1048576
1363
+ _MAX_ROW_VALUES = 512
1364
+
1365
+
1366
+ def _canonical_check(value: Any, at: str = "") -> None:
1367
+ if value is None or isinstance(value, (bool, str)):
1368
+ return
1369
+ if isinstance(value, int):
1370
+ if abs(value) > _SAFE_INTEGER:
1371
+ raise ServiceError("%s: %d is outside ±(2^53 − 1) and has no canonical form; store it as a string" % (at or "/", value))
1372
+ return
1373
+ if isinstance(value, float):
1374
+ raise ServiceError("%s: a fraction has no canonical form; a settings backup stores it as its decimal string" % (at or "/"))
1375
+ if isinstance(value, list):
1376
+ for i, item in enumerate(value):
1377
+ _canonical_check(item, "%s/%d" % (at, i))
1378
+ return
1379
+ if isinstance(value, dict):
1380
+ for key, item in value.items():
1381
+ if not isinstance(key, str):
1382
+ raise ServiceError("%s: object keys are strings" % (at or "/"))
1383
+ _canonical_check(item, "%s/%s" % (at, key))
1384
+ return
1385
+ raise ServiceError("%s: %s is not a JSON value" % (at or "/", type(value).__name__))
1386
+
1387
+
1388
+ def settings_canonical_json(value: Any) -> str:
1389
+ """The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, ", " and ": ",
1390
+ strings escaped as JSON requires and nothing else, integers in decimal — Python's
1391
+ ``json.dumps(value, sort_keys=True, ensure_ascii=False)``. Refuses a fraction, an integer outside ±(2^53 − 1)
1392
+ and anything that is not JSON."""
1393
+ _canonical_check(value)
1394
+ return json.dumps(value, sort_keys=True, ensure_ascii=False)
1395
+
1396
+
1397
+ def settings_backup_digest(tables: Any) -> str:
1398
+ """The ``sha256`` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of ``tables``."""
1399
+ return hashlib.sha256(settings_canonical_json(tables).encode("utf-8")).hexdigest()
1400
+
1401
+
1402
+ def settings_backup_problems(doc: Any) -> List[str]:
1403
+ """Why a reader refuses this settings backup: its shape (``settings-backup.schema.json``) and FAC-SEM-035 —
1404
+ the checksum is the digest of the tables, counts name exactly the tables and their rows, every row is as wide as
1405
+ its columns, and every value has a canonical form. Empty means intact."""
1406
+ if not isinstance(doc, dict):
1407
+ return ["a settings backup is a JSON object"]
1408
+ problems: List[str] = []
1409
+ if doc.get("format") != SETTINGS_BACKUP_FORMAT:
1410
+ problems.append("format must be %s, not %r: a reader refuses a format it does not know" % (SETTINGS_BACKUP_FORMAT, doc.get("format")))
1411
+ for key in ("format", "service", "created_at", "reason", "sha256", "counts", "tables"):
1412
+ if key not in doc:
1413
+ problems.append("missing %s" % key)
1414
+ for key in doc:
1415
+ if key not in ("format", "service", "created_at", "reason", "sha256", "counts", "tables", "extensions"):
1416
+ problems.append("%s is not a settings backup field" % key)
1417
+ if "extensions" in doc:
1418
+ if not isinstance(doc["extensions"], dict):
1419
+ problems.append("extensions is an object")
1420
+ else:
1421
+ for key in doc["extensions"]:
1422
+ if not _URI.fullmatch(key):
1423
+ problems.append("extensions key %r is not a URI" % key)
1424
+ service = doc.get("service")
1425
+ if "service" in doc:
1426
+ if not isinstance(service, dict) or set(service) != {"id", "instance", "version", "schema"}:
1427
+ problems.append("service is {id, instance, version, schema}")
1428
+ else:
1429
+ if not _ID.fullmatch(str(service["id"])):
1430
+ problems.append("service.id must match %s" % _ID.pattern)
1431
+ if not _INSTANCE.fullmatch(str(service["instance"])):
1432
+ problems.append("service.instance must match %s" % _INSTANCE.pattern)
1433
+ if not (isinstance(service["version"], str) and len(service["version"]) <= 64):
1434
+ problems.append("service.version is a string of at most 64 characters")
1435
+ schema = service["schema"]
1436
+ if not ((isinstance(schema, int) and not isinstance(schema, bool) and schema >= 0)
1437
+ or (isinstance(schema, str) and 1 <= len(schema) <= 64)):
1438
+ problems.append("service.schema is a whole number or a short string")
1439
+ if "created_at" in doc and not _is_timestamp(doc["created_at"]):
1440
+ problems.append("created_at is a UTC timestamp")
1441
+ if "reason" in doc and not (isinstance(doc["reason"], str) and _REASON.fullmatch(doc["reason"])):
1442
+ problems.append("reason must match %s" % _REASON.pattern)
1443
+ if "sha256" in doc and not (isinstance(doc["sha256"], str) and _SHA256.fullmatch(doc["sha256"])):
1444
+ problems.append("sha256 is 64 lowercase hex digits")
1445
+ tables = doc.get("tables") if isinstance(doc.get("tables"), dict) else {}
1446
+ counts = doc.get("counts") if isinstance(doc.get("counts"), dict) else {}
1447
+ if "tables" in doc and not isinstance(doc.get("tables"), dict):
1448
+ problems.append("tables is an object")
1449
+ if "counts" in doc and not isinstance(doc.get("counts"), dict):
1450
+ problems.append("counts is an object")
1451
+ for name in list(tables) + [n for n in counts if n not in tables]:
1452
+ if not _TABLE.fullmatch(name) or _SECRET_TABLE.fullmatch(name):
1453
+ problems.append("table %s is never backed up: it can only hold connections, tokens, secrets, credentials, "
1454
+ "passwords, login codes or sessions (or its name is not a table name)" % name)
1455
+ for name, count in counts.items():
1456
+ if not (isinstance(count, int) and not isinstance(count, bool) and count >= 0):
1457
+ problems.append("counts.%s is a whole number" % name)
1458
+ canonical = True
1459
+ for name, table in tables.items():
1460
+ if not isinstance(table, dict) or set(table) != {"columns", "rows"}:
1461
+ problems.append("tables.%s is {columns, rows}" % name)
1462
+ continue
1463
+ columns, rows = table["columns"], table["rows"]
1464
+ if not (isinstance(columns, list) and 1 <= len(columns) <= 512 and len(set(map(str, columns))) == len(columns)):
1465
+ problems.append("tables.%s.columns is 1 to 512 distinct names" % name)
1466
+ columns = columns if isinstance(columns, list) else []
1467
+ for column in columns:
1468
+ if not (isinstance(column, str) and _COLUMN.fullmatch(column)) or _SECRET_COLUMN.fullmatch(str(column)):
1469
+ problems.append("column %s.%s is refused: its name says it holds a credential value (name the secret, "
1470
+ "never store it: secret_name, token_slot)" % (name, column))
1471
+ if not isinstance(rows, list):
1472
+ problems.append("tables.%s.rows is a list" % name)
1473
+ continue
1474
+ width = len(columns)
1475
+ for i, row in enumerate(rows):
1476
+ at = "/tables/%s/rows/%d" % (name, i)
1477
+ if not isinstance(row, list):
1478
+ problems.append("%s is a list of values" % at)
1479
+ continue
1480
+ if len(row) != width:
1481
+ problems.append("FAC-SEM-035 %s: row has %d values for %d columns" % (at, len(row), width))
1482
+ if len(row) > _MAX_ROW_VALUES:
1483
+ problems.append("%s holds more than %d values" % (at, _MAX_ROW_VALUES))
1484
+ for j, cell in enumerate(row):
1485
+ if isinstance(cell, float) or (isinstance(cell, int) and not isinstance(cell, bool) and abs(cell) > _SAFE_INTEGER):
1486
+ canonical = False
1487
+ problems.append("FAC-SEM-035 %s/%d: a fraction or an unsafe integer has no canonical form; store it as a string" % (at, j))
1488
+ elif isinstance(cell, str) and _SURROGATE.search(cell):
1489
+ canonical = False
1490
+ problems.append("FAC-SEM-035 %s/%d: a string with a lone surrogate cannot be encoded as UTF-8" % (at, j))
1491
+ elif isinstance(cell, str) and len(cell) > _MAX_CELL_CHARS:
1492
+ problems.append("%s/%d is longer than %d characters" % (at, j, _MAX_CELL_CHARS))
1493
+ elif not (cell is None or isinstance(cell, (bool, int, str))):
1494
+ canonical = False
1495
+ problems.append("%s/%d is not a JSON scalar" % (at, j))
1496
+ if counts.get(name) != len(rows):
1497
+ problems.append("FAC-SEM-035 /counts/%s: counts says %s for %d rows" % (name, counts.get(name, "nothing"), len(rows)))
1498
+ for name in counts:
1499
+ if name not in tables:
1500
+ problems.append("FAC-SEM-035 /counts/%s: counts names %s, which the backup does not carry" % (name, name))
1501
+ if canonical and isinstance(doc.get("tables"), dict):
1502
+ try:
1503
+ digest: Optional[str] = settings_backup_digest(tables)
1504
+ except (ServiceError, UnicodeError, TypeError, ValueError) as exc: # a name no canonical form can carry
1505
+ digest = None
1506
+ problems.append("FAC-SEM-035 /tables: the tables have no canonical form (%s)" % exc)
1507
+ if digest is not None and doc.get("sha256") != digest:
1508
+ problems.append("FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it")
1509
+ return problems
1510
+
1511
+
1512
+ def settings_backup_root(platform: str = sys.platform) -> Path:
1513
+ """Where every service keeps its settings backups — outside its data directory (DEC-0025)."""
1514
+ if platform == "darwin":
1515
+ return Path.home() / "Library/Application Support/PassionCode"
1516
+ if platform == "win32":
1517
+ appdata = os.environ.get("APPDATA")
1518
+ return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "PassionCode"
1519
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "PassionCode"
1520
+
1521
+
1522
+ def settings_backup_dir(service_id: str, instance: str = "default", root: Optional[Path] = None) -> Path:
1523
+ _require_id(service_id)
1524
+ if not (isinstance(instance, str) and _INSTANCE.fullmatch(instance)):
1525
+ raise ServiceError("instance must match %s" % _INSTANCE.pattern)
1526
+ return (root or settings_backup_root()) / "settings-backups" / service_id / instance
1527
+
1528
+
1529
+ def make_settings_backup(*, service_id: str, instance: str = "default", version: str, schema: Any,
1530
+ tables: Dict[str, Dict[str, Any]], reason: str, now: Optional[_dt.datetime] = None) -> Dict[str, Any]:
1531
+ """A snapshot of the operator's decisions and bindings: the tables on the service's allow-list, each
1532
+ ``{"columns": [...], "rows": [[...], ...]}`` of JSON scalars (a fraction as its decimal string). Refuses a file a
1533
+ reader would refuse."""
1534
+ now = now or _dt.datetime.now(_dt.timezone.utc)
1535
+ doc = {"format": SETTINGS_BACKUP_FORMAT,
1536
+ "service": {"id": service_id, "instance": instance, "version": version, "schema": schema},
1537
+ "created_at": now.astimezone(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "reason": reason,
1538
+ "sha256": "", "counts": {name: len(table.get("rows") or []) for name, table in tables.items()}, "tables": tables}
1539
+ try:
1540
+ doc["sha256"] = settings_backup_digest(tables)
1541
+ except ServiceError as exc:
1542
+ raise ServiceError("this snapshot has no canonical form: %s" % exc) from None
1543
+ problems = settings_backup_problems(doc)
1544
+ if problems:
1545
+ raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
1546
+ return doc
1547
+
1548
+
1549
+ def _backup_name(doc: Dict[str, Any]) -> str:
1550
+ stamp = doc["created_at"].replace("-", "").replace(":", "")
1551
+ head, _, frac = stamp.rstrip("Z").partition(".")
1552
+ return "settings-%s%sZ.json" % (head, (frac + "000000")[:6])
1553
+
1554
+
1555
+ def settings_backups(directory: Path) -> List[Path]:
1556
+ """The snapshot files, newest first (their names sort in time order)."""
1557
+ directory = Path(directory)
1558
+ if not directory.is_dir():
1559
+ return []
1560
+ return sorted((p for p in directory.iterdir() if re.fullmatch(r"settings-[0-9]{8}T[0-9]{12}Z\.json", p.name)), reverse=True)
1561
+
1562
+
1563
+ def _no_constant(name: str) -> Any:
1564
+ raise ValueError("%s is not JSON" % name)
1565
+
1566
+
1567
+ def read_settings_backup(path: Path) -> Dict[str, Any]:
1568
+ """A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035)."""
1569
+ try:
1570
+ doc = json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=_no_constant)
1571
+ except (OSError, ValueError) as exc:
1572
+ raise ServiceError("settings backup %s cannot be read: %s" % (path, exc)) from None
1573
+ problems = settings_backup_problems(doc)
1574
+ if problems:
1575
+ raise ServiceError("settings backup %s is refused: %s" % (path, "; ".join(problems)))
1576
+ return doc
1577
+
1578
+
1579
+ def write_settings_backup(doc: Dict[str, Any], directory: Path, *, keep: int = SETTINGS_BACKUP_KEEP) -> Optional[Path]:
1580
+ """Write a snapshot made by ``make_settings_backup``: atomically, 0600 in a 0700 directory, named by its time.
1581
+ A temporary file an interrupted write left is removed first; a snapshot whose checksum equals the newest file's
1582
+ is not written again (None). After a write, all but the ``keep`` newest are removed (at least 14)."""
1583
+ problems = settings_backup_problems(doc)
1584
+ if problems:
1585
+ raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
1586
+ directory = Path(directory)
1587
+ directory.mkdir(parents=True, exist_ok=True)
1588
+ ensure_private_dir(directory)
1589
+ stale = time.time() - _STALE_TEMP_SECONDS
1590
+ for left in directory.iterdir():
1591
+ # Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
1592
+ try:
1593
+ if left.name.startswith(".settings-") and left.stat().st_mtime < stale:
1594
+ left.unlink()
1595
+ except OSError:
1596
+ pass
1597
+ existing = settings_backups(directory)
1598
+ if existing:
1599
+ try:
1600
+ if read_settings_backup(existing[0])["sha256"] == doc["sha256"]:
1601
+ return None
1602
+ except Exception: # noqa: BLE001 - a damaged newest file is no reason to skip a good snapshot
1603
+ pass
1604
+ path = directory / _backup_name(doc)
1605
+ atomic_write(path, json.dumps(doc, ensure_ascii=False, indent=2).encode("utf-8") + b"\n", 0o600)
1606
+ # The newest by name, but never the one just written: a clock that stepped back must not delete it.
1607
+ for old in [p for p in settings_backups(directory) if p != path][max(keep, SETTINGS_BACKUP_KEEP) - 1:]:
1608
+ old.unlink()
1609
+ return path
1610
+
1611
+
1612
+ def backup_due(directory: Path, now: Optional[_dt.datetime] = None) -> bool:
1613
+ """True when there is no snapshot or the newest is older than 24 hours: take one at start or on the daily tick."""
1614
+ newest = settings_backups(directory)
1615
+ if not newest:
1616
+ return True
1617
+ try:
1618
+ stamp = _dt.datetime.strptime(newest[0].name[len("settings-"):-len("Z.json")], "%Y%m%dT%H%M%S%f").replace(tzinfo=_dt.timezone.utc)
1619
+ except ValueError:
1620
+ return True # a name no calendar has: take a fresh snapshot rather than trust it
1621
+ return ((now or _dt.datetime.now(_dt.timezone.utc)) - stamp).total_seconds() > SETTINGS_BACKUP_MAX_AGE_SECONDS
1622
+
1623
+
1624
+ def newest_intact_backup(directory: Path) -> Tuple[Optional[Path], Optional[Dict[str, Any]], List[Tuple[Path, str]]]:
1625
+ """The source of an automatic restore: the newest snapshot that is intact and has content. Damaged ones are
1626
+ skipped and returned with the reason, for the restore report."""
1627
+ skipped: List[Tuple[Path, str]] = []
1628
+ for path in settings_backups(directory):
1629
+ try:
1630
+ doc = read_settings_backup(path)
1631
+ except Exception as exc: # noqa: BLE001 - one damaged file never stops the scan
1632
+ skipped.append((path, str(exc)))
1633
+ continue
1634
+ if any(doc["counts"].values()):
1635
+ return path, doc, skipped
1636
+ return None, None, skipped
1637
+
1638
+
1639
+ def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
1640
+ """What restoring ``doc`` would do — it adds and never overwrites (DEC-0025). ``current`` describes the database
1641
+ now, per table: ``columns`` (all of them), ``key`` (the primary key columns), ``required`` (columns without a
1642
+ default) and ``existing`` (the primary keys present, as tuples). Per table: the rows to insert (dicts over the
1643
+ columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
1644
+ to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
1645
+ ``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
1646
+ row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's act."""
1647
+ problems = settings_backup_problems(doc)
1648
+ if problems:
1649
+ raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
1650
+ plan: Dict[str, Any] = {"source": {"created_at": doc["created_at"], "sha256": doc["sha256"]}, "tables": {}, "unknownTables": []}
1651
+ for name, table in doc["tables"].items():
1652
+ now_table = current.get(name)
1653
+ if now_table is None:
1654
+ plan["unknownTables"].append(name)
1655
+ continue
1656
+ columns = list(now_table.get("columns") or [])
1657
+ key = list(now_table.get("key") or [])
1658
+ required = list(now_table.get("required") or [])
1659
+ existing = {tuple(k) for k in now_table.get("existing") or []}
1660
+ shared = [c for c in table["columns"] if c in columns]
1661
+ missing = [c for c in required if c not in table["columns"]]
1662
+ entry = {"insert": [], "present": 0, "droppedColumns": [c for c in table["columns"] if c not in columns],
1663
+ "defaultedColumns": [c for c in columns if c not in table["columns"]], "missingRequired": missing, "refused": []}
1664
+ planned = set()
1665
+ for row in table["rows"]:
1666
+ values = dict(zip(table["columns"], row))
1667
+ ident = [values.get(c) for c in key]
1668
+ if any(c not in table["columns"] for c in key):
1669
+ entry["refused"].append({"key": ident, "reason": "the primary key is not in the snapshot"})
1670
+ elif tuple(ident) in planned:
1671
+ entry["refused"].append({"key": ident, "reason": "the snapshot holds this key twice"})
1672
+ elif tuple(ident) in existing:
1673
+ entry["present"] += 1
1674
+ elif missing:
1675
+ entry["refused"].append({"key": ident, "reason": "required column %s is not in the snapshot" % ", ".join(missing)})
1676
+ else:
1677
+ planned.add(tuple(ident))
1678
+ entry["insert"].append({c: values[c] for c in shared})
1679
+ plan["tables"][name] = entry
1680
+ return plan
1681
+
1682
+
1156
1683
  class JsonlUsageLedger:
1157
1684
  """Usage receipts for services that keep none yet: append-only JSON lines, mode 0600, pruned
1158
1685
  to the reported window so the file stays bounded (LC-12). One writer process — the service,
@@ -2001,7 +2528,7 @@ def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path]
2001
2528
  # --- helpers for input validation -----------------------------------------------
2002
2529
 
2003
2530
  def _require_id(service_id: str) -> None:
2004
- if not _ID.match(service_id):
2531
+ if not (isinstance(service_id, str) and _ID.fullmatch(service_id)):
2005
2532
  raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
2006
2533
 
2007
2534
 
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.18.0"
8
+ version: "0.19.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
11
11
  ---