Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions loopx/capabilities/periodic_report/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_SOURCE_STATUSES,
)
from ...control_plane.content_digest import ENVELOPED_SHA256_PATTERN
from ...control_plane.digest_envelope import sha256_envelope


SOURCE_RESULT_SCHEMA = "periodic_report_source_result_v0"
Expand Down Expand Up @@ -751,7 +752,7 @@ def _normalize_artifact_result(
if len(raw_content) > 1000000:
raise ValueError("artifact.content exceeds 1000000 characters")
content = raw_content
expected_digest = f"sha256:{hashlib.sha256(content.encode('utf-8')).hexdigest()}"
expected_digest = sha256_envelope(content.encode("utf-8"))
if artifact.get("content_digest") != expected_digest:
raise ValueError("artifact.content_digest does not match content")
document_digest = _text(
Expand All @@ -760,16 +761,13 @@ def _normalize_artifact_result(
if not ENVELOPED_SHA256_PATTERN.fullmatch(document_digest):
raise ValueError("artifact.document_digest must use sha256")
if expected_document is not None:
expected_document_digest = (
"sha256:"
+ hashlib.sha256(
json.dumps(
expected_document,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
expected_document_digest = sha256_envelope(
json.dumps(
expected_document,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
)
if document_digest != expected_document_digest:
raise ValueError("artifact.document_digest does not match document")
Expand Down
3 changes: 2 additions & 1 deletion loopx/capabilities/periodic_report/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from urllib.parse import unquote, urlsplit

from ...control_plane.content_digest import ENVELOPED_SHA256_PATTERN
from ...control_plane.digest_envelope import sha256_envelope
from .adapters import ARTIFACT_SCHEMA, DOCUMENT_SCHEMA
from .core import _normalize_trigger_receipt, _reject_raw_keys

Expand Down Expand Up @@ -83,7 +84,7 @@ def _canonical_json(value: object) -> str:


def _content_digest(content: str) -> str:
return f"sha256:{hashlib.sha256(content.encode('utf-8')).hexdigest()}"
return sha256_envelope(content.encode("utf-8"))


def _resource_root(value: object) -> str:
Expand Down
4 changes: 2 additions & 2 deletions loopx/capabilities/periodic_report/audience.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from typing import Any

from .core import _reject_raw_keys
from ...control_plane.digest_envelope import sha256_envelope

AUDIENCE_POLICY_SCHEMA = "periodic_report_audience_policy_v0"
AUDIENCE_RECIPIENT_SCHEMA = "periodic_report_audience_recipient_v0"
Expand Down Expand Up @@ -244,7 +244,7 @@ def _digest(value: object) -> str:
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def build_periodic_report_announcement_plan(
Expand Down
4 changes: 2 additions & 2 deletions loopx/capabilities/periodic_report/bindings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import hashlib
import json
import re
from collections.abc import Mapping, Sequence
Expand All @@ -17,6 +16,7 @@
_SINK_STATUSES,
)
from ...control_plane.content_digest import ENVELOPED_SHA256_PATTERN
from ...control_plane.digest_envelope import sha256_envelope

GENERATION_BUNDLE_SCHEMA = "periodic_report_generation_bundle_v0"
GENERATION_RECEIPT_SCHEMA = "periodic_report_generation_receipt_v0"
Expand Down Expand Up @@ -175,7 +175,7 @@ def _sha256(value: object) -> str:
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def _identity(value: object, *, prefix: str) -> str:
Expand Down
8 changes: 4 additions & 4 deletions loopx/capabilities/periodic_report/cadence_journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"""
from __future__ import annotations

import hashlib
import json
import re
from collections.abc import Callable, Mapping
Expand All @@ -17,16 +16,17 @@
from ...registry import atomic_write_json
from .cadence import report_cadence_window
from ...control_plane.content_digest import ENVELOPED_SHA256_PATTERN
from ...control_plane.digest_envelope import sha256_envelope

CADENCE_WINDOW_SCHEMA = "periodic_report_cadence_window_v0"
JOURNAL_SCHEMA = "periodic_report_cadence_journal_v0"
_ID = re.compile(r"^[a-z][a-z0-9_.:-]{2,127}$")


def _digest(value: object) -> str:
return "sha256:" + hashlib.sha256(json.dumps(
value, sort_keys=True, ensure_ascii=False, separators=(",", ":"),
).encode()).hexdigest()
return sha256_envelope(
json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()
)


def cadence_journal_path(root: Path, goal_id: str) -> Path:
Expand Down
4 changes: 2 additions & 2 deletions loopx/capabilities/periodic_report/incremental.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import hashlib
import json
import re
from collections.abc import Mapping, Sequence
Expand All @@ -11,6 +10,7 @@
from ...control_plane.content_digest import ENVELOPED_SHA256_PATTERN
from ...file_lock import LockAcquisitionPolicy, exclusive_file_lock
from ...registry import atomic_write_json, read_json
from ...control_plane.digest_envelope import sha256_envelope


PUBLICATION_CANDIDATE_SCHEMA = "periodic_report_publication_candidate_v0"
Expand All @@ -24,7 +24,7 @@ def _canonical_digest(value: object) -> str:
encoded = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def _identity(value: object, *, prefix: str) -> str:
Expand Down
4 changes: 2 additions & 2 deletions loopx/capabilities/periodic_report/machine_defaults.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import hashlib
import json
from collections.abc import Mapping
from datetime import datetime, timezone
Expand All @@ -11,6 +10,7 @@

from ...control_plane.content_digest import ENVELOPED_SHA256_PATTERN
from ...control_plane.todos.contract import normalize_todo_claimed_by
from ...control_plane.digest_envelope import sha256_envelope
from ..configuration_ui import resolve_capability_configuration
from ..machine_configuration.contract import (
MACHINE_CONFIGURATION_SCHEMA,
Expand Down Expand Up @@ -91,7 +91,7 @@ def _digest(value: object) -> str:
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def normalize_periodic_report_machine_defaults(
Expand Down
3 changes: 2 additions & 1 deletion loopx/capabilities/periodic_report/pending_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
InteractionProjectionHookRegistration,
)
from ...control_plane.effect_runtime import effect_runtime_result
from ...control_plane.digest_envelope import sha256_envelope
from ...history import load_registry
from .todo_source import read_report_todo_source
from ...registry import (
Expand Down Expand Up @@ -91,7 +92,7 @@ def _canonical_digest(value: object) -> str:
encoded = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def _intent_key(intent: Mapping[str, Any]) -> str:
Expand Down
17 changes: 8 additions & 9 deletions loopx/capabilities/periodic_report/post_writeback_hook.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import hashlib
import json
import warnings
from collections.abc import Mapping
Expand All @@ -11,6 +10,7 @@
POST_WRITEBACK_HOOK_RESULT_SCHEMA_VERSION,
PostWritebackHookRegistration,
)
from ...control_plane.digest_envelope import enveloped_sha256, sha256_envelope
from ...control_plane.goals.goal_frontier import (
build_goal_frontier_projection_from_summaries,
)
Expand Down Expand Up @@ -434,7 +434,9 @@ def evaluate_periodic_report_trigger_evaluation_intent(
"candidates": [{
"trigger_kind": "cadence_due", "observed_at": window["due_at"],
"source_ref": "cadence:" + window["window_id"],
"evidence_digest": "sha256:" + window["window_id"].removeprefix("cadence_"),
"evidence_digest": enveloped_sha256(
window["window_id"].removeprefix("cadence_")
),
"facts": {"due": True},
}],
})
Expand Down Expand Up @@ -470,7 +472,7 @@ def evaluate_periodic_report_trigger_evaluation_intent(
raise ValueError("periodic-report typed request is invalid")
requested_at = str(report_request["requested_at"])
request_id = str(report_request["request_id"])
evidence_digest = "sha256:" + hashlib.sha256(
evidence_digest = sha256_envelope(
json.dumps(
{
"request_id": request_id,
Expand All @@ -481,7 +483,7 @@ def evaluate_periodic_report_trigger_evaluation_intent(
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
)
return build_periodic_report_trigger_decision(
{
"schema_version": "periodic_report_trigger_request_v0",
Expand Down Expand Up @@ -524,11 +526,8 @@ def evaluate_periodic_report_trigger_evaluation_intent(
raise ValueError("periodic-report stage completion receipt is invalid")
completed_at = str(stage["completed_at"])
stage_identity = str(stage["stage_identity"])
evidence_digest = (
"sha256:"
+ hashlib.sha256(
json.dumps([stage_identity], separators=(",", ":")).encode()
).hexdigest()
evidence_digest = sha256_envelope(
json.dumps([stage_identity], separators=(",", ":")).encode()
)
request = {
"schema_version": "periodic_report_trigger_request_v0",
Expand Down
3 changes: 2 additions & 1 deletion loopx/capabilities/periodic_report/request_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

from ...agent_registry import registered_agent_ids_for_goal
from ...control_plane.digest_envelope import sha256_envelope
from ...extensions.hook_adapters import discover_extension_hook_adapters
from ...extensions.runtime import default_extension_state_file
from ...file_lock import exclusive_file_lock
Expand Down Expand Up @@ -87,7 +88,7 @@ def _digest(value: object) -> str:
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def _timestamp(value: object, label: str) -> str:
Expand Down
4 changes: 2 additions & 2 deletions loopx/capabilities/periodic_report/runtime_producer.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

import hashlib
import json
from collections.abc import Iterable, Mapping, Sequence
from datetime import datetime
from typing import Any

from ...rollout_event_log import ROLLOUT_EVENT_SCHEMA_VERSION
from ...control_plane.digest_envelope import sha256_envelope
from .core import (
_integer,
_object,
Expand Down Expand Up @@ -46,7 +46,7 @@ def _event_digest(event_ids: Sequence[str]) -> str:
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def _safe_durable_event(
Expand Down
4 changes: 2 additions & 2 deletions loopx/capabilities/periodic_report/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import hashlib
import heapq
import json
from collections.abc import Mapping, Sequence
Expand All @@ -11,6 +10,7 @@
from typing import Any

from ...registry import atomic_write_json, read_json
from ...control_plane.digest_envelope import sha256_envelope
from .incremental import read_periodic_report_publication_cursor
from .incremental import normalize_periodic_report_publication_cursor

Expand All @@ -33,7 +33,7 @@ def _canonical_digest(value: object) -> str:
encoded = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
return sha256_envelope(encoded)


def _text(value: object, label: str, *, maximum: int) -> str:
Expand Down
46 changes: 46 additions & 0 deletions loopx/control_plane/digest_envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""One owner for building the envelope that `content_digest` recognizes.

`loopx/control_plane/content_digest.py` decides what a stored SHA-256 looks like.
It is generated from its TypeScript value owner and deliberately exports nothing
but the two whole-value patterns, so it has no counterpart for the other half of
the same decision: writing the string. Producers that concatenate the prefix by
hand are named as that missing half in the guard's own docstring, and 94 sites in
`loopx/` did exactly that before this module (Refs #5336).

This is that counterpart, kept separate from the leaf on purpose: hashing bytes
is not a cross-runtime pattern, and the generator that emits the leaf refuses
syntax it cannot translate. What the two modules share is the decision, not a
copy of it -- every value returned here is checked against the owner's own
`ENVELOPED_SHA256_PATTERN` object, so a producer cannot emit a string the reader
would refuse, and neither module states the shape twice.

Callers that canonicalize before hashing keep doing that: the bytes are each
surface's own question, and the envelope is the shared one.
"""

from __future__ import annotations

import hashlib

from .content_digest import ENVELOPED_SHA256_PATTERN

DIGEST_ENVELOPE_PREFIX = "sha256:"


def enveloped_sha256(digest_hex: str) -> str:
"""Return a lowercase hex SHA-256 in its stored envelope.

Fails closed on a value the owner would not recognize, rather than emitting a
digest that only the writer believes is well formed.
"""

enveloped = f"{DIGEST_ENVELOPE_PREFIX}{digest_hex}"
if ENVELOPED_SHA256_PATTERN.fullmatch(enveloped) is None:
raise ValueError("content digest must be sha256:<64 lowercase hex characters>")
return enveloped


def sha256_envelope(data: bytes) -> str:
"""Hash ``data`` and return the digest in the stored envelope."""

return enveloped_sha256(hashlib.sha256(data).hexdigest())
4 changes: 2 additions & 2 deletions loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@
},
{
"site": "loopx/capabilities/periodic_report/pending_intent.py::<module>._active_delivery_subscription::codec_read:load_registry#1",
"line": 245,
"line": 246,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
Expand All @@ -335,7 +335,7 @@
},
{
"site": "loopx/capabilities/periodic_report/request_action.py::<module>._request_profile::codec_read:load_registry#1",
"line": 229,
"line": 230,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
Expand Down
Loading
Loading