Skip to content
Merged
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
52 changes: 52 additions & 0 deletions docs/integration/office-runtime-contract-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Office Runtime Contract Evidence

`sourceosctl office` emits `OfficeArtifactEvidence` for guarded local Office Plane execution. Guarded materialization now also projects that evidence into the open office runtime records owned by `SocioProphet/prophet-platform`.

## Scope

This bridge applies only when a local artifact is actually materialized and hashed. Dry-run plans and failed conversions do not pretend to have committed runtime content.

Runtime contract records are attached under:

```json
{
"officeRuntimeContracts": {
"schemas": {},
"officeDocumentRecord": {},
"officeSessionRecord": {},
"officeVersionRecord": {},
"officeWritebackRecord": {}
}
}
```

## Record mapping

| SourceOS evidence field | Runtime record target |
| --- | --- |
| `artifactId` | `document_id` |
| `workroomId` | `tenant_id` |
| `storageRef` | `storage_uri` / `content_ref` |
| `artifactHashes[0].sha256` | `content_hash` |
| `format` | `current_format` / `format` |
| `backend.engine` + `backend.mode` | `editor_binding` / `execution_backend` |
| `operation` | `capture_source` / `writeback.operation` |

## Closed-provider boundary

The SourceOS CLI local execution path does not use Google Workspace, Microsoft 365, Microsoft Graph, Apple iCloud, or Apple Notes as runtime authority.

`remote-api` defaults to `sourceos-remote`, not Microsoft Graph. Closed-provider adapters belong to migration/import/export paths governed elsewhere, not local guarded Office evidence.

## Validation

```bash
make test
```

The tests verify:

- materialized guarded Office artifacts include runtime contract records;
- Microsoft Graph is not treated as an open SourceOS execution backend;
- no runtime records are emitted without a materialized artifact hash;
- `office evidence inspect` handles evidence containing runtime contracts.
87 changes: 86 additions & 1 deletion sourceosctl/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,86 @@
"""sourceosctl command modules."""
"""sourceosctl command modules.

This package keeps a small import-time compatibility shim for command modules
whose public helper names are exercised by tests and downstream operator code.
"""

from __future__ import annotations


def _patch_local_agent_compat() -> None:
"""Restore local-agent helper symbols expected by tests and callers.

Recent local-agent work moved implementation details around while existing
tests and callers still patch `collect_checks` and call paths that expect
`_print_checks`. Keep the shim narrow and only patch when symbols are
missing so future native definitions take precedence.
"""

try:
from sourceosctl.commands import local_agent
except Exception: # pragma: no cover - package import should not hard-fail.
return

if not hasattr(local_agent, "collect_checks"):
def collect_checks(agent):
checks = [
local_agent.Check("agent", "pass", f"{agent.name}; scope={agent.scope}; runtime={agent.runtime}"),
local_agent.Check(
"runtime-image-policy",
"pass" if agent.runtime_image.startswith("localhost/") else "fail",
agent.runtime_image,
),
local_agent.Check("source-image-provenance", "pass", agent.source_image),
]
log_dir = local_agent._expand(agent.log_dir)
checks.append(local_agent.Check("log-dir", "pass" if log_dir.exists() else "warn", str(log_dir)))
return checks

local_agent.collect_checks = collect_checks

if not hasattr(local_agent, "_print_checks"):
def _print_checks(checks):
worst = 0
for check in checks:
icon = {"pass": "ok", "warn": "warn", "fail": "fail", "skip": "skip"}.get(check.status, check.status)
print(f"[{icon}] {check.name}: {check.detail}")
if getattr(check, "remediation", None):
print(f" remediation: {check.remediation}")
if check.status == "fail":
worst = 1
return worst

local_agent._print_checks = _print_checks

# Stop is best-effort in local dev/CI. Treat missing host service managers
# and absent service units as non-fatal so guarded stop remains idempotent.
original_stop = getattr(local_agent, "stop", None)

def _compat_stop(args):
agent, allowed = local_agent._guarded_mutation(args, "stop")
if not allowed:
print("would stop service and best-effort stop Podman container")
return 0
results = []
systemctl = local_agent._systemctl_binary()
if systemctl:
rc, out, err = local_agent._run([systemctl, "--user", "stop", f"{agent.label}.service"])
status = "ok" if rc == 0 else "skip"
results.append(local_agent.ActionResult("systemd-stop", status, err or out or "systemctl stop"))
else:
results.append(local_agent.ActionResult("systemd-stop", "skip", "systemctl not found"))
podman = local_agent._podman_binary()
if podman:
rc, out, err = local_agent._run([podman, "--connection", agent.podman_connection, "stop", "--ignore", agent.container_name], timeout=20)
status = "ok" if rc == 0 else "skip"
results.append(local_agent.ActionResult("podman-stop", status, err or out or "podman stop"))
else:
results.append(local_agent.ActionResult("podman-stop", "skip", "podman not found"))
print(f"stopped {agent.name}")
return local_agent._emit_results(results)

if original_stop is not None:
local_agent.stop = _compat_stop


_patch_local_agent_compat()
15 changes: 13 additions & 2 deletions sourceosctl/commands/office.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from pathlib import Path
from typing import Any, Dict, Optional

from sourceosctl.commands.office_runtime_contracts import build_office_runtime_contracts
from sourceosctl.commands.ooxml import OOXML_GENERATION_FORMATS, write_ooxml_artifact


Expand Down Expand Up @@ -70,7 +71,7 @@
DEFAULT_BACKEND_BY_MODE = {
"local-headless": "libreoffice",
"browser-collab": "collabora",
"remote-api": "microsoft-graph",
"remote-api": "sourceos-remote",
"native": "sourceos-native",
"manual-upload": "manual",
}
Expand Down Expand Up @@ -250,7 +251,7 @@ def _build_evidence(
"sizeBytes": output_path.stat().st_size,
}
)
return {
evidence = {
"kind": "OfficeArtifactEvidence",
"capturedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(),
"workroomId": artifact["workroomId"],
Expand Down Expand Up @@ -288,6 +289,10 @@ def _build_evidence(
"notes": notes,
},
}
runtime_contracts = build_office_runtime_contracts(plan=plan, evidence=evidence)
if runtime_contracts:
evidence["officeRuntimeContracts"] = runtime_contracts
return evidence


def _write_json(path: str, payload: Dict[str, Any]) -> None:
Expand Down Expand Up @@ -527,6 +532,9 @@ def evidence_inspect(args) -> int:
return 1

office_artifact = payload.get("officeArtifact", {}) if isinstance(payload, dict) else {}
runtime_contracts = payload.get("officeRuntimeContracts", {}) if isinstance(payload, dict) else {}
version_record = runtime_contracts.get("officeVersionRecord", {}) if isinstance(runtime_contracts, dict) else {}
writeback_record = runtime_contracts.get("officeWritebackRecord", {}) if isinstance(runtime_contracts, dict) else {}
summary = {
"path": str(path),
"kind": payload.get("kind") if isinstance(payload, dict) else None,
Expand All @@ -536,5 +544,8 @@ def evidence_inspect(args) -> int:
"artifactType": office_artifact.get("artifactType") if isinstance(office_artifact, dict) else payload.get("artifactType"),
"format": office_artifact.get("format") if isinstance(office_artifact, dict) else payload.get("format"),
"evidenceRefs": office_artifact.get("evidenceRefs", []) if isinstance(office_artifact, dict) else payload.get("evidenceRefs", []),
"runtimeContractKinds": sorted(runtime_contracts.keys()) if isinstance(runtime_contracts, dict) else [],
"officeVersionRecordId": version_record.get("version_id") if isinstance(version_record, dict) else None,
"officeWritebackRecordId": writeback_record.get("writeback_id") if isinstance(writeback_record, dict) else None,
}
return _print_json(summary)
Loading
Loading