cctally 1.98.0 → 1.99.1

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.
@@ -324,6 +324,8 @@ def cmd_account(args: argparse.Namespace) -> int:
324
324
  return _cmd_account_show(args)
325
325
  if action == "label":
326
326
  return _cmd_account_label(args)
327
+ if action == "attribute":
328
+ return _cmd_account_attribute(args)
327
329
  eprint("account: unknown action")
328
330
  return 2
329
331
 
@@ -528,3 +530,926 @@ def _cmd_account_label(args: argparse.Namespace) -> int:
528
530
  _jr.run_stats_ingest(mode="authoritative")
529
531
  print(f"Labeled {key[:8]} -> {label}")
530
532
  return 0
533
+
534
+
535
+ # --------------------------------------------------------------------------
536
+ # #500 — `cctally account attribute`: operator attribution of recorded Codex
537
+ # quota windows and spend.
538
+ #
539
+ # Spec: docs/superpowers/specs/2026-08-14-500-codex-window-attribution-design.md
540
+ #
541
+ # The command is a PLANNER plus an apply sequence. Planning decides what to
542
+ # record and refuses loudly; the fold-time overlay (`_lib_codex_window_
543
+ # attribution`, applied inside `load_codex_quota_observations`) re-decides what
544
+ # to APPLY on every load and suppresses quietly. The two evaluation points are
545
+ # deliberately separate: ingest keeps running, and a group that was cleanly
546
+ # unattributed at assertion time may later acquire native evidence.
547
+ #
548
+ # Nothing here writes `quota_window_snapshots`. The journal holds the truth, a
549
+ # derived cache table indexes it, and both axes follow from one insertion point.
550
+ # --------------------------------------------------------------------------
551
+
552
+ #: Refusal codes, sorted into this order wherever more than one applies, so the
553
+ #: envelope is stable and an operator reads the same first cause every run.
554
+ #:
555
+ #: `not_weekly` and `model_scoped` share a DISPOSITION and a remedy, and they
556
+ #: are still two codes here, because this surface names a CAUSE per window
557
+ #: rather than a decision. Measured on the maintainer's store, one whole-era
558
+ #: range reaches 513 five-hour windows against 26 separate model pools, and an
559
+ #: operator reading `refusalCodes: ["model_scoped"]` beside a 5-hour reset
560
+ #: concludes the `_lib_codex_pools` classifier is wrong about it. The fold-time
561
+ #: outcome deliberately stays ONE value (`_lib_codex_window_attribution
562
+ #: .SUPPRESSED_MODEL_SCOPED`): that surface names a suppression decision, and
563
+ #: nothing acts on the two differently there.
564
+ _REFUSAL_ORDER = (
565
+ "partial_group",
566
+ "not_weekly",
567
+ "model_scoped",
568
+ "native_account_conflict",
569
+ "assertion_conflict",
570
+ "spend_account_conflict",
571
+ )
572
+
573
+ #: The refusals that BLOCK an all-or-nothing apply, which is not every refusal.
574
+ #:
575
+ #: Each of these names a real disagreement about a window the operator could
576
+ #: legitimately have meant, so applying the rest would leave the era they asked
577
+ #: for half-attributed. `not_weekly` and `model_scoped` are deliberately NOT
578
+ #: among them: a 5-hour window or a separate model pool can NEVER be account
579
+ #: weekly quota, so it was never a candidate, and the operator did not ask for
580
+ #: it — a time range selected it. Measured read-only against the maintainer's
581
+ #: store on 2026-08-15, a single `--since 2025-12-01 --until 2026-07-29` selects
582
+ #: 605 groups of which 539 are out of scope (513 five-hour windows and 26 Spark
583
+ #: pools) and 66 are attributable; letting the 539 block would refuse every run
584
+ #: an operator could ever make, which is the literal reading of the spec's
585
+ #: precedence table and is dead on arrival. Such a group is still REPORTED as
586
+ #: refused with its code, which is what AC4's "refused at plan time" asks for
587
+ #: and what keeps the exclusion visible rather than silent.
588
+ _BLOCKING_REFUSALS = frozenset({
589
+ "partial_group",
590
+ "native_account_conflict",
591
+ "assertion_conflict",
592
+ "spend_account_conflict",
593
+ })
594
+
595
+
596
+ class _AttributeUsage(ValueError):
597
+ """A native-usage error: printed on stderr, exit 2."""
598
+
599
+
600
+ def _attribute_parse_instant(text: object, flag: str) -> "dt.datetime":
601
+ """A timezone-aware ISO instant, normalized to UTC.
602
+
603
+ Naive input is refused rather than assumed to be UTC or local. The selector
604
+ is a half-open `[since, until)` range and partial-group refusal depends on
605
+ exactly which side of it a boundary falls, so an ambiguous instant would
606
+ silently change which groups are whole.
607
+ """
608
+ import datetime as dt
609
+
610
+ raw = str(text or "").strip()
611
+ if not raw:
612
+ raise _AttributeUsage(f"{flag} requires an ISO instant")
613
+ try:
614
+ parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
615
+ except ValueError:
616
+ raise _AttributeUsage(
617
+ f"{flag}: {raw!r} is not an ISO-8601 instant") from None
618
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
619
+ raise _AttributeUsage(
620
+ f"{flag}: {raw!r} is naive; a timezone-aware instant is required "
621
+ "(for example 2026-01-01T00:00:00Z)")
622
+ return parsed.astimezone(dt.timezone.utc)
623
+
624
+
625
+ def _attribute_resolve_account(conn, ref: str) -> str:
626
+ """Resolve `<ref>` to a REAL Codex account key, or raise `_AttributeUsage`.
627
+
628
+ The literal `unattributed` resolves everywhere else a ref is accepted and is
629
+ rejected here, because attributing data TO the sentinel is not a fact an
630
+ operator can assert — the sentinel means "nobody could determine this".
631
+ """
632
+ try:
633
+ key = _lib_accounts.resolve_account_ref(conn, ref)
634
+ except _lib_accounts.AccountRefError as exc:
635
+ eprint(f"account attribute: ref {ref!r} is ambiguous or unknown")
636
+ print_ref_candidates(conn, exc.candidates)
637
+ raise _AttributeUsage("unresolvable ref") from None
638
+ if key == _lib_accounts.UNATTRIBUTED:
639
+ raise _AttributeUsage(
640
+ "account attribute: the literal 'unattributed' names the "
641
+ "\"account could not be determined\" bucket, so it cannot be the "
642
+ "SUBJECT of an attribution")
643
+ row = conn.execute(
644
+ "SELECT provider FROM accounts WHERE account_key = ?", (key,)
645
+ ).fetchone()
646
+ provider = (row[0] if row is not None else None) or ""
647
+ if provider != "codex":
648
+ raise _AttributeUsage(
649
+ f"account attribute: {ref!r} resolves to a {provider or 'unknown'} "
650
+ "account; this command attributes codex data only (Claude legacy "
651
+ "history keeps its accounts_cutover answer)")
652
+ return key
653
+
654
+
655
+ def _attribute_witness(value) -> str:
656
+ """One spelling for one reset instant, applied to BOTH sides of the binding.
657
+
658
+ The same normalizer the overlay applies to a group's stored resets and to an
659
+ assertion's stored witnesses, so a `Z` witness and a `+00:00` group member
660
+ intersect. Taken from the pure ledger leaf rather than respelled.
661
+ """
662
+ import _lib_quota_ledger
663
+
664
+ return _lib_quota_ledger.normalize_reset(value)
665
+
666
+
667
+ class _AttributeGroup:
668
+ """One planned physical window group, or one planned assertion record."""
669
+
670
+ __slots__ = (
671
+ "group_key", "source_root_key", "logical_limit_key", "observed_slot",
672
+ "window_minutes", "canonical_resets_at", "raw_resets", "disposition",
673
+ "native_accounts", "assertion_accounts", "observation_count",
674
+ "spend_candidate_count", "refusal_codes", "op_ids",
675
+ )
676
+
677
+ def __init__(self, **kwargs):
678
+ for name in self.__slots__:
679
+ setattr(self, name, kwargs.get(name))
680
+
681
+ @property
682
+ def blocks(self) -> bool:
683
+ """Whether this group's refusal stops the whole apply."""
684
+ return bool(set(self.refusal_codes) & _BLOCKING_REFUSALS)
685
+
686
+ @property
687
+ def fingerprint(self) -> tuple:
688
+ """Everything the apply revalidation compares.
689
+
690
+ Spec §8: the preview was computed without locks, so the plan is
691
+ re-derived under them and compared before anything is written. This is
692
+ the comparison — the group's witnesses, the accounts present, the
693
+ active assertions and the spend counts.
694
+ """
695
+ return (
696
+ tuple(self.group_key), tuple(self.raw_resets),
697
+ tuple(self.native_accounts), tuple(self.assertion_accounts),
698
+ int(self.observation_count), int(self.spend_candidate_count),
699
+ str(self.disposition), tuple(self.refusal_codes),
700
+ tuple(self.op_ids or ()),
701
+ )
702
+
703
+ def to_json(self) -> dict:
704
+ return {
705
+ "group": {
706
+ "sourceRootKey": self.source_root_key,
707
+ "logicalLimitKey": self.logical_limit_key,
708
+ "observedSlot": self.observed_slot,
709
+ "windowMinutes": int(self.window_minutes),
710
+ "canonicalResetsAtUtc": self.canonical_resets_at,
711
+ "rawResetsAtUtc": list(self.raw_resets),
712
+ },
713
+ "disposition": self.disposition,
714
+ "nativeAccountKeys": list(self.native_accounts),
715
+ "assertionAccountKeys": list(self.assertion_accounts),
716
+ "observationCount": int(self.observation_count),
717
+ "spendCandidateCount": int(self.spend_candidate_count),
718
+ "refusalCodes": list(self.refusal_codes),
719
+ "assertionOpIds": list(self.op_ids or ()),
720
+ }
721
+
722
+
723
+ def _attribute_spend_index(conn, roots) -> dict:
724
+ """Per root, the accounting rows sorted by instant, for O(log n) lookups.
725
+
726
+ One query per root rather than one per group: a whole-history plan can name
727
+ dozens of groups, and the alternative is a full scan each time.
728
+ """
729
+ index: dict = {}
730
+ for root in sorted(roots):
731
+ rows = []
732
+ try:
733
+ cursor = conn.execute(
734
+ "SELECT timestamp_utc, account_key FROM codex_session_entries "
735
+ " WHERE source_root_key = ? ORDER BY timestamp_utc", (root,))
736
+ except sqlite3.DatabaseError:
737
+ index[root] = ([], [])
738
+ continue
739
+ for timestamp, account in cursor:
740
+ parsed = _attribute_parse_stored_instant(timestamp)
741
+ if parsed is None:
742
+ continue
743
+ rows.append((parsed, account))
744
+ rows.sort(key=lambda item: item[0])
745
+ index[root] = ([item[0] for item in rows], [item[1] for item in rows])
746
+ return index
747
+
748
+
749
+ def _attribute_parse_stored_instant(value):
750
+ import datetime as dt
751
+
752
+ if not isinstance(value, str) or not value.strip():
753
+ return None
754
+ text = value.strip()
755
+ if text.endswith("Z"):
756
+ text = text[:-1] + "+00:00"
757
+ try:
758
+ parsed = dt.datetime.fromisoformat(text)
759
+ except ValueError:
760
+ return None
761
+ if parsed.tzinfo is None:
762
+ return parsed.replace(tzinfo=dt.timezone.utc)
763
+ return parsed.astimezone(dt.timezone.utc)
764
+
765
+
766
+ def _attribute_spend_slice(index, root, start, end):
767
+ """The `[start, end)` accounting rows of one root, as account keys."""
768
+ import bisect
769
+
770
+ instants, accounts = index.get(root, ([], []))
771
+ low = bisect.bisect_left(instants, start)
772
+ high = bisect.bisect_left(instants, end)
773
+ return accounts[low:high]
774
+
775
+
776
+ def _attribute_plan_groups(conn, *, account_key, since, until):
777
+ """Plan the ATTRIBUTE mode: which physical window groups the range names.
778
+
779
+ The range selects OBSERVATIONS. A group every one of whose observations
780
+ falls inside it is whole and may be attributed; a group the range covers
781
+ only partially is refused rather than split, and its whole extent is
782
+ reported so the operator can widen the range instead of guessing at it.
783
+ """
784
+ import datetime as dt
785
+
786
+ import _cctally_quota as _quota
787
+ import _lib_codex_pools as _pools
788
+ import _lib_codex_window_attribution as _wa
789
+
790
+ observations = _quota.load_codex_quota_observations(cache_conn=conn)
791
+ _resolutions, ownership = _quota.resolve_codex_window_attributions(conn)
792
+
793
+ buckets: dict = {}
794
+ for observation in observations:
795
+ identity = observation.identity
796
+ anchor = observation.canonical_resets_at
797
+ key = (identity.source, identity.source_root_key,
798
+ identity.logical_limit_key, identity.observed_slot,
799
+ identity.window_minutes, anchor)
800
+ bucket = buckets.get(key)
801
+ if bucket is None:
802
+ bucket = buckets[key] = {
803
+ "members": [], "accounts": set(), "witnesses": set(),
804
+ "model_scoped": False,
805
+ }
806
+ bucket["members"].append(observation)
807
+ if identity.account_key != _wa.UNATTRIBUTED_SENTINEL:
808
+ bucket["accounts"].add(identity.account_key)
809
+ bucket["witnesses"].add(_attribute_witness(
810
+ observation.resets_at.astimezone(dt.timezone.utc).isoformat()))
811
+ # `limit_name` sits outside identity equality, so one group's members
812
+ # can disagree about it; ANY Spark evidence demotes the whole group out
813
+ # of account weekly quota (#373), matching the fold and the spend pass.
814
+ if _pools.is_model_scoped_codex_quota(
815
+ identity.logical_limit_key, identity.limit_name):
816
+ bucket["model_scoped"] = True
817
+
818
+ selected_keys = [
819
+ key for key, bucket in buckets.items()
820
+ if any(since <= member.captured_at < until
821
+ for member in bucket["members"])
822
+ ]
823
+ roots = {key[1] for key in selected_keys}
824
+ spend_index = _attribute_spend_index(conn, roots)
825
+
826
+ planned = []
827
+ for key in sorted(selected_keys, key=lambda item: (item[5], str(item))):
828
+ bucket = buckets[key]
829
+ members = bucket["members"]
830
+ owner = ownership.get(key)
831
+ # The overlay stamps an unattributed observation only where the group
832
+ # carries NO native real account, so subtracting the resolved owner
833
+ # recovers the pre-overlay native population exactly.
834
+ native = sorted(bucket["accounts"] - ({owner} if owner else set()))
835
+ asserted = sorted({owner} if owner else set())
836
+ anchor = key[5]
837
+ window_minutes = int(key[4])
838
+ start = anchor - dt.timedelta(minutes=window_minutes)
839
+ spend = _attribute_spend_slice(spend_index, key[1], start, anchor)
840
+ spend_candidates = sum(
841
+ 1 for account in spend
842
+ if not account or account == _wa.UNATTRIBUTED_SENTINEL)
843
+ spend_conflicts = sorted({
844
+ account for account in spend
845
+ if account and account != _wa.UNATTRIBUTED_SENTINEL
846
+ and account != account_key
847
+ })
848
+
849
+ codes = []
850
+ if not all(since <= member.captured_at < until for member in members):
851
+ codes.append("partial_group")
852
+ if window_minutes != _wa.ACCOUNT_WEEKLY_WINDOW_MINUTES:
853
+ codes.append("not_weekly")
854
+ if bucket["model_scoped"]:
855
+ codes.append("model_scoped")
856
+ if native and native != [account_key]:
857
+ codes.append("native_account_conflict")
858
+ if asserted and asserted != [account_key]:
859
+ codes.append("assertion_conflict")
860
+ if spend_conflicts:
861
+ codes.append("spend_account_conflict")
862
+
863
+ if codes:
864
+ disposition = "refused"
865
+ elif native == [account_key] or asserted == [account_key]:
866
+ # Already true, by native evidence or by an assertion already on
867
+ # record. Idempotency falls out of this branch rather than needing a
868
+ # dedup check: a second identical apply plans every group here.
869
+ disposition = "noop"
870
+ else:
871
+ disposition = "eligible"
872
+
873
+ planned.append(_AttributeGroup(
874
+ group_key=key, source_root_key=key[1], logical_limit_key=key[2],
875
+ observed_slot=key[3], window_minutes=window_minutes,
876
+ canonical_resets_at=anchor.isoformat(),
877
+ raw_resets=tuple(sorted(bucket["witnesses"])),
878
+ disposition=disposition, native_accounts=tuple(native),
879
+ assertion_accounts=tuple(asserted),
880
+ observation_count=len(members),
881
+ spend_candidate_count=spend_candidates,
882
+ refusal_codes=tuple(
883
+ code for code in _REFUSAL_ORDER if code in codes),
884
+ op_ids=(),
885
+ ))
886
+ return planned
887
+
888
+
889
+ def _attribute_plan_retractions(conn, *, account_key, since, until):
890
+ """Plan the RETRACT mode, over the durable assertion records (spec §4.1).
891
+
892
+ Retraction cannot use the attribute selector. An assertion is allowed to be
893
+ DORMANT — its witnesses match no current group — and the operator is told to
894
+ clear a dormant or split assertion by retracting it. A dormant assertion has
895
+ no current observation for an observation-range selector to find, so an
896
+ observation-based `--retract` could never reach the one case it most needs
897
+ to reach.
898
+ """
899
+ import datetime as dt
900
+
901
+ import _cctally_cache as _cache_mod
902
+ import _cctally_quota as _quota
903
+
904
+ records = _cache_mod.load_active_window_attributions(conn)
905
+ resolutions, _ownership = _quota.resolve_codex_window_attributions(conn)
906
+ outcome = {str(r.op_id): str(r.outcome) for r in resolutions}
907
+
908
+ planned = []
909
+ for record in records:
910
+ if str(record["account_key"]) != account_key:
911
+ continue
912
+ anchor = _attribute_parse_stored_instant(
913
+ record.get("canonical_resets_at_utc"))
914
+ # The selector is the record's stored assertion-time WINDOW — the
915
+ # nominal `[anchor - window, anchor)` interval — matched by OVERLAP, not
916
+ # its reset instant. Two reasons, and the second is the one that would
917
+ # bite an operator. Attribute mode selects OBSERVATIONS, which sit up to
918
+ # a week before the reset they witness, so an instant match would make
919
+ # the very range that recorded an assertion fail to retract it. And a
920
+ # retraction is a corrective action the operator previews before
921
+ # applying, so reaching one record too many is cheap where reaching one
922
+ # too few is the failure §4.1 exists to prevent.
923
+ #
924
+ # The assertion timestamp is the tiebreak for a record whose window is
925
+ # no longer resolvable at all, which is exactly the record a retraction
926
+ # most needs to be able to reach.
927
+ if anchor is not None:
928
+ start = anchor - dt.timedelta(minutes=int(record["window_minutes"]))
929
+ if not (start < until and since < anchor):
930
+ continue
931
+ else:
932
+ asserted = _attribute_parse_stored_instant(
933
+ record.get("asserted_at_utc"))
934
+ if asserted is None or not (since <= asserted < until):
935
+ continue
936
+ op_id = str(record["op_id"])
937
+ planned.append(_AttributeGroup(
938
+ group_key=("codex", str(record["source_root_key"]),
939
+ str(record["logical_limit_key"]),
940
+ str(record["observed_slot"]),
941
+ int(record["window_minutes"]),
942
+ record.get("canonical_resets_at_utc")),
943
+ source_root_key=str(record["source_root_key"]),
944
+ logical_limit_key=str(record["logical_limit_key"]),
945
+ observed_slot=str(record["observed_slot"]),
946
+ window_minutes=int(record["window_minutes"]),
947
+ canonical_resets_at=record.get("canonical_resets_at_utc"),
948
+ raw_resets=tuple(str(v) for v in record["raw_resets_at_utc"]),
949
+ disposition=outcome.get(op_id, "dormant"),
950
+ native_accounts=(), assertion_accounts=(account_key,),
951
+ observation_count=0, spend_candidate_count=0,
952
+ refusal_codes=(), op_ids=(op_id,),
953
+ ))
954
+ planned.sort(key=lambda group: (str(group.canonical_resets_at or ""),
955
+ group.op_ids[0]))
956
+ return planned
957
+
958
+
959
+ def _attribute_records(planned, *, account_key, mode, at):
960
+ """The journal ops one apply appends — ONE PER GROUP, never a whole range.
961
+
962
+ The journal enforces a 65,536-byte line limit, and per-group lines also make
963
+ a partial append recoverable as a complete prefix.
964
+ """
965
+ import _lib_journal as _lj
966
+
967
+ records = []
968
+ for group in planned:
969
+ # `codex_window_attributions.canonical_resets_at_utc` is nullable per
970
+ # its DDL, and the journal is append-only, so `str(None)` would write
971
+ # the literal "None" into a segment nothing can ever rewrite. The field
972
+ # is audit-only and never matched on, so the record's first raw witness
973
+ # is the honest stand-in; when there is not even one, the builder's
974
+ # non-empty guard rejects the record rather than accepting a placeholder.
975
+ canonical = group.canonical_resets_at
976
+ if not isinstance(canonical, str) or not canonical.strip():
977
+ canonical = next(iter(group.raw_resets or ()), None)
978
+ common = dict(
979
+ account_key=account_key,
980
+ source_root_key=group.source_root_key,
981
+ logical_limit_key=group.logical_limit_key,
982
+ observed_slot=group.observed_slot,
983
+ window_minutes=int(group.window_minutes),
984
+ raw_resets_at_utc=list(group.raw_resets),
985
+ canonical_resets_at_utc=canonical,
986
+ )
987
+ if mode == "retract":
988
+ records.append(_lj.make_codex_window_attribution_retract(
989
+ at=at, retracted_assertion_ids=list(group.op_ids), **common))
990
+ else:
991
+ records.append(_lj.make_codex_window_attribution(at=at, **common))
992
+ return records
993
+
994
+
995
+ def _attribute_plan(conn, *, account_key, mode, since, until):
996
+ if mode == "retract":
997
+ return _attribute_plan_retractions(
998
+ conn, account_key=account_key, since=since, until=until)
999
+ return _attribute_plan_groups(
1000
+ conn, account_key=account_key, since=since, until=until)
1001
+
1002
+
1003
+ def _attribute_status(planned, *, mode, applied: bool) -> str:
1004
+ if not planned:
1005
+ return "empty"
1006
+ if any(group.blocks for group in planned):
1007
+ return "refused"
1008
+ if applied:
1009
+ return "applied"
1010
+ if mode != "retract" and not any(
1011
+ group.disposition == "eligible" for group in planned):
1012
+ # Nothing left to record. That covers both a second identical apply
1013
+ # (every group already asserted) and a range that reached only
1014
+ # out-of-scope windows — neither is a refusal, and reporting the second
1015
+ # as one would tell an operator to fix something no operator can fix.
1016
+ # The summary tells the two apart.
1017
+ return "noop"
1018
+ return "preview"
1019
+
1020
+
1021
+ def _attribute_payload(*, status, mode, account_key, label, since, until,
1022
+ planned, actions, errors, until_specified=True) -> dict:
1023
+ selected = len(planned)
1024
+ eligible = sum(1 for g in planned if g.disposition == "eligible")
1025
+ noop = sum(1 for g in planned if g.disposition == "noop")
1026
+ refused = sum(1 for g in planned if g.disposition == "refused")
1027
+ blocking = sum(1 for g in planned if g.blocks)
1028
+ if mode == "retract":
1029
+ # Every matched record is retractable; the disposition column carries
1030
+ # the record's CURRENT resolution state instead (resolved / dormant /
1031
+ # split / suppressed_*), which is what §4.1 asks the preview to show.
1032
+ eligible, noop = selected, 0
1033
+ return {
1034
+ "status": status,
1035
+ "mode": mode,
1036
+ "source": "codex",
1037
+ "account": ({"accountKey": account_key, "accountLabel": label}
1038
+ if account_key else None),
1039
+ # `until` is the RESOLVED exclusive end, never null on a run that got
1040
+ # far enough to resolve one, so a consumer can reproduce the selection
1041
+ # the command actually made. `untilSpecified` is what preserves the
1042
+ # other fact — whether the operator named that instant or the command
1043
+ # defaulted it to the run's own "now".
1044
+ "selector": {"since": since, "until": until,
1045
+ "untilSpecified": bool(until_specified)},
1046
+ "summary": {
1047
+ "selectedGroups": selected, "eligibleGroups": eligible,
1048
+ "noOpGroups": noop, "refusedGroups": refused,
1049
+ "blockingRefusedGroups": blocking,
1050
+ },
1051
+ "groups": [group.to_json() for group in planned],
1052
+ "actions": dict(actions),
1053
+ "errors": list(errors),
1054
+ }
1055
+
1056
+
1057
+ _ATTRIBUTE_NO_ACTIONS = {
1058
+ "journalOpsAppended": 0, "quotaGroupsUpdated": 0, "spendRowsUpdated": 0,
1059
+ }
1060
+
1061
+
1062
+ #: Refused rows the human render prints before it summarizes the rest — the cap
1063
+ #: `five-hour-blocks` already applies to an unfiltered listing, for the same
1064
+ #: reason. A whole-era range selects 605 groups on the maintainer's store and
1065
+ #: refuses 539 of them as out of scope, and one row each buries the handful
1066
+ #: that actually need a decision.
1067
+ _ATTRIBUTE_REFUSED_ROW_CAP = 50
1068
+
1069
+
1070
+ def _attribute_render(payload, *, requested_apply: bool = False) -> None:
1071
+ mode = payload["mode"]
1072
+ account = payload["account"] or {}
1073
+ selector = payload["selector"]
1074
+ print(
1075
+ f"account attribute ({mode}) — codex / "
1076
+ f"{account.get('accountLabel') or account.get('accountKey') or '-'}")
1077
+ # The resolved instant, annotated when the command supplied it, so this
1078
+ # render and the `--json` envelope describe the same range.
1079
+ until_note = "" if selector.get("untilSpecified", True) else " (now)"
1080
+ print(f"range: [{selector['since']}, {selector['until']}){until_note}")
1081
+ if not payload["groups"]:
1082
+ print("No window group matches the selector; nothing to do.")
1083
+ return
1084
+ headers = (["WINDOW RESET", "SLOT", "OBS", "SPEND", "STATE", "DETAIL"]
1085
+ if mode != "retract" else
1086
+ ["WINDOW RESET", "SLOT", "ASSERTION", "STATE", "DETAIL"])
1087
+ rows = []
1088
+ shown_refused = hidden_refused = 0
1089
+ for group in payload["groups"]:
1090
+ if group["disposition"] == "refused":
1091
+ if shown_refused >= _ATTRIBUTE_REFUSED_ROW_CAP:
1092
+ hidden_refused += 1
1093
+ continue
1094
+ shown_refused += 1
1095
+ window = group["group"]
1096
+ detail = ", ".join(group["refusalCodes"]) or ", ".join(
1097
+ group["nativeAccountKeys"] or group["assertionAccountKeys"])[:16]
1098
+ if mode == "retract":
1099
+ rows.append([
1100
+ str(window["canonicalResetsAtUtc"] or "-"),
1101
+ window["observedSlot"],
1102
+ (group["assertionOpIds"] or ["-"])[0][:16],
1103
+ group["disposition"], detail or "-",
1104
+ ])
1105
+ else:
1106
+ rows.append([
1107
+ str(window["canonicalResetsAtUtc"]), window["observedSlot"],
1108
+ str(group["observationCount"]), str(group["spendCandidateCount"]),
1109
+ group["disposition"], detail or "-",
1110
+ ])
1111
+ print(_render_table(headers, rows))
1112
+ if hidden_refused:
1113
+ print(f"… and {hidden_refused} more refused window(s) not shown "
1114
+ f"(--json lists every one).")
1115
+ summary = payload["summary"]
1116
+ line = (
1117
+ f"{summary['selectedGroups']} selected, "
1118
+ f"{summary['eligibleGroups']} eligible, {summary['noOpGroups']} no-op, "
1119
+ f"{summary['refusedGroups']} refused")
1120
+ blocking = int(summary.get("blockingRefusedGroups") or 0)
1121
+ if blocking:
1122
+ # Without this an operator cannot tell a refusal that stops the run from
1123
+ # an out-of-scope window that was merely skipped, except by reading
1124
+ # every row's codes.
1125
+ line += f" ({blocking} blocking)"
1126
+ print(line)
1127
+ actions = payload["actions"]
1128
+ if payload["status"] in ("applied", "recovered"):
1129
+ print(
1130
+ f"applied: {actions['journalOpsAppended']} journal op(s), "
1131
+ f"{actions['quotaGroupsUpdated']} window group(s), "
1132
+ f"{actions['spendRowsUpdated']} spend row(s)")
1133
+ print(
1134
+ "The percentage axis converges on this run; a projection reached "
1135
+ "from a Codex hook tick instead defers a whole-history pass to the "
1136
+ "detached verifier and lands on a following tick.")
1137
+ elif payload["status"] == "refused":
1138
+ if requested_apply:
1139
+ print("Nothing was written: apply is all-or-nothing and a group "
1140
+ "refused.")
1141
+ else:
1142
+ # A preview was never going to write anything, so reporting that it
1143
+ # did not is no news; what the operator needs is what an apply WOULD
1144
+ # do with this range.
1145
+ print("Nothing would be written: apply is all-or-nothing and a "
1146
+ "group refused. Resolve or exclude it, then re-run with "
1147
+ "--yes.")
1148
+ elif payload["status"] != "empty":
1149
+ print("Preview only — nothing was written. Re-run with --yes to apply.")
1150
+ for error in payload["errors"]:
1151
+ eprint(f"account attribute: {error['message']}")
1152
+
1153
+
1154
+ def _attribute_recovered(previous, current, account_key) -> bool:
1155
+ """Whether the difference between two plans is a `recordedPending` recovery.
1156
+
1157
+ §8.5: if the append succeeds and a later step fails, the journal holds the
1158
+ truth and the derived state does not. On the rerun the tail replay lands the
1159
+ records, so every group this plan wanted to assert now reads as a no-op for
1160
+ exactly this account. That is a RECOVERY, not drift, and it must be
1161
+ recognised before the generic drift check refuses it.
1162
+ """
1163
+ before = {tuple(g.group_key): g for g in previous
1164
+ if g.disposition == "eligible"}
1165
+ if not before:
1166
+ return False
1167
+ after = {tuple(g.group_key): g for g in current}
1168
+ for key, _group in before.items():
1169
+ landed = after.get(key)
1170
+ if landed is None or landed.disposition != "noop":
1171
+ return False
1172
+ if landed.assertion_accounts != (account_key,):
1173
+ return False
1174
+ return True
1175
+
1176
+
1177
+ def _attribute_tail_pending(conn) -> bool:
1178
+ """Whether an earlier run recorded attribution the stats side never got.
1179
+
1180
+ §8.5 promises that a rerun after a failure anywhere past the append
1181
+ "completes the cache and stats steps, reports `recovered`, and exits 0". The
1182
+ window this predicate exists for is the one where the journal append AND the
1183
+ cache transaction both succeeded and the stats step did not: the derived
1184
+ table already carries the assertions, so every group plans as a no-op, and
1185
+ the `--yes` short-circuit would exit 0 reporting `noop` while stats.db still
1186
+ lacked the attribution. Retract mode reaches the same state through `empty`.
1187
+
1188
+ The predicate is the projection certificate's attribution revision against
1189
+ the derived table's live one — the same comparison
1190
+ `load_codex_quota_projection_certificate` already fails closed on (§8.3).
1191
+ A store that has never asserted anything reads 0 on both sides, so an
1192
+ ordinarily idempotent second apply still short-circuits and still never
1193
+ takes the lock set, which is what AC9 pins.
1194
+ """
1195
+ import _cctally_cache as _cache_mod
1196
+ import _cctally_quota as _quota
1197
+
1198
+ try:
1199
+ live = int(_cache_mod.codex_window_attribution_revision(conn))
1200
+ stamped = int(_quota._certificate_attribution_revision(
1201
+ _quota._codex_quota_projection_certificate_payload(conn)))
1202
+ except (sqlite3.DatabaseError, TypeError, ValueError):
1203
+ # A cache too old to carry either value has no attribution to finish.
1204
+ return False
1205
+ return stamped != live
1206
+
1207
+
1208
+ def _attribute_apply(planned, *, account_key, mode, since, until, at,
1209
+ completion=False):
1210
+ """Record the plan, move both axes, and re-project — spec §8.
1211
+
1212
+ Locks, in the one order that preserves the repository lock-order law:
1213
+ stats maintenance exclusive, cache maintenance shared, the journal ingest
1214
+ lock exclusive, the global cache writer flock exclusive, the Codex provider
1215
+ flock exclusive. `release_cache_flocks()` then drops the last two so every
1216
+ cache write is committed and unlocked before the stats transaction opens.
1217
+
1218
+ Returns `(status, actions, errors, planned)`.
1219
+ """
1220
+ import _cctally_cache as _cache_mod
1221
+ import _cctally_journal as _jr
1222
+ import _cctally_quota as _quota
1223
+ import _cctally_rederive as _rd
1224
+
1225
+ actions = dict(_ATTRIBUTE_NO_ACTIONS)
1226
+ appended = False
1227
+ try:
1228
+ with _rd.codex_attribution_apply_locks() as owner:
1229
+ # Opened INSIDE the lock set, which is only safe because the schema
1230
+ # and every pending migration are already applied: `_cmd_account_
1231
+ # attribute` opens the cache for the preview before this runs, and
1232
+ # `apply_db_rederive` does the same before taking the same locks. A
1233
+ # future caller reaching this without that guarantee would run a
1234
+ # migration under five held flocks.
1235
+ conn = _cache_mod.open_cache_db()
1236
+ try:
1237
+ # Reconcile the derived table's tail from the journal FIRST, so
1238
+ # a record this command appended on a previous run and never
1239
+ # materialized is visible to the revalidation below.
1240
+ conn.execute("BEGIN IMMEDIATE")
1241
+ _landed, skipped = _cache_mod.rehydrate_codex_window_attributions(
1242
+ conn)
1243
+ conn.commit()
1244
+ if skipped:
1245
+ _jr._report_window_attribution_skips(skipped)
1246
+
1247
+ fresh = _attribute_plan(
1248
+ conn, account_key=account_key, mode=mode,
1249
+ since=since, until=until)
1250
+ recovered = (
1251
+ mode != "retract"
1252
+ and _attribute_recovered(planned, fresh, account_key))
1253
+ if not recovered:
1254
+ if ([g.fingerprint for g in fresh]
1255
+ != [g.fingerprint for g in planned]):
1256
+ return ("conflict", actions, [{
1257
+ "code": "plan_drift",
1258
+ "message": (
1259
+ "the store changed before the apply lock was "
1260
+ "acquired; rerun `cctally account attribute` "
1261
+ "for a fresh preview"),
1262
+ }], fresh)
1263
+ if any(g.blocks for g in fresh):
1264
+ return ("refused", actions, [], fresh)
1265
+
1266
+ targets = [g for g in fresh if g.disposition == "eligible"] \
1267
+ if mode != "retract" else list(fresh)
1268
+ records = _attribute_records(
1269
+ targets, account_key=account_key, mode=mode, at=at)
1270
+ if records:
1271
+ _jr.append_records(
1272
+ records, expected_high_water=_jr.journal_high_water())
1273
+ appended = True
1274
+ actions["journalOpsAppended"] = len(records)
1275
+
1276
+ conn.execute("BEGIN IMMEDIATE")
1277
+ _landed, skipped = _cache_mod.rehydrate_codex_window_attributions(
1278
+ conn)
1279
+ restored, adopted = (
1280
+ _cache_mod.reconcile_codex_window_attribution_spend(
1281
+ conn, strict=True))
1282
+ # Verify the applied record set against the plan we locked,
1283
+ # BEFORE the commit: a silent partial application must abort the
1284
+ # transaction rather than be reported as success (spec §8.2).
1285
+ active = {
1286
+ str(row["op_id"])
1287
+ for row in _cache_mod.load_active_window_attributions(conn)
1288
+ if str(row["account_key"]) == account_key
1289
+ }
1290
+ expected = {str(rec["id"]) for rec in records}
1291
+ if mode == "retract":
1292
+ # The TARGETED assertion ids, never the retraction op ids.
1293
+ # A retraction inserts no row of its own — it only stamps
1294
+ # `retracted_by_op_id` on the assertions it names — so
1295
+ # intersecting the retraction ids with the active set is
1296
+ # empty by construction and proves nothing, which is not
1297
+ # what §8.2 asks for.
1298
+ targeted = {
1299
+ op_id for group in targets
1300
+ for op_id in (group.op_ids or ())}
1301
+ if targeted & active:
1302
+ raise sqlite3.DatabaseError(
1303
+ "retraction did not tombstone every named assertion")
1304
+ elif not expected <= active:
1305
+ raise sqlite3.DatabaseError(
1306
+ "the attribution records did not all materialize")
1307
+ conn.commit()
1308
+ except BaseException:
1309
+ try:
1310
+ conn.rollback()
1311
+ except sqlite3.Error:
1312
+ pass
1313
+ raise
1314
+ finally:
1315
+ conn.close()
1316
+ owner.release_cache_flocks()
1317
+ if skipped:
1318
+ _jr._report_window_attribution_skips(skipped)
1319
+ actions["spendRowsUpdated"] = int(restored) + int(adopted)
1320
+ actions["quotaGroupsUpdated"] = len(targets)
1321
+ # Still holding stats maintenance and the ingest lock: consume the
1322
+ # appended prefix into stats.db without reacquiring either.
1323
+ _jr.run_stats_ingest(mode="authoritative", locks_held=True)
1324
+ except _rd.RederiveBusy as exc:
1325
+ return ("busy", actions, [{"code": "busy", "message": str(exc)}], planned)
1326
+ except Exception as exc: # noqa: BLE001 - reported, never swallowed
1327
+ if appended:
1328
+ return ("recordedPending", actions, [{
1329
+ "code": "recordedPending",
1330
+ "message": (
1331
+ f"the journal records were appended but a later step "
1332
+ f"failed ({exc}); rerun the same command to finish"),
1333
+ }], planned)
1334
+ return ("error", actions, [
1335
+ {"code": "apply_failed", "message": str(exc)}], planned)
1336
+
1337
+ # Outside the lock set, because this takes its own. No alert-eligible roots
1338
+ # are passed, which is what suppresses historical milestone dispatch while
1339
+ # still re-anchoring terminal state (spec §8.4).
1340
+ #
1341
+ # It opens its own stats transaction over the whole history and can fail on
1342
+ # its own, and by this point the journal append, the cache transaction and
1343
+ # the stats ingest have all succeeded. Uncaught, that failure would reach
1344
+ # the operator as a traceback with no envelope at all, when the state is
1345
+ # exactly the one `recordedPending` describes: recorded, not finished. The
1346
+ # rerun completes it, because the certificate's attribution revision is
1347
+ # still behind the derived table's (`_attribute_tail_pending`).
1348
+ try:
1349
+ _quota.reconcile_codex_quota_projection()
1350
+ except Exception as exc: # noqa: BLE001 - reported, never swallowed
1351
+ return ("recordedPending", actions, [{
1352
+ "code": "recordedPending",
1353
+ "message": (
1354
+ f"the attribution was recorded and applied but the quota "
1355
+ f"projection did not finish ({exc}); rerun the same command "
1356
+ f"to complete it"),
1357
+ }], fresh)
1358
+ status = ("recovered" if ((recovered or completion) and not appended)
1359
+ else "applied")
1360
+ return (status, actions, [], fresh)
1361
+
1362
+
1363
+ def _cmd_account_attribute(args: argparse.Namespace) -> int:
1364
+ """`cctally account attribute` — preview by default, `--yes` applies.
1365
+
1366
+ Exit codes follow the staged-command convention of `db rederive` and
1367
+ `db journal-repair`: 0 for a preview, an empty selection, a no-op and a
1368
+ successful apply; 2 for every validation failure and every refusal; 3 for
1369
+ operational failure, including `recordedPending`.
1370
+ """
1371
+ import _cctally_cache as _cache_mod
1372
+
1373
+ emit_json = bool(getattr(args, "emit_json", False))
1374
+ mode = "retract" if getattr(args, "retract", False) else "attribute"
1375
+ now = _cctally_core._command_as_of()
1376
+ since_text = str(getattr(args, "since", "") or "")
1377
+ until_text = getattr(args, "until", None)
1378
+
1379
+ try:
1380
+ since = _attribute_parse_instant(since_text, "--since")
1381
+ until = (_attribute_parse_instant(until_text, "--until")
1382
+ if until_text else now)
1383
+ if until <= since:
1384
+ raise _AttributeUsage(
1385
+ "account attribute: --until must be strictly after --since; "
1386
+ "the range is half-open [since, until)")
1387
+ conn = _cctally_core.open_db()
1388
+ try:
1389
+ account_key = _attribute_resolve_account(
1390
+ conn, getattr(args, "ref", None))
1391
+ label = display_account_label(conn, account_key)
1392
+ finally:
1393
+ conn.close()
1394
+ except _AttributeUsage as exc:
1395
+ message = str(exc)
1396
+ if message != "unresolvable ref":
1397
+ eprint(message)
1398
+ if emit_json:
1399
+ print(json.dumps(_cctally().stamp_schema_version(
1400
+ _attribute_payload(
1401
+ status="error", mode=mode, account_key=None, label=None,
1402
+ since=since_text,
1403
+ until=(str(until_text) if until_text else None),
1404
+ until_specified=bool(until_text),
1405
+ planned=[], actions=_ATTRIBUTE_NO_ACTIONS,
1406
+ errors=[{"code": "usage", "message": message}]))))
1407
+ return 2
1408
+
1409
+ since_iso = since.isoformat().replace("+00:00", "Z")
1410
+ # Always the RESOLVED end, so a `--json` consumer can reproduce the exact
1411
+ # selection; `untilSpecified` carries whether the operator named it.
1412
+ until_iso = until.isoformat().replace("+00:00", "Z")
1413
+
1414
+ requested_apply = bool(getattr(args, "yes", False))
1415
+ cache = _cache_mod.open_cache_db()
1416
+ try:
1417
+ planned = _attribute_plan(
1418
+ cache, account_key=account_key, mode=mode, since=since, until=until)
1419
+ completion = requested_apply and _attribute_tail_pending(cache)
1420
+ finally:
1421
+ cache.close()
1422
+
1423
+ actions = dict(_ATTRIBUTE_NO_ACTIONS)
1424
+ errors: list = []
1425
+ status = _attribute_status(planned, mode=mode, applied=False)
1426
+ # An apply with nothing to record short-circuits BEFORE the lock set. That
1427
+ # is what makes a second identical apply append zero journal lines and
1428
+ # leave the high-water unchanged (spec §8.5) rather than taking five flocks
1429
+ # to discover the same thing.
1430
+ #
1431
+ # `completion` is the one exception, and it is what keeps §8.5's recovery
1432
+ # promise honest: a run whose append and cache transaction landed and whose
1433
+ # stats step did not plans every group as a no-op, so the short-circuit
1434
+ # alone would report `noop`, exit 0, and leave the attribution missing from
1435
+ # stats.db forever.
1436
+ if requested_apply and status != "refused" and (
1437
+ status == "preview" or completion):
1438
+ at = now.isoformat(timespec="microseconds").replace("+00:00", "Z")
1439
+ status, actions, errors, planned = _attribute_apply(
1440
+ planned, account_key=account_key, mode=mode,
1441
+ since=since, until=until, at=at, completion=completion)
1442
+
1443
+ payload = _attribute_payload(
1444
+ status=status, mode=mode, account_key=account_key, label=label,
1445
+ since=since_iso, until=until_iso, until_specified=bool(until_text),
1446
+ planned=planned, actions=actions, errors=errors)
1447
+ if emit_json:
1448
+ print(json.dumps(_cctally().stamp_schema_version(payload)))
1449
+ else:
1450
+ _attribute_render(payload, requested_apply=requested_apply)
1451
+ if status in ("refused", "conflict"):
1452
+ return 2
1453
+ if status in ("recordedPending", "error", "busy"):
1454
+ return 3
1455
+ return 0