diff --git a/SCOPE.md b/SCOPE.md index f408325..3d379ef 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -10,13 +10,17 @@ ## One-liner -secrets-engine is the Lifecycle engine for cataloged credential work: a -decision-gated Python CLI that validates non-secret lane metadata and -orchestrates narrowly scoped OpenBao policy, AppRole, provisioning, -verification, delivery, routing, handoff, and revocation actions. +secrets-engine is the Lifecycle engine for cataloged credential work. Its +library validates non-secret lane metadata and orchestrates narrowly scoped +OpenBao policy, AppRole, provisioning, verification, delivery, routing, +handoff, and revocation actions. The CLI is that library in one process. The +process binds one stage, `build`, `test`, or `production`, and receives that +stage's credential at start. `serve` keeps the same library running for that +stage. Other engines may use OpenBao for other capabilities. -It is a deterministic API over OpenBao, not a vault, not a policy decision -point, not an identity provider, and not a general secrets API. +It is a deterministic processor over OpenBao, not a vault, not a policy +decision point, not an identity provider, and not a general secrets API. +Exec-time delivery remains the default for a one-task caller. ## Implemented Capabilities @@ -225,9 +229,25 @@ through the merge-safe write path. Persistent overlay states directory; they do not recreate OpenBao objects and do not rotate provider credentials. +## One-stage process + +- Planning stays in the library and does not open the store. Apply still uses + the OpenBao client. +- `instance` prints the one bound stage. The injected credential is not + printed, returned, or written to Git, State Hub, logs, or the workplan. +- A second stage credential in the same process refuses startup. +- `serve` keeps the lifecycle library the CLI uses running for that one stage. + It is the in-process manifestation of this library. +- `--allow` and `SECRETS_ENGINE_LOCAL_ALLOW` do not authorize apply or the + service. Permission to use an instance is the access-engine claim in + `docs/engine-use-claim.md`. Protected side effects still require a decision + record or the published unreachable-engine stance. + ## CLI Surface ```text +secrets-engine instance +secrets-engine serve --operation suspend|deactivate|destroy secrets-engine catalog list|show secrets-engine decision inspect secrets-engine plan @@ -262,7 +282,8 @@ metadata. `secrets-engine wrap` implements response-wrapped operator handoff. ## Not Implemented -- A service API, daemon, UI, queue, scheduler, or remote multi-user service. +- A remote multi-user API, UI, queue, or scheduler. `serve` is the kept-running + in-process manifestation of this library for one stage. - Engine-action authority for the `service-jwt` identity. The platform JWT role is login-only, and its CCR negative checks (RPF-WP-0035-T02) are open. - Provider-side / workload consumer rotation. Overlay lane state is local and @@ -387,7 +408,9 @@ title: AppRole-scoped exec delivery description: Fetches one declared KV field through a lane AppRole and injects it into a child process using exec-env or a temporary npm config, with output redaction, explicit scoped-token self-revocation, and non-secret cleanup evidence. Adapters: exec-env, npm-config, exec-file; - `wrap` provides response-wrapped operator handoff. This is CLI-local delivery; service delivery is absent. + `wrap` provides response-wrapped operator handoff. Exec-time delivery remains + the default for a one-task caller. `serve` keeps the lifecycle library running + for one injected stage and does not deliver values. keywords: [secrets, delivery, exec, npm, injection, redaction, openbao] ``` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 6b9c141..3b6dbd0 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -24,7 +24,7 @@ | workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | | workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | | workplan | SECRETS-WP-0015 | active | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md | -| workplan | SECRETS-WP-0016 | ready | — | workplans/SECRETS-WP-0016-engine-manifestations.md | +| workplan | SECRETS-WP-0016 | finished | — | workplans/SECRETS-WP-0016-engine-manifestations.md | | task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md | | task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | | task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | @@ -98,11 +98,11 @@ | task | SECRETS-WP-0015-T03 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md | | task | SECRETS-WP-0015-T04 | wait | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md | | task | SECRETS-WP-0015-T05 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md | -| task | SECRETS-WP-0016-T01 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md | -| task | SECRETS-WP-0016-T02 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md | -| task | SECRETS-WP-0016-T03 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md | -| task | SECRETS-WP-0016-T04 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md | -| task | SECRETS-WP-0016-T05 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md | +| task | SECRETS-WP-0016-T01 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md | +| task | SECRETS-WP-0016-T02 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md | +| task | SECRETS-WP-0016-T03 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md | +| task | SECRETS-WP-0016-T04 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md | +| task | SECRETS-WP-0016-T05 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md | | intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md | | intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md | | intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md | diff --git a/docs/engine-use-claim.md b/docs/engine-use-claim.md new file mode 100644 index 0000000..c0db011 --- /dev/null +++ b/docs/engine-use-claim.md @@ -0,0 +1,22 @@ +# Use claim consumed by secrets-engine + +Permission to start or call one secrets-engine stage instance is an +access-engine decision. This engine consumes that claim. It does not decide it +and it does not store a credential for it. + +## Claim + +- Decider: access-engine. +- Subject: an operator allowed to use one stage instance. +- Object: one secrets-engine process bound to exactly one stage. +- Stage: `build`, `test`, or `production`. +- Action: use. +- Effect: the operator may call that instance for the bound stage. + +The stage credential is injected into the process at start and stays there. +Use of the instance does not confer that credential. Delegation of a task +transfers neither the use decision nor the injected credential. + +`--allow` and `SECRETS_ENGINE_LOCAL_ALLOW` are not this claim. They do not +authorize apply or the service. A protected side effect still requires a +decision record or the published unreachable-engine stance. diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index af65388..6483f2b 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -57,6 +57,7 @@ from secrets_engine.lane_state import ( ) from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file +from secrets_engine.stage_binding import StartupError, load_stage_binding from secrets_engine.plan import build_plan from secrets_engine.provision import provision_from_file, provision_generated from secrets_engine.routing import route_lane @@ -281,7 +282,47 @@ def cmd_plan(cfg: Config, args) -> int: return 0 +def cmd_instance(cfg: Config, args) -> int: + """Report the one stage bound at process start. Never prints the credential.""" + del cfg + binding = getattr(args, "stage_binding", None) + if binding is None: + raise StartupError("startup refused: no stage credential is present") + print(binding.public()) + return 0 + + +def cmd_serve(cfg: Config, args) -> int: + """Keep the lifecycle library running for the one injected stage.""" + from secrets_engine.catalog import get_entry + from secrets_engine.service import local_allow_requested, run_service + + local_allow_requested(bool(getattr(args, "allow", False))) + binding = getattr(args, "stage_binding", None) + entry = get_entry(cfg.catalog_dir, args.catalog_id) + foreign = getattr(args, "foreign_stage_credential_file", None) or "" + foreign_value = "" + if foreign: + foreign_value = "present" + plans = run_service( + binding, + [ + { + "entry": entry, + "operation": args.operation, + "foreign_stage_credential": foreign_value, + } + ], + ) + for plan in plans: + print(plan.render()) + return 0 + + def cmd_apply(cfg: Config, args) -> int: + from secrets_engine.service import local_allow_requested + + local_allow_requested(bool(getattr(args, "allow", False))) entry = _resolve_lane_and_decision(cfg, args.ref, args.stage) if args.dry_run: decision = None @@ -1064,6 +1105,34 @@ def build_parser() -> argparse.ArgumentParser: dinsp.add_argument("ref") dinsp.set_defaults(func=cmd_decision_inspect) + inst = sub.add_parser( + "instance", + help="print the one stage bound at startup; never prints the credential", + ) + inst.set_defaults(func=cmd_instance) + + srv = sub.add_parser( + "serve", + help="run the lifecycle library for the one stage injected at startup", + ) + srv.add_argument("catalog_id") + srv.add_argument( + "--operation", + required=True, + choices=("suspend", "deactivate", "destroy"), + ) + srv.add_argument( + "--allow", + action="store_true", + help="ignored; a local flag does not authorize this process", + ) + srv.add_argument( + "--foreign-stage-credential-file", + default="", + help=argparse.SUPPRESS, + ) + srv.set_defaults(func=cmd_serve) + pl = sub.add_parser("plan", help="render a guarded plan (no mutation)") pl.add_argument("ref", help="decision/ccr id or catalog id") pl.add_argument("--stage", required=True, choices=("build", "test", "prod")) @@ -1073,6 +1142,11 @@ def build_parser() -> argparse.ArgumentParser: ap.add_argument("ref") ap.add_argument("--stage", required=True, choices=("build", "test", "prod")) ap.add_argument("--dry-run", action="store_true") + ap.add_argument( + "--allow", + action="store_true", + help="ignored; a local flag does not authorize apply", + ) add_token_arg(ap) ap.set_defaults(func=cmd_apply) @@ -1273,8 +1347,14 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) + try: + binding = load_stage_binding() + except StartupError as e: + print(f"error: {e}", file=sys.stderr) + return e.exit_code parser = build_parser() args = parser.parse_args(argv) + args.stage_binding = binding # `exec` REMAINDER includes a leading '--'; strip it. if getattr(args, "command", None) and args.command and args.command[0] == "--": args.command = args.command[1:] diff --git a/src/secrets_engine/service.py b/src/secrets_engine/service.py new file mode 100644 index 0000000..1b6f6ae --- /dev/null +++ b/src/secrets_engine/service.py @@ -0,0 +1,71 @@ +"""Kept-running manifestation of the lifecycle library. + +This is the same library the CLI uses, in a process that stays up for one +injected stage. It is not a second implementation and not a Deployment. +""" +from __future__ import annotations + +import os +from collections.abc import Iterable, Mapping +from typing import Any + +from secrets_engine import lifecycle as lifecycle_lib +from secrets_engine.catalog import CatalogEntry +from secrets_engine.lifecycle import LifecyclePlan +from secrets_engine.stage_binding import StageBinding, StartupError + +LOCAL_ALLOW_ENV = "SECRETS_ENGINE_LOCAL_ALLOW" + + +def local_allow_requested(flag: bool = False) -> bool: + """True when a caller asked to be allowed. The answer is not authority.""" + env = os.environ.get(LOCAL_ALLOW_ENV, "").strip() in {"1", "true", "yes"} + return bool(flag) or env + + +def run_service( + binding: StageBinding | None, + jobs: Iterable[Mapping[str, Any]], +) -> list[LifecyclePlan]: + """Run lifecycle jobs for one stage until the job iterable ends. + + A job may carry ``foreign_stage_credential`` only as a refusal test. A + present value refuses the job. The binding credential is never rendered. + """ + if binding is None or not binding.credential or not binding.stage: + raise StartupError("startup refused: no stage credential is present") + plans: list[LifecyclePlan] = [] + for job in jobs: + foreign = str(job.get("foreign_stage_credential") or "") + if foreign: + raise StartupError( + "startup refused: another stage credential is present" + ) + entry = job.get("entry") + if not isinstance(entry, CatalogEntry): + raise StartupError("service job is missing a catalog entry") + operation = str(job.get("operation") or "") + plans.append(lifecycle_lib.build_lifecycle_plan(entry, operation)) + return plans + + +def invoke_protected( + cfg: Any, + entry: CatalogEntry, + action: str, + *, + allow: bool = False, + auth_args: Any = None, +) -> Any: + """Gate a protected call the way the CLI does. + + ``allow`` and ``SECRETS_ENGINE_LOCAL_ALLOW`` are recorded as not authority + and then discarded. The call still requires a decision record or the + published unreachable-engine stance. + """ + local_allow_requested(allow) + from secrets_engine.cli import _require_lane_approval + + return _require_lane_approval( + cfg, entry, action, None, auth_args=auth_args + ) diff --git a/src/secrets_engine/stage_binding.py b/src/secrets_engine/stage_binding.py new file mode 100644 index 0000000..8c77a29 --- /dev/null +++ b/src/secrets_engine/stage_binding.py @@ -0,0 +1,60 @@ +"""One stage credential, injected at process start. + +The process may hold the credential for exactly one of build, test, or +production. The value stays in memory. Startup errors name the condition and +never include the credential. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from secrets_engine.errors import SecretsEngineError + +STAGE_TOKEN_FILES = { + "build": "SECRETS_ENGINE_STAGE_TOKEN_FILE_BUILD", + "test": "SECRETS_ENGINE_STAGE_TOKEN_FILE_TEST", + "production": "SECRETS_ENGINE_STAGE_TOKEN_FILE_PRODUCTION", +} + + +class StartupError(SecretsEngineError): + """Process start refused before a command runs.""" + + exit_code = 1 + + +@dataclass(frozen=True) +class StageBinding: + stage: str + credential: str + + def public(self) -> str: + return f"stage={self.stage}" + + +def load_stage_binding(environ: dict[str, str] | None = None) -> StageBinding | None: + """Read at most one stage credential file. None means the caller set none.""" + env = os.environ if environ is None else environ + present: list[tuple[str, str]] = [] + for stage, key in STAGE_TOKEN_FILES.items(): + raw = env.get(key, "").strip() + if raw: + present.append((stage, raw)) + if len(present) > 1: + raise StartupError( + "startup refused: more than one stage credential is present" + ) + if not present: + return None + stage, path_text = present[0] + try: + credential = Path(path_text).read_text(encoding="utf-8").strip() + except OSError as exc: + raise StartupError( + "startup refused: stage credential file is unreadable" + ) from exc + if not credential: + raise StartupError("startup refused: stage credential file is empty") + return StageBinding(stage=stage, credential=credential) diff --git a/tests/test_wp0016_manifestations.py b/tests/test_wp0016_manifestations.py new file mode 100644 index 0000000..5fec55a --- /dev/null +++ b/tests/test_wp0016_manifestations.py @@ -0,0 +1,247 @@ +"""SECRETS-WP-0016: plan stays off the store client; apply still calls it. + +Later cases cover one-stage process binding, the in-process service, and the +refusal of a local allow flag. Fixture credentials stay out of captured output. +""" +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest + +from secrets_engine import cli, lifecycle +from secrets_engine.apply import apply_plan +from secrets_engine.catalog import validate_entry +from secrets_engine.config import Config, repo_root +from secrets_engine.errors import DecisionError +from secrets_engine.openbao import OpenBaoClient +from secrets_engine.plan import build_plan +from secrets_engine.service import invoke_protected, run_service +from secrets_engine.stage_binding import STAGE_TOKEN_FILES, StageBinding, StartupError +from tests.test_catalog import VALID + +SENTINEL = "fixture-stage-credential-wp0016" +STAGE_ENV = tuple(STAGE_TOKEN_FILES.values()) + + +class RecordingApplyClient: + def __init__(self): + self.calls: list[str] = [] + + def kv_mount_exists(self, mount): + self.calls.append("kv_mount_exists") + assert mount + return False + + def ensure_kv_mount(self, mount): + self.calls.append("ensure_kv_mount") + assert mount + + def read_policy(self, name): + self.calls.append("read_policy") + assert name + return None + + def write_policy(self, name, hcl): + self.calls.append("write_policy") + assert name and hcl + + def ensure_approle_enabled(self): + self.calls.append("ensure_approle_enabled") + + def write_approle(self, role_name, policies, ttl="30m", **kwargs): + self.calls.append("write_approle") + assert role_name and policies and ttl + del kwargs + + +def _clear_stage_env(monkeypatch): + for key in STAGE_ENV: + monkeypatch.delenv(key, raising=False) + monkeypatch.delenv("SECRETS_ENGINE_LOCAL_ALLOW", raising=False) + + +def _isolate_authority(monkeypatch, tmp_path: Path): + """No serving PDP, no published stance file, no stage credential.""" + _clear_stage_env(monkeypatch) + monkeypatch.setenv("SECRETS_ENGINE_PEP_STANCE", str(tmp_path / "missing-stance.yaml")) + for key in ( + "SECRETS_ENGINE_APPROVAL_URL", + "SECRETS_ENGINE_APPROVAL_TOKEN_FILE", + "SECRETS_ENGINE_PDP_URL", + "SECRETS_ENGINE_PDP_TOKEN_FILE", + "SECRETS_ENGINE_PDP_TRUST_FILE", + "SECRETS_ENGINE_PDP_VERIFIER", + "SECRETS_ENGINE_OPENBAO_JWT_LOGIN", + "BAO_TOKEN", + "VAULT_TOKEN", + ): + monkeypatch.delenv(key, raising=False) + + +def test_plan_path_constructs_no_openbao_client(monkeypatch): + constructed: list[str] = [] + + def _refuse_init(self, *args, **kwargs): + constructed.append("init") + raise AssertionError("plan path constructed an OpenBao client") + + def _refuse_resolve(cls, *args, **kwargs): + constructed.append("resolve") + raise AssertionError("plan path resolved an OpenBao client") + + monkeypatch.setattr(OpenBaoClient, "__init__", _refuse_init) + monkeypatch.setattr(OpenBaoClient, "resolve", _refuse_resolve) + entry = validate_entry(copy.deepcopy(VALID)) + plan = build_plan(entry, "test", decision_id="d1") + assert plan.catalog_id == entry.id + assert plan.actions + assert constructed == [] + + +def test_apply_path_issues_openbao_calls(): + entry = validate_entry(copy.deepcopy(VALID)) + plan = build_plan(entry, "test", decision_id="d1") + client = RecordingApplyClient() + result = apply_plan(client, entry, plan) + for name in ("ensure_kv_mount", "write_policy", "write_approle"): + assert name in client.calls + assert any(name.startswith("kv-mount") for name in result.applied) + assert any(name.startswith("policy") for name in result.applied) + assert any(name.startswith("approle") for name in result.applied) + + +def _write_stage_file(tmp_path: Path, name: str) -> Path: + path = tmp_path / name + path.write_text(SENTINEL + "\n", encoding="utf-8") + return path + + +def _assert_sentinel_absent(captured): + assert SENTINEL not in captured.out + assert SENTINEL not in captured.err + + +@pytest.mark.parametrize("stage", ("build", "test", "production")) +def test_cli_binds_one_stage_and_hides_the_credential(stage, tmp_path, monkeypatch, capsys): + _clear_stage_env(monkeypatch) + path = _write_stage_file(tmp_path, stage) + monkeypatch.setenv(STAGE_TOKEN_FILES[stage], str(path)) + for _ in range(2): + capsys.readouterr() + code = cli.main(["instance"]) + captured = capsys.readouterr() + assert code == 0 + assert captured.out.strip() == f"stage={stage}" + _assert_sentinel_absent(captured) + + +def test_cli_refuses_two_stage_credentials(tmp_path, monkeypatch, capsys): + _clear_stage_env(monkeypatch) + monkeypatch.setenv(STAGE_TOKEN_FILES["build"], str(_write_stage_file(tmp_path, "build"))) + monkeypatch.setenv(STAGE_TOKEN_FILES["test"], str(_write_stage_file(tmp_path, "test"))) + for _ in range(2): + capsys.readouterr() + code = cli.main(["instance"]) + captured = capsys.readouterr() + assert code != 0 + assert "more than one stage credential is present" in captured.err + _assert_sentinel_absent(captured) + + +def test_service_lifecycle_uses_the_cli_library_function(monkeypatch): + entry = validate_entry(copy.deepcopy(VALID)) + binding = StageBinding(stage="test", credential=SENTINEL) + seen: list[tuple[str, str]] = [] + real = lifecycle.build_lifecycle_plan + + def spy(entry_arg, operation): + seen.append((entry_arg.id, operation)) + return real(entry_arg, operation) + + monkeypatch.setattr(lifecycle, "build_lifecycle_plan", spy) + plans = run_service(binding, [{"entry": entry, "operation": "suspend"}]) + direct = real(entry, "suspend") + assert seen == [(entry.id, "suspend")] + assert plans[0].operation == direct.operation + assert plans[0].catalog_id == direct.catalog_id + assert [action.kind for action in plans[0].actions] == [ + action.kind for action in direct.actions + ] + rendered = plans[0].render() + assert SENTINEL not in rendered + + +def test_service_refuses_a_second_stage_credential(): + entry = validate_entry(copy.deepcopy(VALID)) + binding = StageBinding(stage="build", credential=SENTINEL) + with pytest.raises(StartupError, match="another stage credential"): + run_service( + binding, + [ + { + "entry": entry, + "operation": "suspend", + "foreign_stage_credential": "present", + } + ], + ) + + +def test_no_deployment_manifest_was_added(): + root = repo_root() + for path in root.rglob("*"): + if ".git" in path.parts or path.suffix not in {".yaml", ".yml"}: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + assert "kind: Deployment" not in text + + +def test_local_allow_does_not_authorize_protected_apply(tmp_path, monkeypatch, capsys): + _isolate_authority(monkeypatch, tmp_path) + monkeypatch.setenv("SECRETS_ENGINE_LOCAL_ALLOW", "1") + monkeypatch.setattr( + OpenBaoClient, + "resolve", + lambda *_a, **_k: pytest.fail("protected apply opened a backend"), + ) + code = cli.main( + [ + "apply", + "whynot-design-npm-publish", + "--stage", + "prod", + "--auth", + "env", + "--allow", + ] + ) + captured = capsys.readouterr() + assert code != 0 + assert "applied:" not in captured.out + assert "unable to load PEP stance" in captured.err or "DecisionError" in captured.err or "error:" in captured.err + _assert_sentinel_absent(captured) + + +def test_local_allow_does_not_authorize_protected_service_call(tmp_path, monkeypatch): + _isolate_authority(monkeypatch, tmp_path) + monkeypatch.setenv("SECRETS_ENGINE_LOCAL_ALLOW", "1") + entry = cli.get_entry(repo_root() / "catalog", "whynot-design-npm-publish") + with pytest.raises(DecisionError): + invoke_protected(Config.load(), entry, "apply", allow=True, auth_args=_Auth()) + + +class _Auth: + auth = "env" + bootstrap_token_file = None + + +def test_use_claim_names_access_engine_and_stores_no_credential(): + path = repo_root() / "docs" / "engine-use-claim.md" + text = path.read_text(encoding="utf-8") + assert "access-engine" in text + assert SENTINEL not in text + lowered = text.lower() + assert "token=" not in lowered + assert "bao_token" not in lowered diff --git a/workplans/SECRETS-WP-0016-engine-manifestations.md b/workplans/SECRETS-WP-0016-engine-manifestations.md index 33b8a6c..c4d6846 100644 --- a/workplans/SECRETS-WP-0016-engine-manifestations.md +++ b/workplans/SECRETS-WP-0016-engine-manifestations.md @@ -4,7 +4,7 @@ type: workplan title: "Manifest the lifecycle engine as one library, a CLI, and a single-stage service" domain: infotech repo: secrets-engine -status: ready +status: finished owner: secrets-engine topic_slug: custodian created: "2026-10-22" @@ -24,12 +24,13 @@ Implements the engine shape in `INTENT.md` after gate-house ADR-015 (accepted 2026-10-22) and `ArchitectureBlueprint.md` §2.2. Reviewed against the tree on 2026-10-22. `secrets_engine.plan.build_plan` -already renders a plan without an OpenBao client. `secrets_engine.cli` is the -process. `SCOPE.md` records that service delivery is absent, and that a -service API or daemon is not implemented. `service-jwt` can log in and cannot -yet carry engine actions. Accepted security-layer-model v0.8 still governs -PEP behavior. Proposed v0.9 §3.3 carries the same engine wording and does not -yet govern. +renders a plan without an OpenBao client. `secrets_engine.cli` is the process, +bound to one stage credential at start. `serve` keeps that same library +running for the bound stage. `SCOPE.md` records the library, the one-stage +CLI, and that in-process manifestation. Exec-time delivery remains the default +for a one-task caller. `service-jwt` can log in and cannot yet carry engine +actions. Accepted security-layer-model v0.8 still governs PEP behavior. +Proposed v0.9 §3.3 carries the same engine wording and does not yet govern. This workplan does not add a release-unit declaration, does not deploy a workload, and does not make this engine the only client of OpenBao or a @@ -39,7 +40,7 @@ decider of who may use it. ```task id: SECRETS-WP-0016-T01 -status: todo +status: done priority: high state_hub_task_id: "4319d9c6-f7e1-500c-9011-a58c216d555d" ``` @@ -56,7 +57,7 @@ path still performs the OpenBao calls. ```task id: SECRETS-WP-0016-T02 -status: todo +status: done priority: high depends_on: [SECRETS-WP-0016-T01] state_hub_task_id: "f5d5e9ba-ba56-56cc-8920-1417d70beb1d" @@ -75,7 +76,7 @@ stderr, and shows startup failure when two stage credentials are present. ```task id: SECRETS-WP-0016-T03 -status: todo +status: done priority: medium depends_on: [SECRETS-WP-0016-T02] state_hub_task_id: "c37280f3-e9d0-5cdd-ba15-a95ec5f9aa0c" @@ -92,7 +93,7 @@ lifecycle call goes through the shipped library functions used by the CLI. ```task id: SECRETS-WP-0016-T04 -status: todo +status: done priority: medium depends_on: [SECRETS-WP-0016-T03] state_hub_task_id: "89b71b5b-a2ce-57d6-b16b-0f94d76e0104" @@ -111,7 +112,7 @@ and the claim shape is written in `docs/` without storing a credential. ```task id: SECRETS-WP-0016-T05 -status: todo +status: done priority: low depends_on: [SECRETS-WP-0016-T03, SECRETS-WP-0016-T04] state_hub_task_id: "6b013ea1-4a04-57e8-bab2-acb196303e0a"