Implement SECRETS-WP-0008 unblocked layer-model obligations
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run

Load pep-stance.yaml as the live unreachable-engine gate and record named
stance fields on privileged evidence. Classify evidence, queue load-bearing
records in a local outbox, and add heartbeat/drain commands that never sit
on a mutation path. Publish proposed SSH-CA and secret-use evidence
contracts without adding an OpenBao SSH-CA write.

T02 (access-engine decision records) and T06 (no standing credential) stay
wait on external endpoints.

Assistant: grok
Assistant-Session: 01a04cea-cb33-7c63-bad7-c1b0f9f0076b
This commit is contained in:
tegwick 2026-08-29 12:52:55 +02:00
parent 57f6c4fa65
commit 3cd9955ac9
16 changed files with 1041 additions and 77 deletions

View file

@ -14,6 +14,7 @@ Command surface (FR7):
revoke <catalog-id>
lifecycle suspend|deactivate|destroy <catalog-id>
audit <catalog-id> [--json]
evidence heartbeat|drain|classify
Every privileged action is decision-gated and writes non-secret evidence.
`plan` and `apply --dry-run` never mutate OpenBao.
@ -21,10 +22,8 @@ Every privileged action is decision-gated and writes non-secret evidence.
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from urllib.parse import urlparse
from secrets_engine import __version__
from secrets_engine.apply import apply_plan
@ -33,6 +32,7 @@ from secrets_engine.config import Config, repo_root
from secrets_engine.decisions import require_approved, resolve_decision
from secrets_engine.errors import DecisionError, SecretsEngineError
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.plan import build_plan
from secrets_engine.provision import provision_from_file, provision_generated
@ -85,28 +85,23 @@ def _privileged_evidence(
)
def _unsafe_local_demo_enabled(cfg: Config) -> bool:
"""Return true only for an explicit, offline, loopback-only demo."""
host = (urlparse(cfg.bao_addr).hostname or "").lower()
return (
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
and not cfg.hub_url
and host in {"127.0.0.1", "localhost", "::1"}
)
def _require_lane_approval(
cfg: Config,
entry,
action: str = "",
evidence: PrivilegedActionEvidence | None = None,
):
"""Apply published PEP stance, then resolve lane approval.
def _require_lane_approval(cfg: Config, entry, action: str = ""):
"""Resolve approval for a live action, failing production closed.
The durable State Hub action-authorization endpoint is not available yet.
Production therefore cannot rely on a coarse lane decision. The one narrow
exception is an explicit offline demo against a loopback OpenBao instance.
Production ``fail_closed`` is read from ``pep-stance.yaml``. The durable
access-engine decision record is not served yet, so that row refuses live
production work. The three-factor unsafe-demo exception is not a stance
row. Build/test ``fail_open`` still requires the existing lane-approval
check a tracked gap until SECRETS-WP-0008-T02.
"""
if entry.stage == "prod" and not _unsafe_local_demo_enabled(cfg):
raise DecisionError(
f"production action '{action or 'unknown'}' requires a durable "
"State Hub action authorization; live production remains disabled"
)
stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown")
if evidence is not None:
evidence.mark_stance(stance)
if not entry.approval_required():
return None
decision = resolve_decision(
@ -115,6 +110,8 @@ def _require_lane_approval(cfg: Config, entry, action: str = ""):
decision_ref=entry.approval.get("decision_ref", entry.id),
)
require_approved(entry, decision)
if evidence is not None:
evidence.mark_stance(with_decision(stance, decision))
return decision
@ -229,7 +226,7 @@ def cmd_apply(cfg: Config, args) -> int:
return 0
with _privileged_evidence(cfg, entry, "apply") as evidence:
decision = _require_lane_approval(cfg, entry, "apply")
decision = _require_lane_approval(cfg, entry, "apply", evidence)
evidence.mark_approved(decision)
plan = build_plan(
entry, args.stage, decision_id=decision.id if decision else ""
@ -258,7 +255,7 @@ def cmd_provision(cfg: Config, args) -> int:
raise ProvisioningError(
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
)
decision = _require_lane_approval(cfg, entry, "provision")
decision = _require_lane_approval(cfg, entry, "provision", evidence)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
@ -291,7 +288,7 @@ def cmd_verify(cfg: Config, args) -> int:
"negative_requested": negative,
},
) as evidence:
decision = _require_lane_approval(cfg, entry, "verify")
decision = _require_lane_approval(cfg, entry, "verify", evidence)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
@ -367,7 +364,7 @@ def cmd_handoff(cfg: Config, args) -> int:
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
)
decision = _require_lane_approval(cfg, entry, "handoff")
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
@ -419,7 +416,7 @@ def cmd_exec(cfg: Config, args) -> int:
},
) as evidence:
# require approval + readiness before running.
decision = _require_lane_approval(cfg, entry, "exec")
decision = _require_lane_approval(cfg, entry, "exec", evidence)
evidence.mark_approved(decision)
if not args.command:
from secrets_engine.errors import DeliveryError
@ -498,7 +495,7 @@ def cmd_revoke(cfg: Config, args) -> int:
with _privileged_evidence(
cfg, entry, "revoke", detail={"operation": plan.operation}
) as evidence:
decision = _require_lane_approval(cfg, entry, "deactivate")
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
@ -546,7 +543,7 @@ def cmd_lifecycle(cfg: Config, args) -> int:
"live destroy is disabled until an exact-action destruction "
"approval contract is available; use --dry-run to inspect targets"
)
decision = _require_lane_approval(cfg, entry, args.operation)
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
@ -579,6 +576,65 @@ def cmd_audit(cfg: Config, args) -> int:
return 0
def cmd_evidence_heartbeat(cfg: Config, args) -> int:
"""Write a positive load-bearing heartbeat. Not a permission check."""
import json
from secrets_engine.evidence import write_heartbeat
record = write_heartbeat(_writer(cfg), stage=args.stage)
if args.json:
print(json.dumps(record, indent=2, sort_keys=True))
else:
print(
f"heartbeat {record['result']} queued={record.get('outbox_queued')} "
f"completeness_claimed={record.get('completeness_claimed')}"
)
return 0
def cmd_evidence_drain(cfg: Config, args) -> int:
"""Best-effort drain of the local load-bearing outbox. Never a gate."""
import json
from secrets_engine.evidence import drain_outbox
result = drain_outbox(_writer(cfg), audit_core_url=args.audit_core_url)
if args.json:
print(json.dumps(result, indent=2, sort_keys=True))
else:
print(
f"outbox drain queued={result['queued']} "
f"delivered={result['delivered']} failed={result['failed']} "
f"skipped={result['skipped']}"
)
return 0
def cmd_evidence_classify(cfg: Config, args) -> int:
import json
from secrets_engine.evidence_class import classify
classified = classify(args.action, args.stage)
payload = {
"action": classified.action,
"stage": classified.stage,
"kind": classified.kind,
"rule_id": classified.rule_id,
"queued_locally": classified.queued_locally,
"completeness_claimed": classified.completeness_claimed,
}
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print(
f"{classified.action}/{classified.stage}: {classified.kind} "
f"rule={classified.rule_id} queued={classified.queued_locally}"
)
return 0
# -- parser ----------------------------------------------------------------
@ -700,6 +756,32 @@ def build_parser() -> argparse.ArgumentParser:
au.add_argument("--json", action="store_true")
au.set_defaults(func=cmd_audit)
ev = sub.add_parser("evidence", help="load-bearing evidence heartbeat and outbox")
evsub = ev.add_subparsers(dest="subcmd", required=True)
hb = evsub.add_parser(
"heartbeat",
help="emit a positive nothing-to-report claim (not a permission check)",
)
hb.add_argument("--stage", default="prod", choices=("build", "test", "prod"))
hb.add_argument("--json", action="store_true")
hb.set_defaults(func=cmd_evidence_heartbeat)
dr = evsub.add_parser(
"drain",
help="best-effort drain of the local outbox; never blocks a mutation",
)
dr.add_argument(
"--audit-core-url",
default="",
help="optional audit-core base URL; empty skips delivery and keeps files",
)
dr.add_argument("--json", action="store_true")
dr.set_defaults(func=cmd_evidence_drain)
cl = evsub.add_parser("classify", help="show the §9.6 class for an action/stage")
cl.add_argument("action")
cl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
cl.add_argument("--json", action="store_true")
cl.set_defaults(func=cmd_evidence_classify)
return p

View file

@ -23,6 +23,10 @@ class DecisionError(SecretsEngineError):
exit_code = 3
def __init__(self, message: str, *, stance: dict[str, object] | None = None):
super().__init__(message)
self.stance = dict(stance or {})
class PolicyGuardError(SecretsEngineError):
"""A plan violates a safety guard (wildcard, out-of-stage path, root, ...)."""

View file

@ -17,6 +17,7 @@ from pathlib import Path
from typing import Any
from secrets_engine.errors import DecisionError, SecretsEngineError
from secrets_engine.evidence_class import KIND_ATTRIBUTIVE, classify
from secrets_engine.redact import looks_secret, redact_text
# Keys that must never carry a value into evidence regardless of nesting.
@ -64,6 +65,21 @@ class EvidenceWriter:
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
def outbox_dir(self) -> Path:
path = self.evidence_dir / "outbox"
path.mkdir(parents=True, exist_ok=True)
return path
def _queue_outbox(self, record: dict[str, Any]) -> str:
"""Durably queue a load-bearing record. Never talks to audit-core."""
record_id = str(record.get("record_id") or uuid.uuid4())
path = self.outbox_dir() / f"{record_id}.json"
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
os.chmod(tmp, 0o600)
tmp.replace(path)
return record_id
def record(
self,
action: str,
@ -75,9 +91,18 @@ class EvidenceWriter:
detail: dict[str, Any] | None = None,
hub: bool = True,
) -> dict[str, Any]:
"""Append one non-secret evidence record. Returns the stored record."""
"""Append one non-secret evidence record. Returns the stored record.
Load-bearing records are queued locally first. An audit-core outage
cannot occur here because this method never contacts audit-core.
Completeness is never claimed. Presence or absence of a record is
not consulted as a permission.
"""
record_id = str(uuid.uuid4())
hub_requested = bool(hub and self.hub_url)
evidence_class = classify(action, stage)
hub_requested = bool(
hub and self.hub_url and evidence_class.kind == KIND_ATTRIBUTIVE
)
record = {
"record_id": record_id,
"ts": datetime.now(timezone.utc).isoformat(),
@ -89,7 +114,13 @@ class EvidenceWriter:
"decision_id": decision_id,
"detail": _scrub(detail or {}),
"hub_delivery_requested": hub_requested,
"evidence_kind": evidence_class.kind,
"evidence_rule": evidence_class.rule_id,
"completeness_claimed": False,
}
if evidence_class.queued_locally:
self._queue_outbox(record)
record["outbox_queued"] = True
self._append_local(record)
if hub_requested:
delivery = self._post_hub(
@ -205,6 +236,7 @@ class PrivilegedActionEvidence:
decision_id: str = ""
approval_status: str = "pending"
completed: bool = False
stance: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.approval_required:
@ -218,6 +250,8 @@ class PrivilegedActionEvidence:
"decision_ref": self.decision_ref,
}
)
if self.stance:
merged.update(self.stance)
if extra:
merged.update(extra)
return merged
@ -239,6 +273,17 @@ class PrivilegedActionEvidence:
self.decision_id = str(getattr(decision, "id", ""))
self.approval_status = "approved"
def mark_stance(self, stance: object | None) -> None:
if stance is None:
return
if hasattr(stance, "as_evidence"):
payload = stance.as_evidence()
elif isinstance(stance, dict):
payload = stance
else:
return
self.stance = {key: value for key, value in payload.items() if value != ""}
def finish(
self, result: str, *, detail: dict[str, Any] | None = None
) -> dict[str, Any]:
@ -259,6 +304,8 @@ class PrivilegedActionEvidence:
return False
if isinstance(exc, DecisionError):
self.approval_status = "rejected"
if getattr(exc, "stance", None):
self.mark_stance(exc.stance)
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
result = "interrupted"
elif isinstance(exc, SecretsEngineError):
@ -274,3 +321,76 @@ class PrivilegedActionEvidence:
detail=self._detail({"error_type": type(exc).__name__}),
)
return False
def write_heartbeat(writer: EvidenceWriter, *, stage: str = "prod") -> dict[str, Any]:
"""Positive claim that can go missing. Not a permission and not silence."""
queued = 0
outbox = writer.evidence_dir / "outbox"
if outbox.is_dir():
queued = sum(1 for path in outbox.glob("*.json") if path.is_file())
return writer.record(
"evidence-heartbeat",
result="nothing-to-report",
stage=stage,
detail={
"form": "heartbeat",
"outbox_depth": queued,
"completeness_claimed": False,
},
hub=False,
)
def drain_outbox(
writer: EvidenceWriter,
*,
audit_core_url: str = "",
) -> dict[str, Any]:
"""Best-effort delivery of queued load-bearing records.
Never called from a mutation path. An audit-core outage leaves files in
place and does not raise into a revoke/destroy handler.
"""
outbox = writer.evidence_dir / "outbox"
if not outbox.is_dir():
return {
"queued": 0,
"delivered": 0,
"failed": 0,
"skipped": 0,
"completeness_claimed": False,
}
files = sorted(path for path in outbox.glob("*.json") if path.is_file())
queued = len(files)
if not audit_core_url:
return {
"queued": queued,
"delivered": 0,
"failed": 0,
"skipped": queued,
"completeness_claimed": False,
}
delivered = 0
failed = 0
for path in files:
try:
payload = path.read_text(encoding="utf-8").encode()
req = urllib.request.Request(
audit_core_url.rstrip("/") + "/v1/events",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=3)
path.unlink()
delivered += 1
except (urllib.error.URLError, OSError, ValueError):
failed += 1
return {
"queued": queued,
"delivered": delivered,
"failed": failed,
"skipped": 0,
"completeness_claimed": False,
}

View file

@ -0,0 +1,164 @@
"""Load-bearing vs attributive evidence classification (§9.6).
The YAML file is the declaration. ``SHIPPED_RULES`` is the pin that makes
drift fail the test. No function here grants or denies an action based on
whether a local evidence record exists.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from secrets_engine.config import repo_root
from secrets_engine.errors import SecretsEngineError
KIND_LOAD_BEARING = "load-bearing"
KIND_ATTRIBUTIVE = "attributive"
KIND_HEARTBEAT = "heartbeat"
# First match wins. Destroy is always load-bearing; production control
# mutations are load-bearing; everything else is attributive.
SHIPPED_RULES = (
{
"id": "heartbeat",
"kind": KIND_HEARTBEAT,
"actions": ("evidence-heartbeat",),
"stages": ("build", "test", "prod", "unknown"),
},
{
"id": "destroy",
"kind": KIND_LOAD_BEARING,
"actions": ("lifecycle-destroy",),
"stages": ("build", "test", "prod", "unknown"),
},
{
"id": "production-control-mutation",
"kind": KIND_LOAD_BEARING,
"actions": (
"revoke",
"lifecycle-suspend",
"lifecycle-deactivate",
"provision",
),
"stages": ("prod",),
},
{
"id": "default-attributive",
"kind": KIND_ATTRIBUTIVE,
"actions": ("*",),
"stages": ("build", "test", "prod", "unknown"),
},
)
class EvidenceClassificationError(SecretsEngineError):
"""Classification file missing or malformed."""
exit_code = 1
@dataclass(frozen=True)
class EvidenceClass:
kind: str
rule_id: str
action: str
stage: str
queued_locally: bool
@property
def completeness_claimed(self) -> bool:
return False
def classification_path() -> Path:
override = os.environ.get("SECRETS_ENGINE_EVIDENCE_CLASSIFICATION", "")
if override:
return Path(override)
return repo_root() / "evidence-classification.yaml"
def _normalize_rules(raw: Any) -> tuple[dict[str, Any], ...]:
if not isinstance(raw, list) or not raw:
raise EvidenceClassificationError(
"evidence-classification.yaml must list at least one rule"
)
rules: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
raise EvidenceClassificationError("each classification rule must be a map")
kind = str(item.get("kind", ""))
if kind not in {KIND_LOAD_BEARING, KIND_ATTRIBUTIVE, KIND_HEARTBEAT}:
raise EvidenceClassificationError(f"unknown evidence kind {kind!r}")
actions = tuple(str(a) for a in item.get("actions") or ())
stages = tuple(str(s) for s in item.get("stages") or ())
if not actions or not stages:
raise EvidenceClassificationError(
f"rule {item.get('id')!r} needs actions and stages"
)
rules.append(
{
"id": str(item.get("id", "")),
"kind": kind,
"actions": actions,
"stages": stages,
}
)
return tuple(rules)
def load_classification_rules(
path: Path | None = None,
) -> tuple[dict[str, Any], ...]:
target = path or classification_path()
try:
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise EvidenceClassificationError(
f"unable to load evidence classification {target}: {exc}"
) from exc
if data.get("completeness_claimed") is not False:
raise EvidenceClassificationError(
f"{target} must declare completeness_claimed: false"
)
if data.get("no_control_branches_on_presence") is not True:
raise EvidenceClassificationError(
f"{target} must declare no_control_branches_on_presence: true"
)
return _normalize_rules(data.get("rules"))
def classify(
action: str,
stage: str,
*,
rules: tuple[dict[str, Any], ...] | None = None,
) -> EvidenceClass:
"""Return the evidence class for an action/stage. Never a permission."""
table = rules if rules is not None else load_classification_rules()
stage_key = stage if stage else "unknown"
for rule in table:
actions = rule["actions"]
stages = rule["stages"]
if "*" not in actions and action not in actions:
continue
if stage_key not in stages and "*" not in stages:
continue
kind = str(rule["kind"])
return EvidenceClass(
kind=kind,
rule_id=str(rule["id"]),
action=action,
stage=stage_key,
queued_locally=kind in {KIND_LOAD_BEARING, KIND_HEARTBEAT},
)
return EvidenceClass(
kind=KIND_ATTRIBUTIVE,
rule_id="implicit-attributive",
action=action,
stage=stage_key,
queued_locally=False,
)

View file

@ -0,0 +1,141 @@
"""Published PEP unreachable-engine stance (security-layer-model v0.7 §6.4).
Runtime reads ``pep-stance.yaml``. ``SHIPPED_STANCE`` is the pin that makes
drift between the published map and this module fail the conformance test.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import yaml
from secrets_engine.config import repo_root
from secrets_engine.errors import DecisionError
SHIPPED_STANCE = {
"build": "fail_open",
"test": "fail_open",
"prod": "fail_closed",
"unknown": "fail_closed",
}
REQUIRED_STAGES = tuple(SHIPPED_STANCE)
VALID_MODES = frozenset({"fail_open", "fail_closed"})
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
@dataclass(frozen=True)
class StanceApplication:
"""Named residue applied when access-engine is unreachable."""
stage: str
failure_mode: str
action: str
demo_exception: bool = False
decision_id: str = ""
def as_evidence(self) -> dict[str, object]:
payload: dict[str, object] = {
"stance_stage": self.stage,
"stance_failure_mode": self.failure_mode,
"stance_demo_exception": self.demo_exception,
}
if self.decision_id:
payload["stance_decision_id"] = self.decision_id
return payload
@dataclass(frozen=True)
class PepStanceMap:
stance: dict[str, str]
path: Path
def for_stage(self, stage: str) -> tuple[str, str]:
key = stage if stage in self.stance else "unknown"
mode = self.stance.get(key, "")
if mode not in VALID_MODES:
raise DecisionError(
f"pep-stance.yaml has no usable mode for stage {stage!r}"
)
return key, mode
def pep_stance_path() -> Path:
override = os.environ.get("SECRETS_ENGINE_PEP_STANCE", "")
if override:
return Path(override)
return repo_root() / "pep-stance.yaml"
def load_pep_stance(path: Path | None = None) -> PepStanceMap:
target = path or pep_stance_path()
try:
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise DecisionError(f"unable to load PEP stance map {target}: {exc}") from exc
raw = data.get("stance")
if not isinstance(raw, dict):
raise DecisionError(f"{target} is missing a stance map")
stance = {str(key): str(value) for key, value in raw.items()}
missing = [stage for stage in REQUIRED_STAGES if stage not in stance]
if missing:
raise DecisionError(
f"{target} is not total; missing stages {missing}"
)
unknown_modes = {
f"{stage}={mode}"
for stage, mode in stance.items()
if mode not in VALID_MODES
}
if unknown_modes:
raise DecisionError(f"{target} has invalid modes: {sorted(unknown_modes)}")
return PepStanceMap(stance=stance, path=target)
def demo_exception_enabled(cfg: Any) -> bool:
"""Three-factor throwaway exception; not a stance row."""
host = (urlparse(getattr(cfg, "bao_addr", "")).hostname or "").lower()
return (
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
and not getattr(cfg, "hub_url", "")
and host in LOOPBACK_HOSTS
)
def apply_unreachable_engine_stance(
cfg: Any,
entry: Any,
action: str,
*,
stance_map: PepStanceMap | None = None,
) -> StanceApplication:
"""Apply the published unreachable-engine residue for a live action.
``fail_closed`` without the demo exception raises ``DecisionError`` carrying
named stance fields. ``fail_open`` is the documented residue: continue to
the existing lane-approval check, which is itself a gap until T02.
"""
loaded = stance_map or load_pep_stance()
stage, mode = loaded.for_stage(getattr(entry, "stage", "unknown"))
demo = demo_exception_enabled(cfg)
applied = StanceApplication(
stage=stage,
failure_mode=mode,
action=action or "unknown",
demo_exception=bool(demo and mode == "fail_closed"),
)
if mode == "fail_closed" and not demo:
raise DecisionError(
f"production action '{applied.action}' requires a durable "
"access-engine decision record; live production remains disabled",
stance=applied.as_evidence(),
)
return applied
def with_decision(stance: StanceApplication, decision: Any) -> StanceApplication:
decision_id = str(getattr(decision, "id", "") or "")
return replace(stance, decision_id=decision_id)