Skip to content

fix(runtime-witness): record the execution state, and attribute a failure to the stage it happened at - #333

Draft
PhysShell wants to merge 3 commits into
mainfrom
claude/current-work-tasks-vw2wqz
Draft

fix(runtime-witness): record the execution state, and attribute a failure to the stage it happened at#333
PhysShell wants to merge 3 commits into
mainfrom
claude/current-work-tasks-vw2wqz

Conversation

@PhysShell

Copy link
Copy Markdown
Owner

The producer half of #331. Consumer PR: PhysShell/OwnAudit#61 — land that one first, so the consumer is fail-closed before this collector starts publishing the final contract shape.

Что и зачем

docs/runtime-witness-operations.md держит три состояния врозь в exit-кодах (0 — смотрели, свидетеля нет; 1 — смотрели, свидетель есть; 2 — не смотрели), потому что не смотреть и посмотреть и ничего не найти — разные исходы. Слой персистентности схлопывал ровно это различие: отказанный attach не писал runtime.json вовсе, и отсутствие файла означало «не оценивали» или «не запускали» или «раннер умер» или «запись не удалась» или «потеряно при передаче». Теперь каждая evaluation, для которой запрошена persistence, оставляет запись состояния — при этом невзятый вердикт по-прежнему не пишется.

The defect

The exit-code contract keeps three states apart on purpose. The persistence layer then collapsed exactly that distinction, because a refused attach wrote nothing at all:

artifact present  ->  some evaluated outcome
artifact absent   ->  not evaluated OR never invoked OR runner died before
                      invocation OR persistence failed OR lost in transit
                      OR an older format nothing reads any more

Absence had too many preimages to carry meaning. Now it carries none:

Absence of a record means no durable knowledge, never a semantic outcome.

What changed

1. The durable record. Every run given --out writes one, stating what happened:

execution.state Exit Carries
observed 1 scope, retained
clean 0 scope, retained
not_evaluated 2 reason.code, reason.stage, reason.detail, optional reason.policy_in_force
error 2 error.classification, error.stage

What must not come back is a verdict nobody earned: not_evaluated and error carry no verdict and no retained key at all — not even retained: [], which downstream reads as "looked, found nothing" and would re-create the collapse one layer up.

scope is required for an evaluated state, not decorative: a clean that does not say what it looked at cannot mean "nothing was there", and consumers route it down the schema-violation path. It names the population the verdict covered (instances_on_heap, instances_reachable, instances_durably_retained) separately from the budgets that bounded only the display (sample_budget, max_hops_budget), so a reader can tell a number that constrained the verdict from one that did not.

2. Stage attribution — the second commit, and the one worth reading. The first cut set a single attached bool after RetentionWalker's constructor, which does AttachToProcess and ClrVersions.FirstOrDefault and CreateRuntime. Two consequences, both real:

  • a live process that opened fine and turned out not to be managed threw with attached still false, so under a restricting ptrace_scope it came back labelled refused-attach — a permission claim about a step where permission had already been granted;
  • AttachAdvice was called unconditionally in the catch block, so any exception from the walk printed "PERMISSION failure, Yama refused it" on stderr while the record correctly said execution.state=error.

The stages are now explicit — open-target → create-runtime → walk — and RetentionWalker splits accordingly (OpenLiveTarget/OpenDumpTarget, then Create taking ownership; finally disposes a target whose ownership never transferred). Only open-target may yield refused-attach and only it prints the ptrace advice; create-runtime is unreadable-target; walk is error. reason.stage is recorded so a reader can check the attribution instead of trusting it.

3. The claim is bounded. Yama being active is not Yama having caused this failure — the kernel does not tell a tracer which check rejected it, and a live process under ptrace_scope=1 can fail to open for unrelated reasons. The field is policy_in_force: what was observed, not a culprit. The stderr wording and the code comment say the same, so the enum cannot be read as smarter than the evidence behind it.

4. Two overclaims from the first commit, corrected in place. verdict is command-specific — roots has a vocabulary (RETAINED / OBSERVED_ONLY / ABSENT), census asks a different question and has none — so the doc no longer lists it among what every evaluated state carries; the code never wrote one for census, and inventing one for schema symmetry would add a word nobody measured. And the invariant is documented as implemented: every evaluation for which persistence was requested leaves a record. A run without --out asked for no durable output, and a standalone tool has no other publication point.

Как проверено

Installed the .NET 8 SDK and ran everything; nothing here was accepted by reading.

  • python tests/run_tests.py → exit 0, wpf 28/28
  • RetentionPath selftest → classifier, verdict, record and stage-attribution contracts; 0 warnings, 0 errors
  • scripts/flagship-demo.sh bad and ok against a live net8 processobserved with a 1000-instance scope; clean
  • all three not_evaluated codes exercised live: usage-error, dead pid → open-target/unreadable-target, live unmanaged process → create-runtime/unreadable-target with zero PERMISSION lines on stderr
  • python audit/runtime/ingest.py --selftest → 26/26

Not verified locally, deliberately stated: a real Yama-denied attach. This kernel has no Yama, so the live refusal path first executes in this PR's CI — which is part of why it is open now rather than later. The deterministic half (create-runtime never citing a policy; a dump read never citing one) is pinned as a pure function in the selftest.

CI moves with the contract. The denied-attach gate previously asserted "no artifact written" — the behaviour being fixed — and now asserts the refusal is recorded, names the stage and the policy in force, and carries no verdict. Two target-free assertions run on every platform, a second case under the same restricting policy proves a non-attach failure does not borrow it, and the flagship demo checks that both variants record a scope.

Тип изменения

  • fix — исправление бага
  • docs — документация
  • refactor / chore / test / ci — без изменения поведения

Связанные issue

Refs #331. Consumer: PhysShell/OwnAudit#61.

Не Closes. Половина контракта живёт в другом репозитории; #331 закрывается только после merge обоих PR и зелёного Yama-гейта здесь.

Чеклист

  • изменение покрыто селфтестом и CI-гейтами
  • docs/runtime-witness-operations.md обновлён
  • коммиты в conventional-commit стиле

Generated by Claude Code

claude added 3 commits August 16, 2026 00:44
…urvives the process

The exit-code contract keeps three states apart on purpose — 0 evaluated /
witness absent, 1 evaluated / witness present, 2 not evaluated — because
*not looking* and *looking and finding nothing* are different outcomes, and
collapsing them is how a monitoring pipeline learns to report health it never
measured.

Persistence then collapsed exactly that distinction. A refused attach wrote no
`runtime.json` at all, so after the process exited the durable record read:

    artifact present  ->  some evaluated outcome
    artifact absent   ->  not evaluated OR never invoked OR runner died before
                          invocation OR persistence failed OR lost in transit
                          OR an older format nothing reads any more

Absence had too many preimages to carry meaning. Now it carries none:

    Absence of a record means no durable knowledge, never a semantic outcome.

Every attempted evaluation writes a record when `--out` is given, stating what
happened in `execution.state`: `observed` / `clean` (with the observation
scope), `not_evaluated` (with a reason code), `error` (with a classification).

What does NOT come back is a verdict nobody earned. A `not_evaluated` or
`error` record carries no `verdict` and no `retained` key at all — not even
`retained: []`, which downstream reads as "looked, found nothing" and would
re-create the collapse one layer up.

Two distinctions the record refuses to guess at:

* `refused-attach` is a claim about permission, so it is made only where a
  refusing policy can be named (`reason.policy`, e.g. the Yama scope). Every
  other unreadable target gets the weaker, true `unreadable-target`. The human
  advice and the record now read one shared observation, so a diagnostic that
  blames the kernel can no longer sit beside a record that blames the target.
* `not_evaluated` vs `error` splits on where the failure landed: before the
  heap was readable nothing was looked at and the target is not implicated;
  after it, the walk broke and the target is not exonerated.

`scope` is required for an evaluated state, not decorative: a `clean` that does
not say what it looked at cannot mean "nothing was there", and consumers must
route it down the schema-violation path. It names the population the verdict
covered separately from the budgets that bounded only the display, so a reader
can tell a number that constrained the verdict from one that did not.

CI moves with the contract. The denied-attach gate asserted "no artifact
written" — the behaviour being fixed — and now asserts the refusal is recorded,
names the policy, and carries no verdict. Two target-free assertions run on
every platform, and the flagship demo checks that both its variants record a
scope.

Verified: witness selftest (classifier, verdict and record contracts), both
flagship demo variants against a live net8 process (observed with a
1000-instance scope; clean), all three not-evaluated codes, ingest selftest
26/26, tests/run_tests.py wpf 28/28.

Refs #331.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m
Review of d16f47d found the refused-attach fix stopped one level short of its
own proof, and in two places.

**stderr could still blame Yama after a successful attach.** The catch block
called AttachAdvice unconditionally and only then checked whether the walker
existed, so any exception from Census/Roots on Linux under a restricting
ptrace_scope printed "PERMISSION failure, Yama refused it" while the record
correctly said `execution.state=error`. The claimed invariant — human diagnostic
and record read one observation — was still broken, just in the other branch.

**One bool could not carry the state it was asked to.** `attached` was set after
RetentionWalker's constructor, which does DataTarget.AttachToProcess AND
ClrVersions.FirstOrDefault AND CreateRuntime. A live process that opened fine and
turned out not to be managed threw with attached still false, so under
ptrace_scope=1/2/3 it came back labelled `refused-attach` — a permission claim
about a step where permission had already been granted.

The stages are now explicit — open-target, create-runtime, walk — and
RetentionWalker splits accordingly (OpenLiveTarget/OpenDumpTarget, then Create
taking ownership). Attribution follows the stage: only open-target may yield
`refused-attach` and only it prints the ptrace advice; create-runtime is
`unreadable-target`; walk is `error`. `reason.stage` is recorded so a reader can
check the attribution rather than trust it.

And the last substitution the previous round left standing: Yama being active is
not Yama having caused this failure. The kernel does not tell a tracer which
check rejected it, and a live process under scope 1 can fail to open for
unrelated reasons. The field is now `policy_in_force` — what was observed — and
the stderr wording says the same, naming a likely cause and its limit rather
than a refuser.

Also correcting two overclaims from d16f47d rather than leaving them to be found
again. `verdict` is command-specific (roots has a vocabulary, census does not),
so the doc no longer lists it among what every evaluated state carries — the
code never wrote one for census. And the record invariant is stated as it is
implemented: every evaluation *for which persistence was requested* leaves a
record; a run without --out asked for no durable output, and a standalone tool
has no other publication point.

CI: the denied-attach gate now also asserts reason.stage, and a second case runs
under the SAME restricting policy — an unreadable dump — proving a non-attach
failure does not borrow the policy or the lecture.

Verified: selftest (now pinning stage attribution as a pure function, including
create-runtime never citing a policy), both flagship variants on a live net8
process, live unmanaged process -> create-runtime/unreadable-target with no
PERMISSION line on stderr, dead pid -> open-target/unreadable-target, ingest
26/26, run_tests.py wpf 28/28. The live Yama refusal itself is unexercised here
(no Yama in this kernel) and remains CI's to prove.

Refs #331.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m
…roves

The behaviour was already honest — the code is reserved for an open-target
failure with a policy observed in force, and `policy_in_force` is worded as an
observation — but the comment above it still opened with "a claim about
PERMISSION". A later reader would take the enum name as the finding and put the
causal claim back by hand.

Comment only; no behaviour change. Selftest and build unchanged.

Refs #331.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bc04dd17-3179-426c-805a-bc72d36dd08b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Owner Author

Base advanced; no base-sync commit introduced

CI was green against the previous base. Since then main advanced through P-038-only changes. The PR head is unchanged; changed-path intersection is empty and git merge-tree --write-tree succeeds for the current base. No base-sync commit was introduced solely to retrigger CI.

Observed, so the gap is stated rather than papered over:

head 35e101f — unchanged since the PR opened
base at CI time f3d3e86
base now 7a63b73 (#332docs/proposals/P-038-boundary-transition-witness.md, docs/proposals/README.md)
changed-path intersection empty — this PR touches audit/runtime/RetentionPath/*, .github/workflows/ci.yml, docs/runtime-witness-operations.md, scripts/flagship-demo.sh
git merge-tree --write-tree origin/main HEAD rc 0, tree b6e3fc3, no conflicted paths

What this does and does not establish. A clean merge proves the absence of a textual conflict. Disjoint paths make a semantic regression unlikely but do not reduce it to zero — a build/test graph can couple files through configuration and dependencies. What is genuinely unproven is the narrow claim "this exact merged tree passed CI", which is a much smaller statement than "the PR is now suspect".

Re-running CI on a base-sync commit would buy that narrow claim at the cost of an extra SHA and a run that re-tests the same diff against docs-only movement. Deferred rather than skipped: before this leaves draft, the base gets checked again, and the branch is updated if branch protection requires up-to-date, or if main has by then touched runtime, CI, or the build graph. If the base has moved only through disjoint changes again, no sync commit is warranted.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants