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
**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)*
## Active Workstreams
@ -19,8 +19,9 @@ Progress: 1/3 done | workplan_id: `12798750-3eee-5d81-a411-c3c4c5ce2d2c`
**Open tasks:**
- ! 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).)*
- · Establish standing authority for bounded routine releases `8e2b4ed9`
*(wait: Automated sync after RPF-WP-0048-T02 soak; founder confirmation given by D2.)*
- ! 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
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`
*(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`
*(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)

View file

@ -243,7 +243,7 @@
| 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-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-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 |

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
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
error both during file sync and emission. Deterministic report-only
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",
"version",
"allowed_paths_digest",
"remote",
"branch",
"pushed_head",
):
value = raw.get(key)
if isinstance(value, str):
@ -206,9 +209,10 @@ def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]:
value = raw.get(key)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
result[key] = min(value, 1_000_000)
publish = raw.get("publish")
if isinstance(publish, bool):
result["publish"] = publish
for key in ("publish", "fast_forward_only", "published"):
value = raw.get(key)
if isinstance(value, bool):
result[key] = value
return result

View file

@ -1,5 +1,8 @@
"""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,
but Activity Core owns admission and unchanged queue carriage at its boundary.
It deliberately does not infer grants from prose, labels, profiles, or
@ -10,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import re
from typing import Any, Literal
from pydantic import (
@ -42,15 +46,69 @@ class CommitCount(BaseModel):
return self
class RepositoryGrant(BaseModel):
"""Versioned, local-only repository authority envelope."""
_REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_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)
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)
commit_count: CommitCount
publish: StrictBool
publish_scope: PublishScope | None = None
@field_validator("allowed_paths")
@classmethod
@ -61,33 +119,44 @@ class RepositoryGrant(BaseModel):
_validate_path_pattern(pattern)
return value
@field_validator("publish")
@classmethod
def _local_only(cls, value: bool) -> bool:
if value:
@model_validator(mode="after")
def _publish_matches_version(self) -> RepositoryGrant:
if self.version == "1":
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(
"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]:
"""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
def grant_id(self) -> str:
"""Match rein-aharness's canonical, path-order-independent grant id."""
return _digest(
{
"allowed_paths": sorted(self.allowed_paths),
"commit_count": {
"max": self.commit_count.max,
"min": self.commit_count.min,
},
"publish": self.publish,
"version": self.version,
}
)[:32]
identity: dict[str, Any] = {
"allowed_paths": sorted(self.allowed_paths),
"commit_count": {
"max": self.commit_count.max,
"min": self.commit_count.min,
},
"publish": self.publish,
"version": self.version,
}
if self.publish_scope is not None:
identity["publish_scope"] = self.publish_scope.canonical()
return _digest(identity)[:32]
@property
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")
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:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
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["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.