M1 Phase 2: policy lint + operation summary - #302
Conversation
Makes `tinkerdown validate` enforce the approved surface. A document that steps outside it now fails with a diagnostic naming the offender and the approved alternatives. The Audit found the plan framed this as "add a lint", when it is three pieces -- two of them prerequisites the plan does not mention. validate never loaded tinkerdown.yaml. It called ParseFileInSite and discarded the result, so it had neither the approved set nor the parsed page. This is the same shape as Phase 1's finding: the parse layer is deliberately config-free, so anything policy-aware loads config itself. A malformed config downgrades to a warning rather than refusing to check syntax, since serve already reports config problems. Action references had no existing extraction to reuse. An action name reaches the server from the *client* when a control is used (GenericState.HandleAction), so nothing in the parse pipeline ever enumerates them -- the plan's "reuse the parser's existing extraction, don't re-regex" could not be followed because there was nothing to reuse. Page.Refs() recovers them by parsing block markup. It is an HTML parse rather than a pattern match because `name` is a legitimate attribute on input and select, where it is a form field; a regex would report every form field as an action reference. There is a test for exactly that. CheckPolicy checks references and declarations separately, because a reference-only lint -- what the plan originally specified -- passes a document that declares `evil` and then references `evil`: every name it uses resolves to something it defined. Declarations turned out to be the cheap half (Page.Config already holds them) and the important one. Shadowing an approved name is reported as "ignored" rather than as a breach: precedence already pins it at runtime, so the diagnostic exists to tell a generating agent why its definition had no effect. Also fixes a bug this introduced: a file with policy violations printed its ✓ and counted toward Valid while reporting its own errors. Approval stays opt-in -- a project with no generation block lints exactly as before, verified end-to-end. Verification: GOWORK=off go test ./... green including the root package with all 32 !ci e2e files (866s), plus 9 extraction cases and 7 policy cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
Adds `tinkerdown validate --summary`, which describes what a document does with the project's approved surface: the sources it reads, the actions it runs, each with the manifest's describes: note and flags for whether it executes, writes, or reaches the network. The privileged bit carries the proportionality rule the plan asked for. A console that only reads is not worth interrupting an operator over; one that executes, writes, or talks off-host is. A prompt shown for every generated page is a prompt nobody reads. Two deliberate calls. Only approved names are summarized -- an unapproved name is a policy violation and the lint reports it as such, so describing it here would present something the document may not do as though it were part of the plan. And every action counts as a write, because an action exists to change something; the alternative was parsing SQL to guess, which would be confidently wrong on exactly the cases that matter. Over-reporting is the safe direction. Fixes a defect that defeated the flag's purpose: the validating banner printed before the JSON, so stdout was not machine-parseable, and the consumer here is a program deciding whether to interrupt its operator rather than a human reading a terminal. Summary mode now emits only JSON on stdout, with the config-load warning moved to stderr. Verified by piping stdout through a parser rather than reading it. Strikes the Manifest accessor from Phase 1's deferral rather than deferring it again: this phase produced its two real consumers, CheckPolicy and Summarize, and neither needed anything beyond Generation plus the existing approval accessors. A bundling struct would be a parallel representation of data the config already exposes, kept in sync for no consumer. Verification: GOWORK=off go test ./... green including the root package with all 32 !ci e2e files (863s), plus 7 summary cases. Proportionality verified end-to-end -- the same console reports privileged true when its source is writable and false when read-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
ReviewSolid piece of work overall — the declaration-vs-reference distinction is the right fix for the shadowing hole, nil-safety on One correctness gap in
|
Review-bot catch on #302, and re-verifying past it found a second gap the bot did not name. The lint recognised only name= on button/form. An action can be bound three ways, and two were invisible: lvt-on:{event}="Action" -- the tier for controls that are not buttons. parser.go:77 says so outright ("triggered via button name routing or lvt-on:click") and auto_tasks.go:217 generates it, so Tinkerdown was emitting a form its own policy check could not see. lvt-form:action="Action" -- explicit form routing, and *first* in the client's resolution order (event-delegation.ts:293) ahead of submitter name and form name. This was a hole, not an omission: an unapproved action invoked only via lvt-on:click passed the lint clean. Verified end-to-end that it is now reported. Root cause is mine and the project's CLAUDE.md names it exactly -- check livetemplate/client before reimplementing lvt-* behavior. I built action extraction from how I assumed actions were bound rather than from how the client dispatches them. The rule exists for this. Second time in this session that re-verifying beyond a reported instance found another (the first was lvt-focus-trap, where the batch re-check turned up lvt-debounce). Treat a review finding as a sample of a class, not the whole of it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
|
Confirmed — this was a hole, not an omission, and re-verifying past it found a second one you didn't name.
Verified end-to-end that an unapproved action invoked only via Root cause is mine, and this repo's CLAUDE.md names it exactly — check Worth recording: this is the second time this session that re-verifying beyond a reported instance found another (the first was Full suite re-running; targeted tests green including two new cases pinning both tiers. |
ReviewNicely scoped phase, and the third commit's self-correction on A few smaller things worth a look: Test coverage gap for the exact defect class this PR already found once. Minor duplication: Writes: src.Readonly != nil && !*src.Readonly,
Minor gap: a Minor inconsistencies, not blocking:
Nothing here blocks — the core policy/summary logic and the declaration-vs-reference distinction look correct, and the shadowing case is handled sensibly (reported, not double-counted against the reference check). |
…fixes
The important one: --summary's "nothing but JSON reaches stdout" was
fixed by hand-verification and never pinned. That is the exact defect
class this PR already shipped once -- the validating banner printing
before the JSON -- and a single stray fmt.Printf anywhere in the validate
path would silently break it again with nothing to catch it. A fix
without a test is a claim with no mechanism to falsify it.
validate_summary_test.go now exercises ValidateCommand end to end and
parses its stdout. Verified by adding a stray Println and watching the
test fail with "stdout is not valid JSON", then removing it.
Five smaller items from the same review:
- Use SourceConfig.IsReadonly() rather than re-deriving the
nil-means-readonly default inline, so the summary cannot drift from
the rest of the codebase if that default changes.
- A sql action now inherits the network reach of the source it runs
against. One pointed at pg/rest/graphql was reporting network: false,
understating what the operator is shown.
- The summary walk and the validation walk share a skip list instead of
disagreeing about what counts as part of the site.
- An unknown flag errors instead of being ignored: a mistyped --sumary
silently produced human-readable output that a script expecting JSON
would then try to parse.
- main.go usage documents --summary.
Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (836s).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
|
All six addressed, and the first was the one I'd have called done. The stdout-purity test. You're right that this is the exact defect class this PR already shipped once, and that I'd fixed it by hand-verification with nothing pinning it. Worth naming why I missed it: I've been careful all phase to confirm a mechanism can fail — neutralising implementations to watch tests break — but treated a hand-verified fix as finished. Hand-verification is precisely what doesn't survive the next commit. A fix without a test is a claim with no mechanism to falsify it.
Unknown flags now error. A mistyped Shared skip list between the two walks, and usage text updated. Also — thank you for independently checking whether Full suite green at 836s. |
ReviewNice, well-tested phase overall — the declared-vs-referenced split, the config-awareness fix, and the pure-stdout regression test are all good. A few issues worth addressing before merge, roughly in order of severity. 1. (High) A malformed
|
Review-bot catch on #302, and the most serious defect of this phase. A malformed tinkerdown.yaml left manifest nil, and a nil manifest summarises to {"privileged": false, "operations": []} with exit 0 -- byte-identical to "this project declares no approved surface, nothing to review". The consumer decides whether to interrupt its operator from that bit, so the gate reported "safe" when the truth was "couldn't tell", and handed an unreviewed app straight through. The warning went to stderr, which a consumer parsing stdout never sees. Worse in context: ValidateGeneration, added in Phase 1, turns a typo in generation.sources into exactly this load error -- deliberately, because an inert approval leaves a name shadowable. Failing open here muted the alarm it exists to raise. I built the loud failure and then wired it to a mute switch. Summary mode is now fatal on a config it cannot read. Plain validate keeps the previous behavior: report the problem, still check document syntax, which remains useful. Verified by restoring the fail-open branch and watching the new test fail. The bug is not a forgotten case. The safe-looking default was wrong: `manifest = nil` reads as harmless while silently converting "could not read the policy" into "there is no policy". A policy gate has a mandatory failure direction and nil-as-empty picked the wrong one. Also fixes a false comment of mine. I claimed the two directory walks share a skip list so they cannot drift, having wired only the summary walk through skipWalkDir; the main walk kept its own inline copy and they agreed by coincidence, since .git starts with a dot the prefix check already catches. They now genuinely share it. Same shape as Phase 1's GenerationConfig comment: I described the property I intended rather than the one I built. Verification: GOWORK=off go test ./... green including the root package with all 32 !ci e2e files (836s). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
|
Both fixed — and the first is the most serious defect of this phase. Fail-open on a policy gate. You're right on every part of it: nil manifest → It's worse than the general case, because Verified by restoring the fail-open branch and watching the new test fail — including a case for the approval-typo path specifically, which is the one you noted had no coverage. What I take from it: this wasn't a forgotten case, it was a wrong safe-looking default. Second finding — my comment was false. I claimed the two walks shared a skip list "so they cannot drift," having wired only the summary walk through That's the same shape as Phase 1's Full suite green at 836s. |
ReviewSolid, well-scoped PR. The commit history shows real self-review discipline — two rounds where review-bot findings were investigated further and turned up a second, related gap each time (missed Correctness
Minor observations (non-blocking)
Test coverageCoverage looks thorough — reference extraction (9 cases incl. the input/select false-positive guard, nil-page, malformed markup), policy checks (7 cases incl. shadowing vs. violation, dedup), summary generation (7 cases), and the JSON-purity contract on stdout (regression-tested by actually parsing captured stdout rather than eyeballing it). The SecurityNo concerns beyond what's already been addressed in-PR. The core security property — an unapproved source/action can't sneak past the lint by declaring itself, and a broken policy config can't silently read as "nothing to review" — is both correctly implemented and tested. Nothing here blocks merging; items 1–3 are polish/follow-up candidates. |
Second phase of M1 in the ephemeral-UI reframe plan, following #301.
tinkerdown validatenow enforces the approved surface, andvalidate --summarydescribes what a document does with it.The check the original spec would have missed
The plan specified linting references: "every source/action referenced by the doc must be in the approved set." That passes this document:
It declares
evil, then referencesevil— every name it uses resolves to something it defined, so a reference-only lint sees nothing wrong. Linting declarations is what closes it, and it turned out to be the cheap half (Page.Configalready holds them) and the important half.The Audit found two prerequisites the plan doesn't mention
validatewas not config-aware. It calledParseFileInSiteand discarded the result, and never loadedtinkerdown.yaml— so it had neither the approved set nor the parsed page. Same shape as Phase 1's finding: the parse layer is deliberately config-free, so anything policy-aware must load config itself.Action references had no existing extraction to reuse. The plan said "reuse the parser's existing extraction, don't re-regex" — but an action name reaches the server from the client when a control is used (
GenericState.HandleAction), so nothing in the parse pipeline ever enumerates them, and policy runs long before any click.Page.Refs()is new machinery.It's an HTML parse, not a regex:
nameis a legitimate attribute on<input>and<select>, where it's a form field. Pattern-matching would report every form field as an action reference — noise that trains an operator to ignore diagnostics. There's a test pinning exactly that case.Also: the Audit item referenced
lvt-persist, which was removed from the codebase (page.go:585) — the fourth plan block naming something absent.Operation summary
{ "privileged": true, "operations": [ { "kind": "action", "name": "approve", "type": "sql", "describes": "Grants scoped, time-boxed access and writes an audit record", "writes": true }, { "kind": "source", "name": "requests", "type": "sqlite", "describes": "Pending PII access requests awaiting approval", "writes": true } ] }privilegedis the proportionality rule — flip that source toreadonly: trueand it becomesfalse. Verified end-to-end, both directions.Fixed a defect that defeated the flag's purpose: the
🔍 Validating…banner printed before the JSON, so stdout wasn't machine-parseable — and the consumer is a program deciding whether to interrupt its operator. Summary mode now emits only JSON on stdout; warnings go to stderr. Verified by piping through a parser rather than reading it.Two deliberate calls: only approved names are summarized (an unapproved name is a violation — describing it would present something the document may not do as though it were planned), and every action counts as a write (an action exists to change something; parsing SQL to guess would be confidently wrong on the cases that matter — over-reporting is the safe direction).
Manifest accessor: struck, not deferred again
Phase 1 deferred it pending a real consumer. Phase 2 produced two —
CheckPolicyandSummarize— and neither needed anything beyondGenerationplus the existing approval accessors. A bundling struct would be a parallel representation of data the config already exposes, kept in sync for nobody.Verification
GOWORK=off go test ./...green including the root package with all 32//go:build !cie2e files (863s)generation:block lints exactly as before, verified end-to-endAlso fixes a bug this introduced: a file with policy violations printed its
✓and counted towardValidwhile reporting its own errors.🤖 Generated with Claude Code
https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h