@andresmassello/uscha 1.93.0 → 1.94.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.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
40
40
  runtime dependencies). The npm package is a thin router; the canonical installer is
41
41
  `uscha-kit/install-uscha.py`.
42
42
 
43
- **Kit v1.93.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.94.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
44
  [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
45
45
  (the per-release changelogs live in the repo, not in the npm tarball)
46
46
 
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
85
85
  from the compiled code: 0.828 measured (12 archetypes) — names AND behaviour
86
86
  ```
87
87
 
88
- **What each arrow is, in the engine (kit 1.93.0, 53 subcommands, all measured):**
88
+ **What each arrow is, in the engine (kit 1.94.0, 53 subcommands, all measured):**
89
89
 
90
90
  | Leg | Subcommands | What it establishes |
91
91
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.93.0",
3
+ "version": "1.94.0",
4
4
  "description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -112,21 +112,45 @@ it's already fully answered. Keep a running list of OPEN GAPS and resolved decis
112
112
  2. **Implicit decisions.** Surface the choices the request assumed: sync vs async,
113
113
  storage, protocol, idempotency, transactional boundaries, who owns state. For each,
114
114
  force an explicit decision and at least one considered alternative.
115
- 3. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
115
+ 3. **Stack and lifecycle (MANDATORY before any stack/architecture decision is
116
+ recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
117
+ DATE. A major version is a family; support is granted to a MINOR line, for a window,
118
+ by an upstream nobody in the room controls. Ask these one at a time, each with your
119
+ recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
120
+ answer from memory**; record the URL and the day you checked:
121
+ (a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
122
+ Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
123
+ (b) support window vs the declared go-live AND the expected operating life — does the
124
+ line stay patched for the whole operation? If not, move it up BEFORE building: a major
125
+ upgrade days before the milestone is the most expensive one there is;
126
+ (c) major dependencies and the upgrade policy — who approves one, and when it is
127
+ scheduled (aligned with the dev-loop's "zero new dependencies without approval");
128
+ (d) development and observability tools the operator wants from day one (consoles, APM,
129
+ admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
130
+ (e) compatibility with reused legacy modules — the minimum version they support.
131
+ The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
132
+ `lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
133
+ ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
134
+ `templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
135
+ compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
136
+ `qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
137
+ `no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
138
+ was CITED; it cannot see whether the citation is TRUE. That part is yours.
139
+ 4. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
116
140
  retries and backoff, 4xx vs 5xx, concurrency, partial/terminal states, what must NOT
117
141
  happen. A feature without its failure behavior is half-specified.
118
- 4. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
142
+ 5. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
119
143
  rules that cannot be broken (money to the cent, numbering without gaps, never cross
120
144
  environments/credentials, secrets never logged, auth/authz, data retention). Write/extend
121
145
  `CONSTITUTION.md` (one invariant per line, CWE ref where it maps); these feed the
122
146
  dev-loop severity gate and a breach is a BLOCKER. **An ADR may never contradict the
123
147
  CONSTITUTION** — if a decision would, it's escalated, not recorded.
124
- 5. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
148
+ 6. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
125
149
  spec"). What you exclude is as important as what you include.
126
- 6. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
150
+ 7. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
127
151
  (tests green, documented, metrics published, runbook) AND success metrics (p95,
128
152
  cost ceiling, zero orphaned records). Each item must be verifiable, not a feeling.
129
- 7. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
153
+ 8. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
130
154
 
131
155
  After each batch, reflect: "Decided: … / Still open: …". Move on only when the
132
156
  current topic is closed.
@@ -146,7 +170,7 @@ have behavior, the scope has a boundary and the DoD is verifiable.") before Phas
146
170
  ## Phase B — Distill the artifacts
147
171
 
148
172
  Only after convergence. Produce (and, if any new project-wide invariant surfaced in
149
- step 4, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
173
+ step 5, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
150
174
  ADR where it could later be "traded away"):
151
175
 
152
176
  1. **One ADR per decision worth recording** at `docs/adr/ADR-NNN-<slug>.md`, format:
@@ -780,7 +780,9 @@ def _content_state(repo_path, repo_type, last_snapshot):
780
780
  # verdict, or a report the record itself marked stale, is no anchor at all.
781
781
  if ((snap.get("tests") or {}).get("freshness") or {}).get("status") == "stale":
782
782
  return None
783
- hashes = {r["path"]: r["sha256"]
783
+ # the RECORD, not just the hash string: `_evidence_hash_matches` needs the `sha256_eol`
784
+ # marker beside it to know whether the compatibility comparison is still on offer
785
+ hashes = {r["path"]: r
784
786
  for r in ((snap.get("tests") or {}).get("reports") or [])
785
787
  if isinstance(r, dict) and r.get("path") and r.get("sha256")
786
788
  and r.get("fresh_by") != "stale"}
@@ -814,7 +816,7 @@ def _report_fresh(repo_path, report_path, clock_fresh, content_state):
814
816
  # cannot arise here, while normalizing one side would stop matching the recorded key.
815
817
  rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
816
818
  recorded = content_state["hashes"].get(rel)
817
- if not recorded or _sha256_file(report_path) != recorded:
819
+ if _evidence_hash_matches(report_path, recorded) is not True:
818
820
  return None
819
821
  return "content"
820
822
 
@@ -842,7 +844,10 @@ def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
842
844
  "mtime_ns": mtime_ns,
843
845
  "mtime": datetime.fromtimestamp(
844
846
  mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
845
- "sha256": _sha256_file(path),
847
+ "sha256": _sha256_evidence(path),
848
+ # the marker says HOW the hash above was taken, so a reader never has to guess
849
+ # (and 1.93.0-and-older records, which have no marker, keep their compatibility)
850
+ "sha256_eol": "lf",
846
851
  })
847
852
  if not reports:
848
853
  status = "not-applicable" if repo_type == "flutter" else "missing"
@@ -5304,6 +5309,59 @@ def _sha256_file(path):
5304
5309
  return None
5305
5310
 
5306
5311
 
5312
+ # kit 1.93.1: the hash of a TEXT evidence file, EOL-NORMALIZED (CRLF -> LF before hashing).
5313
+ #
5314
+ # A JUnit report is text, and a version control system is allowed to rewrite its line endings
5315
+ # on checkout -- `* text=auto eol=lf` is the recommended `.gitattributes` and the kit's own.
5316
+ # The suite that produced the report on Windows wrote CRLF, the repository stores LF, and a
5317
+ # clean checkout therefore yields a file that is byte-different and semantically identical.
5318
+ # The exact-byte hash read that as `evidence altered after ingest`: 1.93.0 shipped with the
5319
+ # limit merely NAMED in SPEC 4, and the release machine's own board hit it the same day.
5320
+ # Normalizing the ONE difference git is allowed to introduce keeps the guarantee that matters
5321
+ # -- any other changed byte (a swapped log, an edited count, a different run) still fails.
5322
+ #
5323
+ # `_sha256_file` is deliberately left alone: compile-validate hashes MANIFEST UNITS, where the
5324
+ # exact bytes ARE the claim, and a manifest that tolerated a rewrite would be tolerating the
5325
+ # thing it exists to detect.
5326
+ def _sha256_evidence(path):
5327
+ try:
5328
+ with open(path, "rb") as fh:
5329
+ data = fh.read()
5330
+ except OSError:
5331
+ return None
5332
+ return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
5333
+
5334
+
5335
+ def _evidence_hash_matches(path, record):
5336
+ """Does the file on disk still hash to what the RECORD carries? True / False, or None when
5337
+ the record carries no hash at all (the caller decides: UNMEASURED, never a pass).
5338
+
5339
+ Records written by 1.93.1 and later carry `sha256_eol: "lf"` and are compared NORMALIZED,
5340
+ full stop. A record written BEFORE that marker hashed the file's exact bytes, whatever
5341
+ line endings the machine that ran the suite happened to write -- and 1.93.0's own release
5342
+ record is the proof: the suite wrote CRLF on Windows, git stored LF, and the LF checkout
5343
+ matched neither the recorded hash nor its normalized form, because the recording was of the
5344
+ CRLF RENDERING of the same text. So a pre-marker record is compared against all three
5345
+ renderings of the bytes on disk: normalized, exact, and CRLF. The door is narrow on purpose
5346
+ -- it opens only for records that predate the marker, and it admits only the line-ending
5347
+ renderings of THIS file's text: a report whose content really changed matches none of them."""
5348
+ want = record.get("sha256") if isinstance(record, dict) else None
5349
+ if not want:
5350
+ return None
5351
+ if _sha256_evidence(path) == want:
5352
+ return True
5353
+ if record.get("sha256_eol") == "lf":
5354
+ return False
5355
+ try:
5356
+ with open(path, "rb") as fh:
5357
+ data = fh.read()
5358
+ except OSError:
5359
+ return False
5360
+ lf = data.replace(b"\r\n", b"\n")
5361
+ return want in (hashlib.sha256(data).hexdigest(),
5362
+ hashlib.sha256(lf.replace(b"\n", b"\r\n")).hexdigest())
5363
+
5364
+
5307
5365
  def _contained_unit(base, unit):
5308
5366
  """A compilation's units must be RELATIVE paths CONTAINED within the compilation
5309
5367
  directory: the manifest references what was compiled, and what was compiled lives with
@@ -8348,6 +8406,10 @@ def cmd_dashboard(args):
8348
8406
  for r in {e.get("repo") for e in ledger["fast_path"]}}
8349
8407
  if ledger.get("spec_drift"):
8350
8408
  out["spec_drift"] = ledger["spec_drift"]
8409
+ # lifecycle (ADR-040): reused VERBATIM from the readiness payload above (one read, no
8410
+ # drift possible between the two surfaces), conditional for the same reason spec_drift is.
8411
+ if rd.get("lifecycle"):
8412
+ out["lifecycle"] = rd["lifecycle"]
8351
8413
  # evidence_origin: the latest snapshot's origin per repo, and ONLY when one exists --
8352
8414
  # a ledger predating ADR-007 keeps the exact prior schema (same conditional-key rule
8353
8415
  # fast_path and spec_drift already follow).
@@ -8989,7 +9051,7 @@ def _sealed_state(ledger, ledger_path):
8989
9051
  elif not r.get("sha256"):
8990
9052
  unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
8991
9053
  "(older snapshot, or the file was unreadable)" % rel)
8992
- elif _sha256_file(full) != r["sha256"]:
9054
+ elif _evidence_hash_matches(full, r) is not True:
8993
9055
  failures.append("evidence altered after ingest: %s" % rel)
8994
9056
 
8995
9057
  out["reasons"] = failures + unmeasured
@@ -9496,6 +9558,12 @@ def cmd_readiness(args):
9496
9558
  "gates": _gate_rollup(ledger),
9497
9559
  "by_repo": repos,
9498
9560
  }
9561
+ # lifecycle (ADR-040): advisory, and CONDITIONAL like fast_path/spec_drift -- a project
9562
+ # that declares no lifecycle: block keeps the exact prior payload and the exact prior
9563
+ # text. Speaking only when it matters is the anti-ceremony rule applied to itself.
9564
+ _lc = _lifecycle_for(os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd())
9565
+ if _lc["declared"]:
9566
+ out["lifecycle"] = _lc
9499
9567
  if args.json:
9500
9568
  print(json.dumps(out, indent=2, ensure_ascii=False))
9501
9569
  return
@@ -9576,6 +9644,8 @@ def cmd_readiness(args):
9576
9644
  f"rising for {STALL_WINDOW} cycles: iterating more is not getting "
9577
9645
  f"closer. Likely a design/SPEC problem — go back to the ADR / "
9578
9646
  f"re-plan with the human (advisory)")
9647
+ if _lc["declared"]:
9648
+ print(" · " + _lifecycle_summary(_lc))
9579
9649
  # rubrica (1.23.0): el ultimo grade por repo, siempre visible — guess
9580
9650
  # estructurado que aconseja; si esta gateado ya bloqueo por el ledger
9581
9651
  for rname, rnode in ledger["repos"].items():
@@ -10928,6 +10998,225 @@ _SC_SETEXT = re.compile(r"^(=+|-{2,})\s*$")
10928
10998
  _SC_CHECKBOX = re.compile(r"^\[[ xX]\]\s*")
10929
10999
 
10930
11000
 
11001
+ # --------------------------------------------------------------------------- #
11002
+ # lifecycle (ADR-040: stack end-of-support vs go-live -- advisory, NEVER gates)
11003
+ # --------------------------------------------------------------------------- #
11004
+ # The method interrogates WHAT and WHY and has always treated the stack as a given
11005
+ # ("we use X"). A major line is not a decision: a minor line has an END-OF-SUPPORT
11006
+ # DATE, and a date that falls before the declared go-live is a forced upgrade nobody
11007
+ # planned. This dimension reads what the ADR CITES and compares it against the SPEC's
11008
+ # declared go-live. It cannot verify that the cited source tells the truth -- only that
11009
+ # a date and a source were cited at all. Advisory by construction, like spec-drift
11010
+ # (ADR-005): it never gates, never caps readiness, and never changes an exit code.
11011
+ _LC_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
11012
+
11013
+
11014
+ def _lc_valid_date(s):
11015
+ """A cited date is real only if it is BOTH shaped YYYY-MM-DD and a calendar date:
11016
+ the format regex alone accepts 2025-13-40 (1.94.0 review), which then string-compares
11017
+ as if it were a real EOL. fromisoformat rejects the impossible month/day."""
11018
+ if not s or not _LC_DATE.match(s):
11019
+ return False
11020
+ try:
11021
+ datetime.strptime(s, "%Y-%m-%d")
11022
+ return True
11023
+ except ValueError:
11024
+ return False
11025
+ _LC_KEY = re.compile(r"^lifecycle\s*:\s*(.*)$")
11026
+ _LC_GOLIVE_FM = re.compile(r"^go[-_ ]?live\s*:\s*(.+?)\s*$", re.I)
11027
+ _LC_GOLIVE_LINE = re.compile(r"^\s*\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
11028
+ r"(\d{4}-\d{2}-\d{2})\b", re.I)
11029
+ _LC_FIELDS = ("component", "version", "eol", "source", "checked")
11030
+
11031
+
11032
+ def _lc_frontmatter(lines):
11033
+ """The lines INSIDE a leading `---` frontmatter block, or None when there is none.
11034
+ An unterminated fence is not data: a half-written block reads as absent."""
11035
+ if not lines or lines[0].strip() != "---":
11036
+ return None
11037
+ body = []
11038
+ for ln in lines[1:]:
11039
+ if ln.strip() == "---":
11040
+ return body
11041
+ body.append(ln)
11042
+ return None
11043
+
11044
+
11045
+ def _lc_parse(lines):
11046
+ """The `lifecycle:` list-of-dicts of a markdown file, parsed as a YAML SUBSET --
11047
+ stdlib only, the kit ships no PyYAML. Returns None when the key is absent (nothing to
11048
+ measure) and [] when the key is there but nothing under it parses (a NAMED absence:
11049
+ the caller reports UNMEASURED with a reason, never a silent pass)."""
11050
+ fm = _lc_frontmatter(lines)
11051
+ if fm is None:
11052
+ return None
11053
+ entries, in_lc, cur = None, False, None
11054
+ for ln in fm:
11055
+ s = ln.strip()
11056
+ if not in_lc:
11057
+ m = _LC_KEY.match(ln)
11058
+ if m:
11059
+ entries = []
11060
+ # only the block form is supported; an inline `lifecycle: [...]` parses to
11061
+ # nothing and reads as unparseable, never as "this ADR fixes no component"
11062
+ in_lc = not m.group(1).strip()
11063
+ continue
11064
+ if not s or s.startswith("#"):
11065
+ continue
11066
+ if not ln[:1].isspace(): # back to column 0 -> the key ended
11067
+ in_lc = False
11068
+ continue
11069
+ if s.startswith("- "):
11070
+ cur = {}
11071
+ entries.append(cur)
11072
+ s = s[2:].strip()
11073
+ if not s:
11074
+ continue
11075
+ if cur is None:
11076
+ continue
11077
+ k, sep, v = s.partition(":")
11078
+ if not sep:
11079
+ continue
11080
+ k = k.strip().lower()
11081
+ if k in _LC_FIELDS:
11082
+ cur[k] = v.strip().strip("\x27\x22")
11083
+ if entries is not None:
11084
+ entries = [e for e in entries if e]
11085
+ return entries
11086
+
11087
+
11088
+ def _lc_go_live(text):
11089
+ """The declared go-live of a SPEC: frontmatter `go_live: YYYY-MM-DD`, or a
11090
+ `**Go-live:** YYYY-MM-DD` line anywhere in the body. None = not declared."""
11091
+ lines = [ln.rstrip("\r") for ln in (text or "").split("\n")]
11092
+ fm = _lc_frontmatter(lines)
11093
+ if fm:
11094
+ for ln in fm:
11095
+ if ln[:1].isspace():
11096
+ continue
11097
+ m = _LC_GOLIVE_FM.match(ln.strip())
11098
+ if m:
11099
+ v = m.group(1).strip().strip("\x27\x22")
11100
+ if _lc_valid_date(v):
11101
+ return v
11102
+ for ln in lines:
11103
+ m = _LC_GOLIVE_LINE.match(ln)
11104
+ if m and _lc_valid_date(m.group(1)):
11105
+ return m.group(1)
11106
+ return None
11107
+
11108
+
11109
+ def _lc_short(path):
11110
+ """The ADR dir as the reader would type it: relative to the cwd when that is shorter,
11111
+ absolute otherwise (a different drive on Windows has no relative form)."""
11112
+ if not path:
11113
+ return "docs/adr"
11114
+ try:
11115
+ rel = os.path.relpath(path)
11116
+ except ValueError:
11117
+ return path
11118
+ return rel if len(rel) < len(path) else path
11119
+
11120
+
11121
+ def _lifecycle_report(adr_dir, spec_text):
11122
+ """Per-component verdicts for every `lifecycle:` entry under `adr_dir`, compared
11123
+ against the go-live declared in `spec_text`. Whole-dimension UNMEASURED -- with the
11124
+ reason spelled out, never silence -- when no ADR carries the block, when the block is
11125
+ unparseable, or when no go-live is declared."""
11126
+ go_live = _lc_go_live(spec_text)
11127
+ comps, unparsed, names = [], [], []
11128
+ if adr_dir and os.path.isdir(adr_dir):
11129
+ names = sorted(f for f in os.listdir(adr_dir) if f.lower().endswith(".md"))
11130
+ for fn in names:
11131
+ try:
11132
+ with open(os.path.join(adr_dir, fn), "r", encoding="utf-8",
11133
+ errors="replace") as fh:
11134
+ lines = [ln.rstrip("\r") for ln in fh.read().split("\n")]
11135
+ except OSError:
11136
+ continue
11137
+ ent = _lc_parse(lines)
11138
+ if ent is None:
11139
+ continue
11140
+ if not ent:
11141
+ unparsed.append(fn)
11142
+ continue
11143
+ for e in ent:
11144
+ comps.append({"adr": fn, "component": e.get("component") or "(unnamed)",
11145
+ "version": e.get("version"), "eol": e.get("eol"),
11146
+ "source": e.get("source"), "checked": e.get("checked")})
11147
+ out = {"status": "UNMEASURED", "reason": None, "go_live": go_live,
11148
+ "adr_dir": adr_dir, "declared": bool(comps or unparsed),
11149
+ "components": comps, "n_components": len(comps), "n_expiring": 0,
11150
+ "unparsed_adrs": unparsed}
11151
+ if not comps:
11152
+ out["reason"] = ("lifecycle: block declared but unparseable in: "
11153
+ + ", ".join(unparsed) if unparsed else
11154
+ "no ADR under %s carries a lifecycle: block"
11155
+ % _lc_short(adr_dir))
11156
+ return out
11157
+ if not go_live:
11158
+ for c in comps:
11159
+ c["status"], c["detail"] = "unmeasured", "no go-live declared"
11160
+ out["reason"] = ("no go-live declared in the SPEC (frontmatter 'go_live: "
11161
+ "YYYY-MM-DD' or a '**Go-live:** YYYY-MM-DD' line)")
11162
+ return out
11163
+ for c in comps:
11164
+ eol = (c.get("eol") or "").strip()
11165
+ cited = (c.get("source") or "").strip()
11166
+ if not eol or eol.lower() == "unknown" or not _lc_valid_date(eol):
11167
+ c["status"] = "no EOL cited"
11168
+ c["detail"] = ("declared unknown" if eol.lower() == "unknown"
11169
+ else ("not a YYYY-MM-DD date: %s" % eol if eol else "absent"))
11170
+ elif eol < go_live:
11171
+ # the sharper fact wins the label: a date that already expires is reported as
11172
+ # expiring even when uncited, and the missing source stays visible in the record
11173
+ c["status"] = "expires before go-live"
11174
+ c["detail"] = "%s < %s" % (eol, go_live)
11175
+ out["n_expiring"] += 1
11176
+ elif not cited:
11177
+ c["status"] = "no source cited"
11178
+ c["detail"] = "eol %s is asserted, not cited" % eol
11179
+ else:
11180
+ c["status"] = "ok"
11181
+ c["detail"] = "eol %s >= go-live %s" % (eol, go_live)
11182
+ out["status"] = "measured"
11183
+ return out
11184
+
11185
+
11186
+ def _lifecycle_summary(lc):
11187
+ """The one advisory line, shared by spec-check and readiness so the two surfaces
11188
+ cannot drift apart."""
11189
+ if lc["status"] != "measured":
11190
+ return "lifecycle: UNMEASURED - %s" % lc["reason"]
11191
+ return ("lifecycle: %d component(s), %d expire before go-live %s (advisory)"
11192
+ % (lc["n_components"], lc["n_expiring"], lc["go_live"]))
11193
+
11194
+
11195
+ def _lifecycle_root(*paths):
11196
+ """The project root a lifecycle read hangs off: the directory of the first path
11197
+ given, else the cwd."""
11198
+ for p in paths:
11199
+ if p:
11200
+ return os.path.dirname(os.path.abspath(p)) or os.getcwd()
11201
+ return os.getcwd()
11202
+
11203
+
11204
+ def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
11205
+ """Read the dimension for a project root: `docs/adr` unless overridden, and the
11206
+ go-live from the text already in hand. `fallback` reads `<root>/SPEC.md` when no
11207
+ text was supplied -- it is OFF when the caller named the spec files itself, so an
11208
+ explicit `--spec` is never silently topped up from a file nobody asked for."""
11209
+ if fallback and not _lc_go_live(spec_text):
11210
+ sp = os.path.join(root, "SPEC.md")
11211
+ if os.path.isfile(sp):
11212
+ try:
11213
+ with open(sp, "r", encoding="utf-8", errors="replace") as fh:
11214
+ spec_text = (spec_text or "") + "\n" + fh.read()
11215
+ except OSError:
11216
+ pass
11217
+ return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
11218
+
11219
+
10931
11220
  def _spec_check_text(text):
10932
11221
  lines = text.split("\n")
10933
11222
  n = len(lines)
@@ -11222,6 +11511,11 @@ def cmd_spec_check(args):
11222
11511
  m = (_spec_check_text(text) if text.strip()
11223
11512
  else {"blockers": [], "untestable": [], "stack_hits": [],
11224
11513
  "non_ears": 0, "n_criteria": 0})
11514
+ # lifecycle (ADR-040): read-only and advisory -- it never touches `fail` below.
11515
+ lc = _lifecycle_for(_lifecycle_root(args.spec[0] if args.spec else None,
11516
+ args.acceptance),
11517
+ getattr(args, "adr_dir", None), text,
11518
+ fallback=not args.spec)
11225
11519
  structural = len(m["blockers"]) + len(acc_block) # estructura = FACT -> bloquea
11226
11520
  soft_find = len(m["untestable"]) + len(m["stack_hits"]) + len(acc_adv)
11227
11521
  fail = structural > 0 or (args.strict and soft_find > 0)
@@ -11230,7 +11524,7 @@ def cmd_spec_check(args):
11230
11524
  if args.json:
11231
11525
  print(json.dumps({"verdict": verdict, "advisory": structural == 0,
11232
11526
  "acceptance_blockers": acc_block,
11233
- "acceptance_advisory": acc_adv, **m},
11527
+ "acceptance_advisory": acc_adv, "lifecycle": lc, **m},
11234
11528
  indent=2, ensure_ascii=False))
11235
11529
  sys.exit(1 if fail else 0)
11236
11530
 
@@ -11251,6 +11545,11 @@ def cmd_spec_check(args):
11251
11545
  print(f" ~ stack named in criteria ({len(m['stack_hits'])}): {', '.join(terms[:6])} — the 'how' belongs in the ADR, not the SPEC")
11252
11546
  if m["non_ears"] and m["n_criteria"]:
11253
11547
  print(f" · advisory: {m['non_ears']}/{m['n_criteria']} criteria do not follow the EARS pattern (When/If/While … shall)")
11548
+ print(" · " + _lifecycle_summary(lc))
11549
+ for c in [x for x in lc["components"] if x.get("status") not in ("ok", None)][:5]:
11550
+ print(" %s %s %s - %s (%s)"
11551
+ % ("!" if c["status"] == "expires before go-live" else "~",
11552
+ c["component"], c.get("version") or "?", c["status"], c["detail"]))
11254
11553
  print(" i consistency: INFERENTIAL (an uncorrelated checker), not this lint · "
11255
11554
  "structure = FACT (blocks) · prose = advisory (--strict to gate)")
11256
11555
  if verdict == "OK":
@@ -12471,6 +12770,9 @@ def build_parser():
12471
12770
  help="validate ACCEPTANCE traceability (AC-n IDs): missing "
12472
12771
  "file / zero criteria / zero traceable / duplicate "
12473
12772
  "IDs block as structural FACTS")
12773
+ psc.add_argument("--adr-dir", default=None,
12774
+ help="ADRs whose lifecycle: blocks are read (ADR-040); default "
12775
+ "docs/adr beside the SPEC")
12474
12776
  psc.add_argument("--strict", action="store_true",
12475
12777
  help="also fail (exit 1) on soft findings: untestable criteria / stack named")
12476
12778
  psc.add_argument("--json", action="store_true")
@@ -137,25 +137,49 @@ answer.
137
137
  I deduce these core entities: … do they work for you, or is one missing?")
138
138
  3. **Operation / API surface.** Propose the endpoints/operations and their
139
139
  contracts (idempotency, status codes).
140
- 4. **Big decisions ( ADR).** Propose 2–3 architecture options with trade-offs and
140
+ 4. **Stack and lifecycle (MANDATORY before any stack/architecture decision is
141
+ recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
142
+ DATE. A major version is a family; support is granted to a MINOR line, for a window,
143
+ by an upstream nobody in the room controls. Ask these one at a time, each with your
144
+ recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
145
+ answer from memory**; record the URL and the day you checked:
146
+ (a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
147
+ Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
148
+ (b) support window vs the declared go-live AND the expected operating life — does the
149
+ line stay patched for the whole operation? If not, move it up BEFORE building: a major
150
+ upgrade days before the milestone is the most expensive one there is;
151
+ (c) major dependencies and the upgrade policy — who approves one, and when it is
152
+ scheduled (aligned with the dev-loop's "zero new dependencies without approval");
153
+ (d) development and observability tools the operator wants from day one (consoles, APM,
154
+ admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
155
+ (e) compatibility with reused legacy modules — the minimum version they support.
156
+ The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
157
+ `lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
158
+ ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
159
+ `templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
160
+ compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
161
+ `qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
162
+ `no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
163
+ was CITED; it cannot see whether the citation is TRUE. That part is yours.
164
+ 5. **Big decisions (→ ADR).** Propose 2–3 architecture options with trade-offs and
141
165
  a recommended default: persistence, protocol, idempotency, sync/async, multi-tenancy.
142
- 5. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
166
+ 6. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
143
167
  concurrency, what must NOT happen.
144
- 6. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
168
+ 7. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
145
169
  rules that can't be broken (money to the cent, no numbering gaps, never cross
146
170
  environments, secrets never logged, auth). Write/extend `CONSTITUTION.md` with these —
147
171
  one invariant per line, with a CWE reference where it maps. They feed the severity gate
148
172
  downstream, and a breach is a BLOCKER, never a trade-off.
149
- 7. **Out of scope.** Explicit boundaries with forward references.
150
- 8. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
151
- 9. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
173
+ 8. **Out of scope.** Explicit boundaries with forward references.
174
+ 9. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
175
+ 10. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
152
176
  dimensions are negotiable (coverage, perf, security)?" Propose thresholds fit for
153
177
  the risk profile (a payments core is not an internal dashboard). What the human
154
178
  declares goes into `uscha.config.json` (`defaults.coverage_threshold`,
155
179
  `defaults.readiness_caps`, `defaults.simplicity`) — a declared threshold reads as
156
180
  **requerimiento (config)** in the engine's output; an undeclared one stays a kit
157
181
  default (opinion) and is labeled as such. Declaring is committing the config.
158
- 10. **Residual risks and dependencies.** What's uncertain, what must exist first.
182
+ 11. **Residual risks and dependencies.** What's uncertain, what must exist first.
159
183
  For each HIGH-uncertainty risk, ask (kit 1.19.0, Tip 21 'Prototype to Learn'):
160
184
  "Does it warrant a time-boxed spike before freezing the SPEC?" A spike runs on a
161
185
  `spike/*` branch and its ONLY legitimate output is an **ADR with lessons**
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.93.0",
4
+ "version": "1.94.0",
5
5
  "displayName": "Uscha",
6
6
  "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.93.0",
3
+ "version": "1.94.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.93.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.94.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.93.0
1
+ uscha-kit 1.94.0
@@ -112,21 +112,45 @@ it's already fully answered. Keep a running list of OPEN GAPS and resolved decis
112
112
  2. **Implicit decisions.** Surface the choices the request assumed: sync vs async,
113
113
  storage, protocol, idempotency, transactional boundaries, who owns state. For each,
114
114
  force an explicit decision and at least one considered alternative.
115
- 3. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
115
+ 3. **Stack and lifecycle (MANDATORY before any stack/architecture decision is
116
+ recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
117
+ DATE. A major version is a family; support is granted to a MINOR line, for a window,
118
+ by an upstream nobody in the room controls. Ask these one at a time, each with your
119
+ recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
120
+ answer from memory**; record the URL and the day you checked:
121
+ (a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
122
+ Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
123
+ (b) support window vs the declared go-live AND the expected operating life — does the
124
+ line stay patched for the whole operation? If not, move it up BEFORE building: a major
125
+ upgrade days before the milestone is the most expensive one there is;
126
+ (c) major dependencies and the upgrade policy — who approves one, and when it is
127
+ scheduled (aligned with the dev-loop's "zero new dependencies without approval");
128
+ (d) development and observability tools the operator wants from day one (consoles, APM,
129
+ admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
130
+ (e) compatibility with reused legacy modules — the minimum version they support.
131
+ The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
132
+ `lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
133
+ ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
134
+ `templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
135
+ compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
136
+ `qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
137
+ `no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
138
+ was CITED; it cannot see whether the citation is TRUE. That part is yours.
139
+ 4. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
116
140
  retries and backoff, 4xx vs 5xx, concurrency, partial/terminal states, what must NOT
117
141
  happen. A feature without its failure behavior is half-specified.
118
- 4. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
142
+ 5. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
119
143
  rules that cannot be broken (money to the cent, numbering without gaps, never cross
120
144
  environments/credentials, secrets never logged, auth/authz, data retention). Write/extend
121
145
  `CONSTITUTION.md` (one invariant per line, CWE ref where it maps); these feed the
122
146
  dev-loop severity gate and a breach is a BLOCKER. **An ADR may never contradict the
123
147
  CONSTITUTION** — if a decision would, it's escalated, not recorded.
124
- 5. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
148
+ 6. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
125
149
  spec"). What you exclude is as important as what you include.
126
- 6. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
150
+ 7. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
127
151
  (tests green, documented, metrics published, runbook) AND success metrics (p95,
128
152
  cost ceiling, zero orphaned records). Each item must be verifiable, not a feeling.
129
- 7. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
153
+ 8. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
130
154
 
131
155
  After each batch, reflect: "Decided: … / Still open: …". Move on only when the
132
156
  current topic is closed.
@@ -146,7 +170,7 @@ have behavior, the scope has a boundary and the DoD is verifiable.") before Phas
146
170
  ## Phase B — Distill the artifacts
147
171
 
148
172
  Only after convergence. Produce (and, if any new project-wide invariant surfaced in
149
- step 4, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
173
+ step 5, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
150
174
  ADR where it could later be "traded away"):
151
175
 
152
176
  1. **One ADR per decision worth recording** at `docs/adr/ADR-NNN-<slug>.md`, format:
@@ -780,7 +780,9 @@ def _content_state(repo_path, repo_type, last_snapshot):
780
780
  # verdict, or a report the record itself marked stale, is no anchor at all.
781
781
  if ((snap.get("tests") or {}).get("freshness") or {}).get("status") == "stale":
782
782
  return None
783
- hashes = {r["path"]: r["sha256"]
783
+ # the RECORD, not just the hash string: `_evidence_hash_matches` needs the `sha256_eol`
784
+ # marker beside it to know whether the compatibility comparison is still on offer
785
+ hashes = {r["path"]: r
784
786
  for r in ((snap.get("tests") or {}).get("reports") or [])
785
787
  if isinstance(r, dict) and r.get("path") and r.get("sha256")
786
788
  and r.get("fresh_by") != "stale"}
@@ -814,7 +816,7 @@ def _report_fresh(repo_path, report_path, clock_fresh, content_state):
814
816
  # cannot arise here, while normalizing one side would stop matching the recorded key.
815
817
  rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
816
818
  recorded = content_state["hashes"].get(rel)
817
- if not recorded or _sha256_file(report_path) != recorded:
819
+ if _evidence_hash_matches(report_path, recorded) is not True:
818
820
  return None
819
821
  return "content"
820
822
 
@@ -842,7 +844,10 @@ def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
842
844
  "mtime_ns": mtime_ns,
843
845
  "mtime": datetime.fromtimestamp(
844
846
  mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
845
- "sha256": _sha256_file(path),
847
+ "sha256": _sha256_evidence(path),
848
+ # the marker says HOW the hash above was taken, so a reader never has to guess
849
+ # (and 1.93.0-and-older records, which have no marker, keep their compatibility)
850
+ "sha256_eol": "lf",
846
851
  })
847
852
  if not reports:
848
853
  status = "not-applicable" if repo_type == "flutter" else "missing"
@@ -5304,6 +5309,59 @@ def _sha256_file(path):
5304
5309
  return None
5305
5310
 
5306
5311
 
5312
+ # kit 1.93.1: the hash of a TEXT evidence file, EOL-NORMALIZED (CRLF -> LF before hashing).
5313
+ #
5314
+ # A JUnit report is text, and a version control system is allowed to rewrite its line endings
5315
+ # on checkout -- `* text=auto eol=lf` is the recommended `.gitattributes` and the kit's own.
5316
+ # The suite that produced the report on Windows wrote CRLF, the repository stores LF, and a
5317
+ # clean checkout therefore yields a file that is byte-different and semantically identical.
5318
+ # The exact-byte hash read that as `evidence altered after ingest`: 1.93.0 shipped with the
5319
+ # limit merely NAMED in SPEC 4, and the release machine's own board hit it the same day.
5320
+ # Normalizing the ONE difference git is allowed to introduce keeps the guarantee that matters
5321
+ # -- any other changed byte (a swapped log, an edited count, a different run) still fails.
5322
+ #
5323
+ # `_sha256_file` is deliberately left alone: compile-validate hashes MANIFEST UNITS, where the
5324
+ # exact bytes ARE the claim, and a manifest that tolerated a rewrite would be tolerating the
5325
+ # thing it exists to detect.
5326
+ def _sha256_evidence(path):
5327
+ try:
5328
+ with open(path, "rb") as fh:
5329
+ data = fh.read()
5330
+ except OSError:
5331
+ return None
5332
+ return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
5333
+
5334
+
5335
+ def _evidence_hash_matches(path, record):
5336
+ """Does the file on disk still hash to what the RECORD carries? True / False, or None when
5337
+ the record carries no hash at all (the caller decides: UNMEASURED, never a pass).
5338
+
5339
+ Records written by 1.93.1 and later carry `sha256_eol: "lf"` and are compared NORMALIZED,
5340
+ full stop. A record written BEFORE that marker hashed the file's exact bytes, whatever
5341
+ line endings the machine that ran the suite happened to write -- and 1.93.0's own release
5342
+ record is the proof: the suite wrote CRLF on Windows, git stored LF, and the LF checkout
5343
+ matched neither the recorded hash nor its normalized form, because the recording was of the
5344
+ CRLF RENDERING of the same text. So a pre-marker record is compared against all three
5345
+ renderings of the bytes on disk: normalized, exact, and CRLF. The door is narrow on purpose
5346
+ -- it opens only for records that predate the marker, and it admits only the line-ending
5347
+ renderings of THIS file's text: a report whose content really changed matches none of them."""
5348
+ want = record.get("sha256") if isinstance(record, dict) else None
5349
+ if not want:
5350
+ return None
5351
+ if _sha256_evidence(path) == want:
5352
+ return True
5353
+ if record.get("sha256_eol") == "lf":
5354
+ return False
5355
+ try:
5356
+ with open(path, "rb") as fh:
5357
+ data = fh.read()
5358
+ except OSError:
5359
+ return False
5360
+ lf = data.replace(b"\r\n", b"\n")
5361
+ return want in (hashlib.sha256(data).hexdigest(),
5362
+ hashlib.sha256(lf.replace(b"\n", b"\r\n")).hexdigest())
5363
+
5364
+
5307
5365
  def _contained_unit(base, unit):
5308
5366
  """A compilation's units must be RELATIVE paths CONTAINED within the compilation
5309
5367
  directory: the manifest references what was compiled, and what was compiled lives with
@@ -8348,6 +8406,10 @@ def cmd_dashboard(args):
8348
8406
  for r in {e.get("repo") for e in ledger["fast_path"]}}
8349
8407
  if ledger.get("spec_drift"):
8350
8408
  out["spec_drift"] = ledger["spec_drift"]
8409
+ # lifecycle (ADR-040): reused VERBATIM from the readiness payload above (one read, no
8410
+ # drift possible between the two surfaces), conditional for the same reason spec_drift is.
8411
+ if rd.get("lifecycle"):
8412
+ out["lifecycle"] = rd["lifecycle"]
8351
8413
  # evidence_origin: the latest snapshot's origin per repo, and ONLY when one exists --
8352
8414
  # a ledger predating ADR-007 keeps the exact prior schema (same conditional-key rule
8353
8415
  # fast_path and spec_drift already follow).
@@ -8989,7 +9051,7 @@ def _sealed_state(ledger, ledger_path):
8989
9051
  elif not r.get("sha256"):
8990
9052
  unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
8991
9053
  "(older snapshot, or the file was unreadable)" % rel)
8992
- elif _sha256_file(full) != r["sha256"]:
9054
+ elif _evidence_hash_matches(full, r) is not True:
8993
9055
  failures.append("evidence altered after ingest: %s" % rel)
8994
9056
 
8995
9057
  out["reasons"] = failures + unmeasured
@@ -9496,6 +9558,12 @@ def cmd_readiness(args):
9496
9558
  "gates": _gate_rollup(ledger),
9497
9559
  "by_repo": repos,
9498
9560
  }
9561
+ # lifecycle (ADR-040): advisory, and CONDITIONAL like fast_path/spec_drift -- a project
9562
+ # that declares no lifecycle: block keeps the exact prior payload and the exact prior
9563
+ # text. Speaking only when it matters is the anti-ceremony rule applied to itself.
9564
+ _lc = _lifecycle_for(os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd())
9565
+ if _lc["declared"]:
9566
+ out["lifecycle"] = _lc
9499
9567
  if args.json:
9500
9568
  print(json.dumps(out, indent=2, ensure_ascii=False))
9501
9569
  return
@@ -9576,6 +9644,8 @@ def cmd_readiness(args):
9576
9644
  f"rising for {STALL_WINDOW} cycles: iterating more is not getting "
9577
9645
  f"closer. Likely a design/SPEC problem — go back to the ADR / "
9578
9646
  f"re-plan with the human (advisory)")
9647
+ if _lc["declared"]:
9648
+ print(" · " + _lifecycle_summary(_lc))
9579
9649
  # rubrica (1.23.0): el ultimo grade por repo, siempre visible — guess
9580
9650
  # estructurado que aconseja; si esta gateado ya bloqueo por el ledger
9581
9651
  for rname, rnode in ledger["repos"].items():
@@ -10928,6 +10998,225 @@ _SC_SETEXT = re.compile(r"^(=+|-{2,})\s*$")
10928
10998
  _SC_CHECKBOX = re.compile(r"^\[[ xX]\]\s*")
10929
10999
 
10930
11000
 
11001
+ # --------------------------------------------------------------------------- #
11002
+ # lifecycle (ADR-040: stack end-of-support vs go-live -- advisory, NEVER gates)
11003
+ # --------------------------------------------------------------------------- #
11004
+ # The method interrogates WHAT and WHY and has always treated the stack as a given
11005
+ # ("we use X"). A major line is not a decision: a minor line has an END-OF-SUPPORT
11006
+ # DATE, and a date that falls before the declared go-live is a forced upgrade nobody
11007
+ # planned. This dimension reads what the ADR CITES and compares it against the SPEC's
11008
+ # declared go-live. It cannot verify that the cited source tells the truth -- only that
11009
+ # a date and a source were cited at all. Advisory by construction, like spec-drift
11010
+ # (ADR-005): it never gates, never caps readiness, and never changes an exit code.
11011
+ _LC_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
11012
+
11013
+
11014
+ def _lc_valid_date(s):
11015
+ """A cited date is real only if it is BOTH shaped YYYY-MM-DD and a calendar date:
11016
+ the format regex alone accepts 2025-13-40 (1.94.0 review), which then string-compares
11017
+ as if it were a real EOL. fromisoformat rejects the impossible month/day."""
11018
+ if not s or not _LC_DATE.match(s):
11019
+ return False
11020
+ try:
11021
+ datetime.strptime(s, "%Y-%m-%d")
11022
+ return True
11023
+ except ValueError:
11024
+ return False
11025
+ _LC_KEY = re.compile(r"^lifecycle\s*:\s*(.*)$")
11026
+ _LC_GOLIVE_FM = re.compile(r"^go[-_ ]?live\s*:\s*(.+?)\s*$", re.I)
11027
+ _LC_GOLIVE_LINE = re.compile(r"^\s*\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
11028
+ r"(\d{4}-\d{2}-\d{2})\b", re.I)
11029
+ _LC_FIELDS = ("component", "version", "eol", "source", "checked")
11030
+
11031
+
11032
+ def _lc_frontmatter(lines):
11033
+ """The lines INSIDE a leading `---` frontmatter block, or None when there is none.
11034
+ An unterminated fence is not data: a half-written block reads as absent."""
11035
+ if not lines or lines[0].strip() != "---":
11036
+ return None
11037
+ body = []
11038
+ for ln in lines[1:]:
11039
+ if ln.strip() == "---":
11040
+ return body
11041
+ body.append(ln)
11042
+ return None
11043
+
11044
+
11045
+ def _lc_parse(lines):
11046
+ """The `lifecycle:` list-of-dicts of a markdown file, parsed as a YAML SUBSET --
11047
+ stdlib only, the kit ships no PyYAML. Returns None when the key is absent (nothing to
11048
+ measure) and [] when the key is there but nothing under it parses (a NAMED absence:
11049
+ the caller reports UNMEASURED with a reason, never a silent pass)."""
11050
+ fm = _lc_frontmatter(lines)
11051
+ if fm is None:
11052
+ return None
11053
+ entries, in_lc, cur = None, False, None
11054
+ for ln in fm:
11055
+ s = ln.strip()
11056
+ if not in_lc:
11057
+ m = _LC_KEY.match(ln)
11058
+ if m:
11059
+ entries = []
11060
+ # only the block form is supported; an inline `lifecycle: [...]` parses to
11061
+ # nothing and reads as unparseable, never as "this ADR fixes no component"
11062
+ in_lc = not m.group(1).strip()
11063
+ continue
11064
+ if not s or s.startswith("#"):
11065
+ continue
11066
+ if not ln[:1].isspace(): # back to column 0 -> the key ended
11067
+ in_lc = False
11068
+ continue
11069
+ if s.startswith("- "):
11070
+ cur = {}
11071
+ entries.append(cur)
11072
+ s = s[2:].strip()
11073
+ if not s:
11074
+ continue
11075
+ if cur is None:
11076
+ continue
11077
+ k, sep, v = s.partition(":")
11078
+ if not sep:
11079
+ continue
11080
+ k = k.strip().lower()
11081
+ if k in _LC_FIELDS:
11082
+ cur[k] = v.strip().strip("\x27\x22")
11083
+ if entries is not None:
11084
+ entries = [e for e in entries if e]
11085
+ return entries
11086
+
11087
+
11088
+ def _lc_go_live(text):
11089
+ """The declared go-live of a SPEC: frontmatter `go_live: YYYY-MM-DD`, or a
11090
+ `**Go-live:** YYYY-MM-DD` line anywhere in the body. None = not declared."""
11091
+ lines = [ln.rstrip("\r") for ln in (text or "").split("\n")]
11092
+ fm = _lc_frontmatter(lines)
11093
+ if fm:
11094
+ for ln in fm:
11095
+ if ln[:1].isspace():
11096
+ continue
11097
+ m = _LC_GOLIVE_FM.match(ln.strip())
11098
+ if m:
11099
+ v = m.group(1).strip().strip("\x27\x22")
11100
+ if _lc_valid_date(v):
11101
+ return v
11102
+ for ln in lines:
11103
+ m = _LC_GOLIVE_LINE.match(ln)
11104
+ if m and _lc_valid_date(m.group(1)):
11105
+ return m.group(1)
11106
+ return None
11107
+
11108
+
11109
+ def _lc_short(path):
11110
+ """The ADR dir as the reader would type it: relative to the cwd when that is shorter,
11111
+ absolute otherwise (a different drive on Windows has no relative form)."""
11112
+ if not path:
11113
+ return "docs/adr"
11114
+ try:
11115
+ rel = os.path.relpath(path)
11116
+ except ValueError:
11117
+ return path
11118
+ return rel if len(rel) < len(path) else path
11119
+
11120
+
11121
+ def _lifecycle_report(adr_dir, spec_text):
11122
+ """Per-component verdicts for every `lifecycle:` entry under `adr_dir`, compared
11123
+ against the go-live declared in `spec_text`. Whole-dimension UNMEASURED -- with the
11124
+ reason spelled out, never silence -- when no ADR carries the block, when the block is
11125
+ unparseable, or when no go-live is declared."""
11126
+ go_live = _lc_go_live(spec_text)
11127
+ comps, unparsed, names = [], [], []
11128
+ if adr_dir and os.path.isdir(adr_dir):
11129
+ names = sorted(f for f in os.listdir(adr_dir) if f.lower().endswith(".md"))
11130
+ for fn in names:
11131
+ try:
11132
+ with open(os.path.join(adr_dir, fn), "r", encoding="utf-8",
11133
+ errors="replace") as fh:
11134
+ lines = [ln.rstrip("\r") for ln in fh.read().split("\n")]
11135
+ except OSError:
11136
+ continue
11137
+ ent = _lc_parse(lines)
11138
+ if ent is None:
11139
+ continue
11140
+ if not ent:
11141
+ unparsed.append(fn)
11142
+ continue
11143
+ for e in ent:
11144
+ comps.append({"adr": fn, "component": e.get("component") or "(unnamed)",
11145
+ "version": e.get("version"), "eol": e.get("eol"),
11146
+ "source": e.get("source"), "checked": e.get("checked")})
11147
+ out = {"status": "UNMEASURED", "reason": None, "go_live": go_live,
11148
+ "adr_dir": adr_dir, "declared": bool(comps or unparsed),
11149
+ "components": comps, "n_components": len(comps), "n_expiring": 0,
11150
+ "unparsed_adrs": unparsed}
11151
+ if not comps:
11152
+ out["reason"] = ("lifecycle: block declared but unparseable in: "
11153
+ + ", ".join(unparsed) if unparsed else
11154
+ "no ADR under %s carries a lifecycle: block"
11155
+ % _lc_short(adr_dir))
11156
+ return out
11157
+ if not go_live:
11158
+ for c in comps:
11159
+ c["status"], c["detail"] = "unmeasured", "no go-live declared"
11160
+ out["reason"] = ("no go-live declared in the SPEC (frontmatter 'go_live: "
11161
+ "YYYY-MM-DD' or a '**Go-live:** YYYY-MM-DD' line)")
11162
+ return out
11163
+ for c in comps:
11164
+ eol = (c.get("eol") or "").strip()
11165
+ cited = (c.get("source") or "").strip()
11166
+ if not eol or eol.lower() == "unknown" or not _lc_valid_date(eol):
11167
+ c["status"] = "no EOL cited"
11168
+ c["detail"] = ("declared unknown" if eol.lower() == "unknown"
11169
+ else ("not a YYYY-MM-DD date: %s" % eol if eol else "absent"))
11170
+ elif eol < go_live:
11171
+ # the sharper fact wins the label: a date that already expires is reported as
11172
+ # expiring even when uncited, and the missing source stays visible in the record
11173
+ c["status"] = "expires before go-live"
11174
+ c["detail"] = "%s < %s" % (eol, go_live)
11175
+ out["n_expiring"] += 1
11176
+ elif not cited:
11177
+ c["status"] = "no source cited"
11178
+ c["detail"] = "eol %s is asserted, not cited" % eol
11179
+ else:
11180
+ c["status"] = "ok"
11181
+ c["detail"] = "eol %s >= go-live %s" % (eol, go_live)
11182
+ out["status"] = "measured"
11183
+ return out
11184
+
11185
+
11186
+ def _lifecycle_summary(lc):
11187
+ """The one advisory line, shared by spec-check and readiness so the two surfaces
11188
+ cannot drift apart."""
11189
+ if lc["status"] != "measured":
11190
+ return "lifecycle: UNMEASURED - %s" % lc["reason"]
11191
+ return ("lifecycle: %d component(s), %d expire before go-live %s (advisory)"
11192
+ % (lc["n_components"], lc["n_expiring"], lc["go_live"]))
11193
+
11194
+
11195
+ def _lifecycle_root(*paths):
11196
+ """The project root a lifecycle read hangs off: the directory of the first path
11197
+ given, else the cwd."""
11198
+ for p in paths:
11199
+ if p:
11200
+ return os.path.dirname(os.path.abspath(p)) or os.getcwd()
11201
+ return os.getcwd()
11202
+
11203
+
11204
+ def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
11205
+ """Read the dimension for a project root: `docs/adr` unless overridden, and the
11206
+ go-live from the text already in hand. `fallback` reads `<root>/SPEC.md` when no
11207
+ text was supplied -- it is OFF when the caller named the spec files itself, so an
11208
+ explicit `--spec` is never silently topped up from a file nobody asked for."""
11209
+ if fallback and not _lc_go_live(spec_text):
11210
+ sp = os.path.join(root, "SPEC.md")
11211
+ if os.path.isfile(sp):
11212
+ try:
11213
+ with open(sp, "r", encoding="utf-8", errors="replace") as fh:
11214
+ spec_text = (spec_text or "") + "\n" + fh.read()
11215
+ except OSError:
11216
+ pass
11217
+ return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
11218
+
11219
+
10931
11220
  def _spec_check_text(text):
10932
11221
  lines = text.split("\n")
10933
11222
  n = len(lines)
@@ -11222,6 +11511,11 @@ def cmd_spec_check(args):
11222
11511
  m = (_spec_check_text(text) if text.strip()
11223
11512
  else {"blockers": [], "untestable": [], "stack_hits": [],
11224
11513
  "non_ears": 0, "n_criteria": 0})
11514
+ # lifecycle (ADR-040): read-only and advisory -- it never touches `fail` below.
11515
+ lc = _lifecycle_for(_lifecycle_root(args.spec[0] if args.spec else None,
11516
+ args.acceptance),
11517
+ getattr(args, "adr_dir", None), text,
11518
+ fallback=not args.spec)
11225
11519
  structural = len(m["blockers"]) + len(acc_block) # estructura = FACT -> bloquea
11226
11520
  soft_find = len(m["untestable"]) + len(m["stack_hits"]) + len(acc_adv)
11227
11521
  fail = structural > 0 or (args.strict and soft_find > 0)
@@ -11230,7 +11524,7 @@ def cmd_spec_check(args):
11230
11524
  if args.json:
11231
11525
  print(json.dumps({"verdict": verdict, "advisory": structural == 0,
11232
11526
  "acceptance_blockers": acc_block,
11233
- "acceptance_advisory": acc_adv, **m},
11527
+ "acceptance_advisory": acc_adv, "lifecycle": lc, **m},
11234
11528
  indent=2, ensure_ascii=False))
11235
11529
  sys.exit(1 if fail else 0)
11236
11530
 
@@ -11251,6 +11545,11 @@ def cmd_spec_check(args):
11251
11545
  print(f" ~ stack named in criteria ({len(m['stack_hits'])}): {', '.join(terms[:6])} — the 'how' belongs in the ADR, not the SPEC")
11252
11546
  if m["non_ears"] and m["n_criteria"]:
11253
11547
  print(f" · advisory: {m['non_ears']}/{m['n_criteria']} criteria do not follow the EARS pattern (When/If/While … shall)")
11548
+ print(" · " + _lifecycle_summary(lc))
11549
+ for c in [x for x in lc["components"] if x.get("status") not in ("ok", None)][:5]:
11550
+ print(" %s %s %s - %s (%s)"
11551
+ % ("!" if c["status"] == "expires before go-live" else "~",
11552
+ c["component"], c.get("version") or "?", c["status"], c["detail"]))
11254
11553
  print(" i consistency: INFERENTIAL (an uncorrelated checker), not this lint · "
11255
11554
  "structure = FACT (blocks) · prose = advisory (--strict to gate)")
11256
11555
  if verdict == "OK":
@@ -12471,6 +12770,9 @@ def build_parser():
12471
12770
  help="validate ACCEPTANCE traceability (AC-n IDs): missing "
12472
12771
  "file / zero criteria / zero traceable / duplicate "
12473
12772
  "IDs block as structural FACTS")
12773
+ psc.add_argument("--adr-dir", default=None,
12774
+ help="ADRs whose lifecycle: blocks are read (ADR-040); default "
12775
+ "docs/adr beside the SPEC")
12474
12776
  psc.add_argument("--strict", action="store_true",
12475
12777
  help="also fail (exit 1) on soft findings: untestable criteria / stack named")
12476
12778
  psc.add_argument("--json", action="store_true")
@@ -137,25 +137,49 @@ answer.
137
137
  I deduce these core entities: … do they work for you, or is one missing?")
138
138
  3. **Operation / API surface.** Propose the endpoints/operations and their
139
139
  contracts (idempotency, status codes).
140
- 4. **Big decisions ( ADR).** Propose 2–3 architecture options with trade-offs and
140
+ 4. **Stack and lifecycle (MANDATORY before any stack/architecture decision is
141
+ recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
142
+ DATE. A major version is a family; support is granted to a MINOR line, for a window,
143
+ by an upstream nobody in the room controls. Ask these one at a time, each with your
144
+ recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
145
+ answer from memory**; record the URL and the day you checked:
146
+ (a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
147
+ Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
148
+ (b) support window vs the declared go-live AND the expected operating life — does the
149
+ line stay patched for the whole operation? If not, move it up BEFORE building: a major
150
+ upgrade days before the milestone is the most expensive one there is;
151
+ (c) major dependencies and the upgrade policy — who approves one, and when it is
152
+ scheduled (aligned with the dev-loop's "zero new dependencies without approval");
153
+ (d) development and observability tools the operator wants from day one (consoles, APM,
154
+ admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
155
+ (e) compatibility with reused legacy modules — the minimum version they support.
156
+ The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
157
+ `lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
158
+ ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
159
+ `templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
160
+ compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
161
+ `qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
162
+ `no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
163
+ was CITED; it cannot see whether the citation is TRUE. That part is yours.
164
+ 5. **Big decisions (→ ADR).** Propose 2–3 architecture options with trade-offs and
141
165
  a recommended default: persistence, protocol, idempotency, sync/async, multi-tenancy.
142
- 5. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
166
+ 6. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
143
167
  concurrency, what must NOT happen.
144
- 6. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
168
+ 7. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
145
169
  rules that can't be broken (money to the cent, no numbering gaps, never cross
146
170
  environments, secrets never logged, auth). Write/extend `CONSTITUTION.md` with these —
147
171
  one invariant per line, with a CWE reference where it maps. They feed the severity gate
148
172
  downstream, and a breach is a BLOCKER, never a trade-off.
149
- 7. **Out of scope.** Explicit boundaries with forward references.
150
- 8. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
151
- 9. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
173
+ 8. **Out of scope.** Explicit boundaries with forward references.
174
+ 9. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
175
+ 10. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
152
176
  dimensions are negotiable (coverage, perf, security)?" Propose thresholds fit for
153
177
  the risk profile (a payments core is not an internal dashboard). What the human
154
178
  declares goes into `uscha.config.json` (`defaults.coverage_threshold`,
155
179
  `defaults.readiness_caps`, `defaults.simplicity`) — a declared threshold reads as
156
180
  **requerimiento (config)** in the engine's output; an undeclared one stays a kit
157
181
  default (opinion) and is labeled as such. Declaring is committing the config.
158
- 10. **Residual risks and dependencies.** What's uncertain, what must exist first.
182
+ 11. **Residual risks and dependencies.** What's uncertain, what must exist first.
159
183
  For each HIGH-uncertainty risk, ask (kit 1.19.0, Tip 21 'Prototype to Learn'):
160
184
  "Does it warrant a time-boxed spike before freezing the SPEC?" A spike runs on a
161
185
  `spike/*` branch and its ONLY legitimate output is an **ADR with lessons**
@@ -98,6 +98,17 @@ enforcing the record is the engine's job. It is never resolved by "working aroun
98
98
  - [ ] `golden-diff` clean (byte for byte) = hard closing condition of the touched module
99
99
  - [ ] Corpus documented; a module with insufficient corpus = PARTIAL, never COVERS
100
100
 
101
+ ## Stack lifecycle — the stack expires (non-negotiable)
102
+
103
+ > A major version is a family; support is granted to a MINOR line, for a window, by an upstream
104
+ > nobody here controls. A stack ADR that fixes a version without its end-of-support date has not
105
+ > decided anything — it has deferred the decision to the week before go-live (ADR-040).
106
+ > Measured — ADVISORY, never a gate — by `qa_ledger.py spec-check`'s lifecycle dimension.
107
+
108
+ - [ ] No stack ADR without a cited end-of-support date for every component it fixes <!-- ADR-040 -->
109
+ - [ ] The date comes from the OFFICIAL source, fetched when asked, with the URL and the day checked recorded — never from memory
110
+ - [ ] No component whose support ends before the declared `go_live` enters the build; the upgrade happens BEFORE, not days after
111
+
101
112
  ## Anti-ceremony — Lean over the method itself (meta-invariant)
102
113
 
103
114
  > The risk is not a bad gate: it is the **sum** of good gates turning `/uscha-devloop` into an
@@ -139,6 +150,12 @@ enforcing the record is the engine's job. It is never resolved by "working aroun
139
150
  - The **Golden (INV-GOLDEN-01)** invariant is measured by `qa_ledger.py golden-diff`: any `.received`
140
151
  that does not match its `.approved` (or is unapproved) = **DIVERGE**, cutting the chain before judgment-day.
141
152
  The agent does not touch `.approved` (ideally a `PreToolUse` hook makes it impossible).
153
+ - The **Stack lifecycle** invariant is measured by `qa_ledger.py spec-check`: each `lifecycle:`
154
+ entry in `docs/adr/*.md` is compared against the SPEC's `go_live` and reads `ok` / `expires
155
+ before go-live` / `no EOL cited` / `no source cited`, with the whole dimension UNMEASURED (and
156
+ the reason named) when nothing declares it. **Advisory** — it never gates and never caps
157
+ readiness: the engine can see that a date was CITED, never that the citation is true. Verifying
158
+ the source is the human's job; the record makes the omission visible.
142
159
  - The **Anti-ceremony** meta-invariant is not measured by any subcommand: it is the admission filter
143
160
  for new gates (does it autorun? does it stay quiet except when it matters? does it collapse into `readiness`? does a
144
161
  trivial change skip it?). Its only mechanized leg today is the single verdict of `readiness` (kit
@@ -0,0 +1,73 @@
1
+ ---
2
+ # The machine-readable half of a stack ADR (ADR-040). One entry per component this ADR
3
+ # FIXES: runtime, web framework, ORM, database, Node, bundler, message broker, cache.
4
+ #
5
+ # component the name as your team says it
6
+ # version the EXACT version or minor line you are fixing -- not the major family
7
+ # eol end of OSS/LTS support, YYYY-MM-DD. `unknown` is allowed and reads as a
8
+ # NAMED absence ("no EOL cited"), never as a pass
9
+ # source the OFFICIAL page the date came from. Fetch it while you ask; never
10
+ # answer from memory
11
+ # checked the day you actually looked, YYYY-MM-DD
12
+ #
13
+ # `qa_ledger.py spec-check` reads this block and compares each `eol` against the
14
+ # `go_live` declared in the SPEC. It is ADVISORY: it never gates and never caps
15
+ # readiness. It measures that a date and a source were CITED -- it cannot verify that
16
+ # the source tells the truth. That check is the human's.
17
+ lifecycle:
18
+ - component: <runtime, e.g. the language runtime>
19
+ version: "<exact version or minor line>"
20
+ eol: YYYY-MM-DD
21
+ source: https://<official support page>
22
+ checked: YYYY-MM-DD
23
+ - component: <web framework>
24
+ version: "<exact version or minor line>"
25
+ eol: YYYY-MM-DD
26
+ source: https://<official support page>
27
+ checked: YYYY-MM-DD
28
+ - component: <database / store>
29
+ version: "<exact version or minor line>"
30
+ eol: unknown
31
+ source: https://<official support page>
32
+ checked: YYYY-MM-DD
33
+ ---
34
+ # ADR-NNN: <the stack this project fixes, and until when it is supported>
35
+
36
+ ## Status: Accepted
37
+
38
+ > The SPEC must declare the milestone this block is compared against, as frontmatter
39
+ > `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line. Without it the whole
40
+ > dimension reads UNMEASURED, with the reason named.
41
+
42
+ ## Context
43
+ <Why this stack, and what already constrains it: reused legacy modules and the minimum
44
+ versions they support, the operator's development/observability tooling (consoles, APM,
45
+ admin UIs) — those constrain versions, so they are asked BEFORE the stack is fixed, not
46
+ after — and the expected operating life of the system beyond go-live.>
47
+
48
+ ## Alternatives
49
+ - A) <option> — support window <until when>, cost <…>
50
+ - B) <option> — support window <until when>, cost <…>
51
+
52
+ ## Decision
53
+ - <The exact versions fixed, one line each, with the date they stop being supported.>
54
+ - **Upgrade policy**: <who approves a major upgrade, and when it is scheduled>. A new
55
+ dependency is never added without explicit approval (the dev-loop's "zero new
56
+ dependencies without approval" rule).
57
+
58
+ ## Reasons
59
+ - <why this line, and why its support window covers the operation, not just the launch>
60
+
61
+ ## Consequences
62
+ + <the good>
63
+ - <the cost: the upgrade already on the calendar, and who owns it>
64
+
65
+ ## Implementation Plan
66
+ - Affected paths: <build files, lockfiles, CI images, base images>
67
+ - Tests: <the suite that proves the fixed versions actually run>
68
+
69
+ ## Verification
70
+ - [ ] Every component this ADR fixes carries a `version`, an `eol` and the `source` it
71
+ was read from, checked on a named day.
72
+ - [ ] No component's `eol` falls before the SPEC's `go_live`; where one does, the upgrade
73
+ is scheduled BEFORE the build, not after.
@@ -8,6 +8,11 @@ Format: Status (proposed/accepted/experiment/deprecated/superseded) · Context
8
8
  Decision · Consequences · Implementation Plan (affected paths, patterns, tests) ·
9
9
  Verification (checkboxes).
10
10
 
11
+ The ADR that fixes the stack has a template of its own, `ADR-stack-template.md`: it carries a
12
+ machine-readable `lifecycle:` frontmatter block (component / version / eol / source / checked)
13
+ that `spec-check` compares against the SPEC's declared `go_live` (ADR-040). Advisory: it reports,
14
+ it never gates.
15
+
11
16
  `Status: Experiment` is for a bounded, reversible hypothesis that needs real feedback.
12
17
  It must include: Hypothesis, Feedback Signal, Review By or Review Trigger, Promote
13
18
  Criteria, and Rollback / Supersede Criteria. Missing/expired metadata is advisory in
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.93.0",
2
+ "version": "1.94.0",
3
3
  "project": null,
4
4
  "defaults": {
5
5
  "coverage_threshold": 60,