Land attended sitting create now that CCR-2026-0026/0027 is live.
Platform verified create-only exchange; this shell cannot POST. create_sitting_approvals.py requires attended reader, skips c01, and refuses a non-loopback approval origin. Dry-run lists the seven memos. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
parent
8c03eb85c0
commit
c3742e27d2
8 changed files with 318 additions and 16 deletions
|
|
@ -61,9 +61,18 @@ Open in this order. One question each. No approve-all.
|
||||||
|
|
||||||
1. `uv run python tools/sitting_bind_preflight.py --origin https://decisions.coulomb.social`
|
1. `uv run python tools/sitting_bind_preflight.py --origin https://decisions.coulomb.social`
|
||||||
— `live_accept` must stay `open`.
|
— `live_accept` must stay `open`.
|
||||||
2. Owning requester creates eight `human_control=true` objects, required_count 1,
|
2. Sitting requester is **live** (CCR-2026-0026/0027, exchange proof 2026-09-15).
|
||||||
no entries. Carry the native id and `binding.digest`; do not invent a digest.
|
Create seven objects from an **attended** reader session (skips `c01`):
|
||||||
Platform custody (unallocated CCR): `docs/sitting-requester-custody-request.md`.
|
|
||||||
|
```sh
|
||||||
|
kubectl -n approval-engine port-forward svc/approval-engine 18281:8080
|
||||||
|
warden access informed-decision-sitting-requester-login --exec -- \
|
||||||
|
env INFD_APPROVAL_ORIGIN=http://127.0.0.1:18281 \
|
||||||
|
uv run python tools/create_sitting_approvals.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run (this shell): `uv run python tools/create_sitting_approvals.py --dry-run`.
|
||||||
|
Do not invent a digest. `c01` stays off this client until decided.
|
||||||
3. `uv run python tools/attach_compact_bindings.py --principal <exact-keycape-sub> --receipt <created.json>`
|
3. `uv run python tools/attach_compact_bindings.py --principal <exact-keycape-sub> --receipt <created.json>`
|
||||||
writes `bound/` copies. Unsigned drafts stay unsigned.
|
writes `bound/` copies. Unsigned drafts stay unsigned.
|
||||||
4. Flex Auth admits a **new** package pinning those eight `memo:` ids to the
|
4. Flex Auth admits a **new** package pinning those eight `memo:` ids to the
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@
|
||||||
"infd-batch-2026-09-14-decisions"
|
"infd-batch-2026-09-14-decisions"
|
||||||
],
|
],
|
||||||
"memo_count": 8,
|
"memo_count": 8,
|
||||||
"bind_path": "INFD-WP-0001-T08 historically proven; live accept open 2026-09-14T22:16:07Z; sitting not admitted",
|
"bind_path": "live accept open; sitting-requester live; attended create not yet run",
|
||||||
"note": "Unsigned drafts. Live accept reopened after audit-core rollout. Remaining: native approval receipts, live KeyCape subject, new Flex Auth package (not the T03 three-record mandate), then a human sitting. Operator packet: OPERATOR.md."
|
"note": "Unsigned drafts. CCR-2026-0026/0027 applied and create-only exchange proof verified 2026-09-15. Next: attended tools/create_sitting_approvals.py (skips c01), then Flex Auth package, then human sitting."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
"informed-decision-sitting-requester"
|
"informed-decision-sitting-requester"
|
||||||
],
|
],
|
||||||
"tokenLifetime": "15m",
|
"tokenLifetime": "15m",
|
||||||
"status": "requested",
|
"status": "applied",
|
||||||
"applied": false
|
"applied": true,
|
||||||
|
"ccrs": ["CCR-2026-0026", "CCR-2026-0027"],
|
||||||
|
"exchange_proof": "railiance-platform/docs/evidence/2026-09-15-sitting-requester-exchange.json"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
**For:** `key-cape` (same registration shape as `secrets-engine-requester`)
|
**For:** `key-cape` (same registration shape as `secrets-engine-requester`)
|
||||||
**Copied to:** `approval-engine` (`docs/keycape-service-registrations.md` —
|
**Copied to:** `approval-engine` (`docs/keycape-service-registrations.md` —
|
||||||
“no requester identity has been settled for `approval:create`”)
|
“no requester identity has been settled for `approval:create`”)
|
||||||
**Status:** requested 2026-09-14. **Not registered. Not in OpenBao. No secret
|
**Status:** **applied 2026-09-15** (CCR-2026-0026/0027, KeyCape row live,
|
||||||
is in this repository.** Custody request (no CCR id):
|
create-only exchange proof verified). No secret is in this repository.
|
||||||
`docs/sitting-requester-custody-request.md`.
|
Sittings are still INFD-WP-0002; platform did not POST. Attended create:
|
||||||
|
`tools/create_sitting_approvals.py`.
|
||||||
|
|
||||||
This is the missing presenter for compact Decision Memo sittings whose
|
This is the missing presenter for compact Decision Memo sittings whose
|
||||||
protected side effect is a **work-record update** (`INFD-WP-0002-T04`), not a
|
protected side effect is a **work-record update** (`INFD-WP-0002-T04`), not a
|
||||||
|
|
|
||||||
40
tests/test_create_sitting_approvals.py
Normal file
40
tests/test_create_sitting_approvals.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"create_sitting_approvals",
|
||||||
|
Path(__file__).resolve().parents[1] / "tools" / "create_sitting_approvals.py",
|
||||||
|
)
|
||||||
|
create = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(create)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_lists_seven_and_skips_c01():
|
||||||
|
report = create.dry_run()
|
||||||
|
assert report["posted"] is False
|
||||||
|
assert report["skipped"] == ["infd-20260914-c01"]
|
||||||
|
assert len(report["memo_ids"]) == 7
|
||||||
|
assert "infd-20260914-c01" not in report["memo_ids"]
|
||||||
|
assert report["memo_ids"][0] == "infd-20260914-c02"
|
||||||
|
assert report["memo_ids"][-1] == "infd-20260914-d04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_attended_refuses_this_shell():
|
||||||
|
with pytest.raises(ValueError, match="attended_reader_required"):
|
||||||
|
create.require_attended()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"origin,ok",
|
||||||
|
[
|
||||||
|
("http://127.0.0.1:18281", True),
|
||||||
|
("http://approval-engine.approval-engine.svc.cluster.local:8080", True),
|
||||||
|
("https://evil.example", False),
|
||||||
|
("http://10.43.103.108:8080", False),
|
||||||
|
(None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_approval_origin_is_loopback_or_in_cluster_only(origin, ok):
|
||||||
|
assert create._approval_origin_ok(origin) is ok
|
||||||
|
|
@ -9,7 +9,7 @@ REG = json.loads((ROOT / "docs" / "keycape-sitting-requester-registration.json")
|
||||||
INTENTS = json.loads((ROOT / "docs" / "batches" / "2026-09-14" / "approval-create-intents.json").read_text())
|
INTENTS = json.loads((ROOT / "docs" / "batches" / "2026-09-14" / "approval-create-intents.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
def test_sitting_requester_is_create_only_and_unapplied():
|
def test_sitting_requester_is_create_only_and_applied():
|
||||||
assert REG["clientId"] == "informed-decision-sitting-requester"
|
assert REG["clientId"] == "informed-decision-sitting-requester"
|
||||||
assert REG["audience"] == "approval-engine"
|
assert REG["audience"] == "approval-engine"
|
||||||
assert REG["allowedScopes"] == ["approval:create"]
|
assert REG["allowedScopes"] == ["approval:create"]
|
||||||
|
|
@ -17,7 +17,8 @@ def test_sitting_requester_is_create_only_and_unapplied():
|
||||||
assert REG["clientType"] == "confidential"
|
assert REG["clientType"] == "confidential"
|
||||||
assert REG["serviceSubject"] == "informed-decision"
|
assert REG["serviceSubject"] == "informed-decision"
|
||||||
assert REG["tenant"] == "tenant:platform"
|
assert REG["tenant"] == "tenant:platform"
|
||||||
assert REG["applied"] is False
|
assert REG["applied"] is True
|
||||||
|
assert REG["ccrs"] == ["CCR-2026-0026", "CCR-2026-0027"]
|
||||||
assert "redirect" not in json.dumps(REG).lower()
|
assert "redirect" not in json.dumps(REG).lower()
|
||||||
forbidden = {"approval:approve", "approval:consume", "approval:read", "openid"}
|
forbidden = {"approval:approve", "approval:consume", "approval:read", "openid"}
|
||||||
assert forbidden.isdisjoint(REG["allowedScopes"])
|
assert forbidden.isdisjoint(REG["allowedScopes"])
|
||||||
|
|
|
||||||
247
tools/create_sitting_approvals.py
Normal file
247
tools/create_sitting_approvals.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""Create compact-sitting approval objects (INFD-WP-0002-T03).
|
||||||
|
|
||||||
|
Attended reader only. Does not bind, consume, or print CLIENT_SECRET.
|
||||||
|
Skips infd-20260914-c01 until its create_client is decided.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
INTENTS = ROOT / "docs" / "batches" / "2026-09-14" / "approval-create-intents.json"
|
||||||
|
RECEIPT = ROOT / "docs" / "evidence" / "2026-09-15-sitting-approval-creates.json"
|
||||||
|
POLICY = "workload-kv-read-informed-decision-sitting-requester-client"
|
||||||
|
KV = "platform/data/workloads/informed-decision/sitting-requester"
|
||||||
|
SIBLING = "platform/data/workloads/secrets-engine/approval-requester"
|
||||||
|
PARENT = "platform/metadata/workloads/informed-decision"
|
||||||
|
ISSUER = "https://kc.coulomb.social"
|
||||||
|
CLIENT_ID = "informed-decision-sitting-requester"
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def http(url, *, body=None, headers=None):
|
||||||
|
req = Request(url, data=body, headers=headers or {})
|
||||||
|
try:
|
||||||
|
with build_opener(ProxyHandler({}), NoRedirect()).open(req, timeout=20) as response:
|
||||||
|
content = response.read(1048577)
|
||||||
|
status = response.status
|
||||||
|
except HTTPError as error:
|
||||||
|
status = error.code
|
||||||
|
content = error.read(1048577)
|
||||||
|
error.close()
|
||||||
|
if len(content) > 1048576:
|
||||||
|
raise ValueError("response_too_large")
|
||||||
|
if not content:
|
||||||
|
return status, {}
|
||||||
|
return status, json.loads(content)
|
||||||
|
|
||||||
|
|
||||||
|
def require_attended() -> None:
|
||||||
|
if Path.home().parent.name != ".warden-attended-login" or os.getenv("BAO_TOKEN") or os.getenv("VAULT_TOKEN"):
|
||||||
|
raise ValueError("attended_reader_required")
|
||||||
|
|
||||||
|
|
||||||
|
def postable_intents(data: dict) -> list[dict]:
|
||||||
|
rows = [row for row in data.get("intents") or [] if row.get("create_client") == CLIENT_ID]
|
||||||
|
if len(rows) != 7:
|
||||||
|
raise ValueError("expected seven postable sitting intents")
|
||||||
|
for row in rows:
|
||||||
|
binding = row["binding"]
|
||||||
|
if binding.get("actor") != "informed-decision":
|
||||||
|
raise ValueError("binding.actor must be informed-decision")
|
||||||
|
if set(binding) != {"action", "actor", "principal", "purpose", "target"}:
|
||||||
|
raise ValueError("binding must be the five native fields")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_origin_ok(origin: str | None) -> bool:
|
||||||
|
if not origin:
|
||||||
|
return False
|
||||||
|
if origin.startswith("http://127.0.0.1:") and origin.count("/") == 2:
|
||||||
|
return True
|
||||||
|
return origin in {
|
||||||
|
"http://approval-engine.approval-engine.svc.cluster.local:8080",
|
||||||
|
"http://approval-engine.approval-engine.svc:8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run() -> dict:
|
||||||
|
data = json.loads(INTENTS.read_text())
|
||||||
|
rows = postable_intents(data)
|
||||||
|
return {
|
||||||
|
"kind": "informed-decision-sitting-create-dry-run",
|
||||||
|
"posted": False,
|
||||||
|
"memo_ids": [row["memo_id"] for row in rows],
|
||||||
|
"skipped": [row["memo_id"] for row in data["intents"] if row.get("create_client") != CLIENT_ID],
|
||||||
|
"requester_client": CLIENT_ID,
|
||||||
|
"human_control": True,
|
||||||
|
"pdp_path": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="list postable memos; no credentials, no POST")
|
||||||
|
parser.add_argument("--approval-origin", help="approval-engine origin; or INFD_APPROVAL_ORIGIN")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.dry_run:
|
||||||
|
print(json.dumps(dry_run(), indent=2, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
require_attended()
|
||||||
|
origin = args.approval_origin or os.environ.get("INFD_APPROVAL_ORIGIN")
|
||||||
|
if not _approval_origin_ok(origin):
|
||||||
|
raise ValueError("approval_origin_required")
|
||||||
|
except ValueError as exc:
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if RECEIPT.exists():
|
||||||
|
raise ValueError("receipt_already_exists")
|
||||||
|
data = json.loads(INTENTS.read_text())
|
||||||
|
rows = postable_intents(data)
|
||||||
|
receipt = {
|
||||||
|
"observed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"status": "failed",
|
||||||
|
"phase": "preflight",
|
||||||
|
"requests": [],
|
||||||
|
"credential_values_emitted": False,
|
||||||
|
"human_entries_created": False,
|
||||||
|
"skipped": [row["memo_id"] for row in data["intents"] if row.get("create_client") != CLIENT_ID],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_create(rows, origin.rstrip("/"), receipt)
|
||||||
|
finally:
|
||||||
|
RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n")
|
||||||
|
print(json.dumps({"status": receipt["status"], "phase": receipt["phase"], "count": len(receipt["requests"])}))
|
||||||
|
return 0 if receipt["status"] == "created" else 2
|
||||||
|
|
||||||
|
|
||||||
|
def _create(rows, origin, receipt):
|
||||||
|
import jwt
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def bao(*args):
|
||||||
|
p = subprocess.run(["bao", *args], capture_output=True, text=True, timeout=20)
|
||||||
|
if p.returncode:
|
||||||
|
raise ValueError("metadata_failed")
|
||||||
|
return json.loads(p.stdout)
|
||||||
|
|
||||||
|
def capabilities(result):
|
||||||
|
if isinstance(result, dict):
|
||||||
|
result = result.get("data", result).get("capabilities", result)
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
def is_deny(result):
|
||||||
|
caps = set(capabilities(result))
|
||||||
|
return caps <= {"deny"} or not caps.intersection({"read", "create", "update", "delete", "list", "patch", "sudo"})
|
||||||
|
|
||||||
|
lookup = bao("token", "lookup", "-format=json")["data"]
|
||||||
|
policies = set(lookup.get("policies", [])) | set(lookup.get("identity_policies", []))
|
||||||
|
if POLICY not in policies or policies - {POLICY, "default"} or not lookup.get("entity_id") or not 0 < int(lookup.get("ttl") or 0) <= 900:
|
||||||
|
raise ValueError("reader_identity_failed")
|
||||||
|
if capabilities(bao("token", "capabilities", "-format=json", KV)) != ["read"]:
|
||||||
|
raise ValueError("reader_scope_failed")
|
||||||
|
if not is_deny(bao("token", "capabilities", "-format=json", SIBLING)):
|
||||||
|
raise ValueError("reader_scope_failed")
|
||||||
|
if not is_deny(bao("token", "capabilities", "-format=json", PARENT)):
|
||||||
|
raise ValueError("reader_scope_failed")
|
||||||
|
helper = Path.home() / ".vault-token"
|
||||||
|
info = helper.lstat()
|
||||||
|
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_uid != os.getuid():
|
||||||
|
raise ValueError("private_helper_required")
|
||||||
|
payload = bao("read", "-format=json", KV)
|
||||||
|
body = payload.get("data", payload)
|
||||||
|
inner = body.get("data", body)
|
||||||
|
secret = inner.get("CLIENT_SECRET") if isinstance(inner, dict) else None
|
||||||
|
if (body.get("metadata") or {}).get("version") != 1 or not secret:
|
||||||
|
raise ValueError("requester_delivery_failed")
|
||||||
|
|
||||||
|
def exchange(scope, credential=secret):
|
||||||
|
auth = __import__("base64").b64encode((CLIENT_ID + ":" + credential).encode()).decode()
|
||||||
|
return http(
|
||||||
|
ISSUER + "/token",
|
||||||
|
body=urlencode({"grant_type": "client_credentials", "scope": scope}).encode(),
|
||||||
|
headers={"Authorization": "Basic " + auth, "Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
|
||||||
|
status, tokens = exchange("approval:create")
|
||||||
|
if status != 200 or "access_token" not in tokens:
|
||||||
|
raise ValueError("requester_exchange_failed")
|
||||||
|
token = tokens["access_token"]
|
||||||
|
status, jwks = http(ISSUER + "/jwks")
|
||||||
|
if status != 200:
|
||||||
|
raise ValueError("jwks_failed")
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
keys = [key for key in jwks["keys"] if key["kid"] == header.get("kid")]
|
||||||
|
if header.get("alg") != "RS256" or len(keys) != 1:
|
||||||
|
raise ValueError("signing_key_failed")
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
jwt.PyJWK.from_dict(keys[0]).key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
issuer=ISSUER,
|
||||||
|
audience="approval-engine",
|
||||||
|
options={"strict_aud": True, "require": ["sub", "iat", "exp", "iss", "aud"]},
|
||||||
|
)
|
||||||
|
roles = claims.get("roles")
|
||||||
|
if isinstance(roles, str):
|
||||||
|
roles = [roles]
|
||||||
|
expected = {
|
||||||
|
"sub": "informed-decision",
|
||||||
|
"tenant": "tenant:platform",
|
||||||
|
"principal_type": "service",
|
||||||
|
"scope": "approval:create",
|
||||||
|
}
|
||||||
|
if any(claims.get(k) != v for k, v in expected.items()) or roles != ["informed-decision-sitting-requester"]:
|
||||||
|
raise ValueError("requester_claims_failed")
|
||||||
|
if int(claims["exp"]) - int(claims["iat"]) != 900:
|
||||||
|
raise ValueError("requester_claims_failed")
|
||||||
|
for scope in ("approval:approve", "approval:consume", "approval:read"):
|
||||||
|
if exchange(scope)[0] != 400:
|
||||||
|
raise ValueError("excess_scope_not_refused")
|
||||||
|
del secret
|
||||||
|
receipt["phase"] = "requester_verified"
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
body = {
|
||||||
|
"binding": row["binding"],
|
||||||
|
"validity": {"not_before": now.isoformat(), "expires_at": (now + timedelta(hours=24)).isoformat()},
|
||||||
|
"required_count": 1,
|
||||||
|
"human_control": True,
|
||||||
|
"pdp_path": False,
|
||||||
|
}
|
||||||
|
receipt["phase"] = "create_attempt:" + row["memo_id"]
|
||||||
|
RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n")
|
||||||
|
status, result = http(
|
||||||
|
origin + "/v1/approvals",
|
||||||
|
body=json.dumps(body).encode(),
|
||||||
|
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
binding = (result or {}).get("binding") or {}
|
||||||
|
if (
|
||||||
|
status != 201
|
||||||
|
or result.get("status") != "requested"
|
||||||
|
or result.get("entries")
|
||||||
|
or binding.get("human_control") is not True
|
||||||
|
or not isinstance(binding.get("digest"), str)
|
||||||
|
):
|
||||||
|
raise ValueError("request_creation_requires_reconciliation")
|
||||||
|
receipt["requests"].append({"memo_id": row["memo_id"], "approval": result})
|
||||||
|
receipt.update(status="created", phase="seven_unapproved_requests_created")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -152,10 +152,12 @@ no redirect). Intents for the eight bindings are in
|
||||||
`docs/batches/2026-09-14/approval-create-intents.json` (`posted: false`;
|
`docs/batches/2026-09-14/approval-create-intents.json` (`posted: false`;
|
||||||
`c01` create-client undecided). No secret, no POST, no bind.
|
`c01` create-client undecided). No secret, no POST, no bind.
|
||||||
|
|
||||||
2026-09-14 23:00 UTC — **custody requested from railiance-platform, no CCR
|
2026-09-15 — **requester live; sittings still wait on attended create +
|
||||||
id allocated.** `docs/sitting-requester-custody-request.md` asks for a new
|
human bind.** RPF-WP-0042 finished: CCR-2026-0026/0027 applied, exchange
|
||||||
KV path `platform/workloads/informed-decision/sitting-requester` (verifier +
|
proof verified, no sitting POST. `tools/create_sitting_approvals.py`
|
||||||
attended reader), not a widening of CCR-2026-0024/0025. Task stays `wait`.
|
requires attended reader and posts seven intents (`c01` skipped). Flex
|
||||||
|
Auth package still waits on those native ids. This shell is not an
|
||||||
|
attended session. Task stays `wait`.
|
||||||
|
|
||||||
## Feed outcomes back to State Hub without hub-authoring
|
## Feed outcomes back to State Hub without hub-authoring
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue