Add repository grant v2 publish scope and adopt daily security flow
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s
Build and Publish Container Image / build-and-push (push) Successful in 1m30s

ACTIVITY-WP-0046: grant v2 carries a publish scope (remote, branch, path
prefixes inside allowed_paths, fast-forward only) in its canonical grant id;
v1 stays local-only with unchanged ids.

ACTIVITY-WP-0047: disabled daily-security-flow definition dispatching the
the-custodian runner as an ops_run (CUST-WP-0079-T03).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 296847@bnt-lap001
Assistant-Session: 4f997d46-1b9d-4131-9699-0c353a215155
This commit is contained in:
tegwick 2026-10-13 21:08:15 +02:00
parent f9f2640f38
commit 9737577f0b
9 changed files with 372 additions and 29 deletions

View file

@ -2,7 +2,7 @@
# Custodian Brief — activity-core # Custodian Brief — activity-core
**Domain:** infotech **Domain:** infotech
**Last synced:** 2026-10-11 18:31 UTC **Last synced:** 2026-10-13 18:57 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams ## Active Workstreams
@ -19,8 +19,9 @@ Progress: 1/3 done | workplan_id: `12798750-3eee-5d81-a411-c3c4c5ce2d2c`
**Open tasks:** **Open tasks:**
- ! Adopt the declared resource set into railiance01 ArgoCD `af09865d` - ! Adopt the declared resource set into railiance01 ArgoCD `af09865d`
*(wait: Adopted 2026-09-27 (manual sync); automated sync waits on RPF-WP-0048-T02: soak evidence plus founder confirmation of scoped identity/checks (D2).)* *(wait: Automated sync after RPF-WP-0048-T02 soak; founder confirmation given by D2.)*
- · Establish standing authority for bounded routine releases `8e2b4ed9` - ! Establish standing authority for bounded routine releases `8e2b4ed9`
*(wait: RPF-IN-0011: return dedicated release principal/custody owner task and native acceptance; D2 scope already accepted)*
### Owner-return pickup adapter for State Hub STATE-WP-0094-T03 ### Owner-return pickup adapter for State Hub STATE-WP-0094-T03
Progress: 5/6 done | workplan_id: `e65f59d4-2272-557d-9071-734c88186d19` Progress: 5/6 done | workplan_id: `e65f59d4-2272-557d-9071-734c88186d19`
@ -45,7 +46,12 @@ Progress: 5/8 done | workplan_id: `e1bfb5a4-e647-592d-883f-fe8728ad2f5f`
- ! Agree the platform runner contract `a2d95c80` - ! Agree the platform runner contract `a2d95c80`
*(wait: Platform owner must accept the candidate request/receipt contract, execution authority checks, limits and credential scope before runtime wiring.)* *(wait: Platform owner must accept the candidate request/receipt contract, execution authority checks, limits and credential scope before runtime wiring.)*
- ! Refresh the existing backup consumer after the owner repairs the lane `020d19be` - ! Refresh the existing backup consumer after the owner repairs the lane `020d19be`
*(wait: KV4 provider repair is delivered. Current exact-primary projection needs repo-manager historical-prefix acceptance (owner request 6bf8ca98-ada1-43ab-83e7-207d4ab195fe); separate approved CNPG hostPath/Forgejo GitOps bundle delivery, ESO/env refresh and native consumer receipt remain. Founder per-change go is required before live promotion.)* *(wait: KV4 provider repair is delivered; repo-manager historical-prefix acceptance is delivered (receipt 39f2c067, 2026-10-06). Separate approved CNPG hostPath/Forgejo GitOps bundle delivery, ESO/env refresh and native consumer receipt remain. Founder per-change go is required before live promotion.)*
## Inbox Hygiene
**Stale unread:** 1 message(s) older than 3 day(s) — triage at session start.
**Missing thread_id:** 2 unread message(s) lack supersession chains.
--- ---
## MCP Orientation (when available) ## MCP Orientation (when available)

View file

@ -243,7 +243,7 @@
| task | ACTIVITY-WP-0040-T03 | done | — | workplans/ACTIVITY-WP-0040-frontend-patterns-feedback.md | | task | ACTIVITY-WP-0040-T03 | done | — | workplans/ACTIVITY-WP-0040-frontend-patterns-feedback.md |
| task | ACTIVITY-WP-0041-T01 | done | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | | task | ACTIVITY-WP-0041-T01 | done | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md |
| task | ACTIVITY-WP-0041-T02 | wait | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | | task | ACTIVITY-WP-0041-T02 | wait | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md |
| task | ACTIVITY-WP-0041-T03 | todo | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | | task | ACTIVITY-WP-0041-T03 | wait | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md |
| task | ACTIVITY-WP-0042-T01 | done | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md | | task | ACTIVITY-WP-0042-T01 | done | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md |
| task | ACTIVITY-WP-0042-T02 | wait | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md | | task | ACTIVITY-WP-0042-T02 | wait | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md |
| task | ACTIVITY-WP-0042-T03 | done | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md | | task | ACTIVITY-WP-0042-T03 | done | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md |

View file

@ -0,0 +1,57 @@
---
id: daily-security-flow
name: Daily Security Flow
enabled: false
owner: activity-core
governance: custodian
status: proposed
trigger:
type: cron
cron_expression: "30 7 * * *"
timezone: Europe/Berlin
misfire_policy: skip
---
# Daily Security Flow
Adopted from the-custodian CUST-WP-0079-T03, tracked in ACTIVITY-WP-0047.
Activity Core owns the schedule; the-custodian owns the runner
(`scripts/security_flow_daily.py`), its report and its delivery checkpoints.
Each fire emits one ops_run for a rein on the workstation profile, which runs a
fixed command in the-custodian checkout:
```text
python3 scripts/security_flow_daily.py --apply
```
The runner resolves hub decisions already recorded as resolved in repo decision
logs (the sanctioned `POST /decisions/{id}/resolve`), writes a deterministic
JSON report, and messages coding agents only when READY/UNBLOCKED change and the
founder only when ATTENDED is nonempty. Its per-recipient checkpoint and run
lock make a repeated or overlapping fire safe.
- Daily 07:30 Europe/Berlin; misfires are skipped, never replayed.
- No repository grant: the run reads repo files and changes nothing in any
repository. A rein must not commit or push for this run.
- Activity Core does not run the script itself (out of scope: general
execution); it dispatches and records the ops_run outcome.
**Not yet enabled.** Activation (ACTIVITY-WP-0047-T02) flips `enabled: true`
once the claiming rein confirms it can run the command against workstation
checkouts. Rollback: `enabled: false`.
```rule
id: run-daily-security-flow
condition: "True"
action:
task_template: Run the daily security flow
description: 'In the-custodian checkout run exactly: python3 scripts/security_flow_daily.py --apply. Report its exit code and the printed JSON report. Do not edit, commit or push any repository and do not send messages yourself; the script handles delivery.'
target_repo: the-custodian
priority: medium
labels: ["automated", "security-flow"]
harness_profile_ref: harness.agent-dev-local@1.1.1
execution_refs:
correlation_id: CUST-WP-0079-T03
assignment_ref: ACTIVITY-WP-0047-T01
```

View file

@ -267,6 +267,32 @@ types, unknown fields, and publication are rejected at definition sync and
again across the full emission batch. The admitted mapping is copied unchanged again across the full emission batch. The admitted mapping is copied unchanged
to the queue claim/read response and never merged into `execution_refs`. to the queue claim/read response and never merged into `execution_refs`.
Repository-grant v2 (ACTIVITY-WP-0046) is the publish-capable form. It keeps
the v1 fields, requires `publish: true`, and adds a mandatory `publish_scope`:
```yaml
repository_grant:
version: "2"
allowed_paths: [checkpoints/]
commit_count: {min: 1, max: 1}
publish: true
publish_scope:
remote: origin # plain remote name, never a URL
branch: main # plain branch name, no refs/, no ..
path_prefixes: [checkpoints/]
fast_forward_only: true # the only accepted value; no force push
```
Every publish path prefix must lie inside `allowed_paths`. A v1 grant with
`publish: true` or a `publish_scope`, and a v2 grant without one, are
rejected. The grant id is the first 32 hex characters of SHA-256 over the
canonical JSON (sorted keys, compact separators) of `allowed_paths` (sorted),
`commit_count`, `publish`, `version` and, for v2, `publish_scope` with sorted
`path_prefixes`. v1 grant ids are unchanged. The rein echoes the grant id in
completion evidence and must refuse any push the grant does not cover;
normalized evidence may add `remote`, `branch`, `pushed_head`,
`fast_forward_only` and `published`.
`ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` makes a missing profile ref an `ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` makes a missing profile ref an
error both during file sync and emission. Deterministic report-only error both during file sync and emission. Deterministic report-only
instructions are exempt because they create no execution request. The flag instructions are exempt because they create no execution request. The flag

View file

@ -198,6 +198,9 @@ def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]:
"acceptance_policy_id", "acceptance_policy_id",
"version", "version",
"allowed_paths_digest", "allowed_paths_digest",
"remote",
"branch",
"pushed_head",
): ):
value = raw.get(key) value = raw.get(key)
if isinstance(value, str): if isinstance(value, str):
@ -206,9 +209,10 @@ def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]:
value = raw.get(key) value = raw.get(key)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0: if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
result[key] = min(value, 1_000_000) result[key] = min(value, 1_000_000)
publish = raw.get("publish") for key in ("publish", "fast_forward_only", "published"):
if isinstance(publish, bool): value = raw.get(key)
result["publish"] = publish if isinstance(value, bool):
result[key] = value
return result return result

View file

@ -1,5 +1,8 @@
"""Typed repository mutation authority carried by Activity Core. """Typed repository mutation authority carried by Activity Core.
Version 1 is local-only. Version 2 adds an explicit publish scope (remote,
branch, path prefixes, fast-forward only) for reins that push their result.
The v1 schema mirrors the accepted rein-aharness repository-grant contract, The v1 schema mirrors the accepted rein-aharness repository-grant contract,
but Activity Core owns admission and unchanged queue carriage at its boundary. but Activity Core owns admission and unchanged queue carriage at its boundary.
It deliberately does not infer grants from prose, labels, profiles, or It deliberately does not infer grants from prose, labels, profiles, or
@ -10,6 +13,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
from typing import Any, Literal from typing import Any, Literal
from pydantic import ( from pydantic import (
@ -42,15 +46,69 @@ class CommitCount(BaseModel):
return self return self
class RepositoryGrant(BaseModel): _REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
"""Versioned, local-only repository authority envelope.""" _BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$")
class PublishScope(BaseModel):
"""Where a version-2 grant may push: one remote branch, fast-forward only."""
model_config = ConfigDict(extra="forbid", frozen=True) model_config = ConfigDict(extra="forbid", frozen=True)
version: Literal["1"] remote: str
branch: str
path_prefixes: list[str] = Field(min_length=1, max_length=20)
fast_forward_only: Literal[True]
@field_validator("remote")
@classmethod
def _validate_remote(cls, value: str) -> str:
if not _REMOTE_RE.fullmatch(value):
raise ValueError(f"remote must be a plain remote name: {value!r}")
return value
@field_validator("branch")
@classmethod
def _validate_branch(cls, value: str) -> str:
if (
not _BRANCH_RE.fullmatch(value)
or value == "HEAD"
or value.startswith("refs/")
or ".." in value
or "//" in value
or value.endswith(("/", ".", ".lock"))
):
raise ValueError(f"branch must be a plain branch name: {value!r}")
return value
@field_validator("path_prefixes")
@classmethod
def _validate_path_prefixes(cls, value: list[str]) -> list[str]:
if len(set(value)) != len(value):
raise ValueError("path_prefixes must not contain duplicates")
for pattern in value:
_validate_path_pattern(pattern)
return value
def canonical(self) -> dict[str, Any]:
return {
"branch": self.branch,
"fast_forward_only": self.fast_forward_only,
"path_prefixes": sorted(self.path_prefixes),
"remote": self.remote,
}
class RepositoryGrant(BaseModel):
"""Versioned repository authority envelope (v1 local-only, v2 publish)."""
model_config = ConfigDict(extra="forbid", frozen=True)
version: Literal["1", "2"]
allowed_paths: list[str] = Field(min_length=1, max_length=100) allowed_paths: list[str] = Field(min_length=1, max_length=100)
commit_count: CommitCount commit_count: CommitCount
publish: StrictBool publish: StrictBool
publish_scope: PublishScope | None = None
@field_validator("allowed_paths") @field_validator("allowed_paths")
@classmethod @classmethod
@ -61,33 +119,44 @@ class RepositoryGrant(BaseModel):
_validate_path_pattern(pattern) _validate_path_pattern(pattern)
return value return value
@field_validator("publish") @model_validator(mode="after")
@classmethod def _publish_matches_version(self) -> RepositoryGrant:
def _local_only(cls, value: bool) -> bool: if self.version == "1":
if value: if self.publish or self.publish_scope is not None:
raise ValueError(
"version 1 does not grant publication; publish must be false"
)
return self
if not self.publish or self.publish_scope is None:
raise ValueError( raise ValueError(
"version 1 does not grant publication; publish must be false" "version 2 requires publish: true and a publish_scope"
) )
return value for prefix in self.publish_scope.path_prefixes:
if not any(_covers(allowed, prefix) for allowed in self.allowed_paths):
raise ValueError(
f"publish path prefix {prefix!r} is outside allowed_paths"
)
return self
def payload(self) -> dict[str, Any]: def payload(self) -> dict[str, Any]:
"""Return the admitted field without semantic rewriting or inference.""" """Return the admitted field without semantic rewriting or inference."""
return self.model_dump(mode="python") return self.model_dump(mode="python", exclude_none=True)
@property @property
def grant_id(self) -> str: def grant_id(self) -> str:
"""Match rein-aharness's canonical, path-order-independent grant id.""" """Match rein-aharness's canonical, path-order-independent grant id."""
return _digest( identity: dict[str, Any] = {
{ "allowed_paths": sorted(self.allowed_paths),
"allowed_paths": sorted(self.allowed_paths), "commit_count": {
"commit_count": { "max": self.commit_count.max,
"max": self.commit_count.max, "min": self.commit_count.min,
"min": self.commit_count.min, },
}, "publish": self.publish,
"publish": self.publish, "version": self.version,
"version": self.version, }
} if self.publish_scope is not None:
)[:32] identity["publish_scope"] = self.publish_scope.canonical()
return _digest(identity)[:32]
@property @property
def acceptance_policy_id(self) -> str: def acceptance_policy_id(self) -> str:
@ -128,6 +197,12 @@ def _validate_path_pattern(pattern: str) -> None:
raise ValueError("path patterns cannot grant protected .git metadata") raise ValueError("path patterns cannot grant protected .git metadata")
def _covers(allowed: str, prefix: str) -> bool:
allowed_parts = allowed.rstrip("/").split("/")
prefix_parts = prefix.rstrip("/").split("/")
return prefix_parts[: len(allowed_parts)] == allowed_parts
def _digest(value: dict[str, Any]) -> str: def _digest(value: dict[str, Any]) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":")) payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest() return hashlib.sha256(payload.encode("utf-8")).hexdigest()

View file

@ -80,3 +80,54 @@ def test_ops_run_schema_has_separate_grant_and_close_identity_columns() -> None:
assert columns["repository_grant"].nullable is True assert columns["repository_grant"].nullable is True
assert columns["close_intent_digest"].nullable is True assert columns["close_intent_digest"].nullable is True
def _publish_grant(**scope_changes: object) -> dict:
scope = {
"remote": "origin",
"branch": "main",
"path_prefixes": ["checkpoints/"],
"fast_forward_only": True,
}
scope.update(scope_changes)
return _grant(
version="2",
allowed_paths=["checkpoints/"],
publish=True,
publish_scope=scope,
)
def test_v2_grant_carries_publish_scope_in_payload_and_identity() -> None:
raw = _publish_grant()
grant = RepositoryGrant.model_validate(raw)
narrower = RepositoryGrant.model_validate(
_publish_grant(path_prefixes=["checkpoints/2026/"])
)
assert grant.payload() == raw
assert grant.grant_id != narrower.grant_id
assert "publish_scope" not in RepositoryGrant.model_validate(_grant()).payload()
@pytest.mark.parametrize(
"value",
[
_publish_grant(fast_forward_only=False),
_publish_grant(remote="https://example.org/repo.git"),
_publish_grant(branch="refs/heads/main"),
_publish_grant(branch="feature/../main"),
_publish_grant(branch="-force"),
_publish_grant(path_prefixes=["docs/"]),
_publish_grant(path_prefixes=["checkpoints-other/"]),
_publish_grant(path_prefixes=[]),
_publish_grant(force=True),
{**_publish_grant(), "publish": False},
{**_publish_grant(), "version": "1"},
_grant(version="2", publish=True),
_grant(publish_scope=_publish_grant()["publish_scope"]),
],
)
def test_v2_grant_rejects_unsafe_or_inconsistent_publish_scope(value: dict) -> None:
with pytest.raises(RepositoryGrantError):
validate_repository_grant(value)

View file

@ -0,0 +1,62 @@
---
id: ACTIVITY-WP-0046
type: workplan
title: "Repository grant v2: publish scope for governed pushes (REINAH-WP-0003-T04)"
domain: infotech
repo: activity-core
status: active
owner: claude-activity-core
topic_slug: activity-core
created: "2026-10-13"
updated: "2026-10-13"
flavor: implementation
related: [REINAH-WP-0003, ACTIVITY-WP-0038, ASSEMBLY-WP-0010]
---
Origin: rein-aharness (message 3eb4fa11, 2026-10-13) needs the repository grant
that Activity Core carries on each ops_run to scope publication, so that
assembly-checkpoint can leave legacy substring routing before
`AGENT_HARNESS_LEGACY_APPROACHES_UNTIL` (2026-12-31). Founder chose option 1a
on 2026-10-13: Activity Core adds the scope; no temporary tenant-declared grant.
Grant v1 stays local-only and its grant ids do not change. v2 adds
`publish_scope` (remote, branch, path prefixes inside `allowed_paths`,
fast-forward only). Activity Core admits and carries the grant; rein-aharness
enforces it and refuses any push without it.
## Admission and carriage
```task
id: ACTIVITY-WP-0046-T01
status: done
priority: high
```
Extend `repository_grant.py` with `PublishScope` and version "2"; include the
scope in the canonical grant id; reject inconsistent v1/v2 combinations and
prefixes outside `allowed_paths`; accept publish evidence keys in
`glas_evidence`. Document the contract in `docs/ops-run-queue.md`. Tests in
`tests/test_repository_grant.py`.
## rein-aharness contract confirmation
```task
id: ACTIVITY-WP-0046-T02
status: wait
priority: high
```
rein-aharness confirms it computes the same v2 grant id and enforces the scope
(REINAH-WP-0003-T04). Wait: reply sent 2026-10-13.
## assembly-checkpoint definitions adopt v2
```task
id: ACTIVITY-WP-0046-T03
status: wait
priority: medium
```
assembly-economics declares a v2 grant on its checkpoint definitions and the
production image carrying this change is rolled out before 2026-12-15. Wait:
T02, then an image release through the normal GitOps path.

View file

@ -0,0 +1,62 @@
---
id: ACTIVITY-WP-0047
type: workplan
title: "Adopt the daily security-flow schedule (CUST-WP-0079-T03)"
domain: infotech
repo: activity-core
status: active
owner: claude-activity-core
topic_slug: activity-core
created: "2026-10-13"
updated: "2026-10-13"
flavor: implementation
related: [CUST-WP-0079]
---
Origin: the-custodian handed over the daily security-flow lane (message
117e538e, case `case:cross-owner:CUST-WP-0079-T03:activity-core`). The workstation
user systemd bus is unavailable, so no host timer can run it. Founder accepted
adoption on 2026-10-13.
Boundary: Activity Core owns the schedule and the ops_run record. the-custodian
owns `scripts/security_flow_daily.py`, its report and delivery checkpoints. A
rein runs the fixed command; Activity Core does not execute it (SCOPE: no
general execution). The run carries no repository grant.
## Source definition
```task
id: ACTIVITY-WP-0047-T01
status: done
priority: high
```
`activity-definitions/daily-security-flow.md`: daily 07:30 Europe/Berlin,
misfire skip, one unconditional rule emitting an ops_run on
`harness.agent-dev-local@1.1.1` with the fixed command and correlation
`CUST-WP-0079-T03`. Committed with `enabled: false`.
## Activation
```task
id: ACTIVITY-WP-0047-T02
status: wait
priority: high
```
Wait: the claiming rein (rein-aharness, workstation profile) confirms it can
run the command against workstation checkouts with hub access. Then flip
`enabled: true`, release through the normal image/GitOps path, admin-sync, and
run one manual trigger.
## First unassisted fire
```task
id: ACTIVITY-WP-0047-T03
status: wait
priority: medium
```
Observe one scheduled fire end to end (ops_run completed, report written, no
duplicate deliveries) and tell the-custodian so CUST-WP-0079-T03 can close.
Wait: T02.