M3: runtime approved-surface enforcement (field-name validation de-scoped) - #308
Conversation
…on 9)
Expands the outline-only M3 into full phase blocks, informed by a direct
Explore of both repos (2026-07-24).
Milestone shape re-scoped TINKERDOWN-ONLY (drift from the plan's "upstream"
filing). The evidence is firmer than M2's: an upstream WithActionPolicy would be
dead code, not merely redundant —
- Tinkerdown routes actions entirely through runtime.GenericState.HandleAction →
RunSQLAction, NEVER through livetemplate.DispatchWithState. A livetemplate
action-policy hook would never fire for tinkerdown.
- Tinkerdown state is map-based ([]map[string]interface{}), so livetemplate's
reflection-based introspection has no typed struct to read.
- Unlike M2 (tinkerdown consumed livetemplate.Validate), an upstream hook here
has zero tinkerdown consumption. A livetemplate-side hook for its own
typed-struct consumers is separable framework work, deferred.
Two tinkerdown phases:
1. Runtime approved-surface enforcement — a shared policy gate at all three
action paths (WS-custom, webhook-direct, WS-builtins; no shared chokepoint
today) so a running app / bypassing caller may only invoke approved actions
against approved sources, respecting readonly. Untangled from confirm:
(a client hint that can't be server-verified) — M3 enforces the approved
surface, not a dialog. Per-action, not per-user (no per-request identity).
2. Field-name validation — validate checks {{.Field}} against a source's schema
(source.SchemaProvider, sqlite-first; snakeToPascal; skip no-schema sources
as a safe miss), closing M2 Phase 5 feed-forward #2.
Roadmap, gap-analysis rows 2/3, provenance table (M3 = N/A, no client bump), and
forward-references updated in step; M4–M5 remain outline-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
Enforces the approved surface at runtime, not only at generation time. When a project declares a generation: block, a running app — or a webhook / crafted-message caller — may only invoke an approved action, and a builtin write may only touch an approved source. Closes the hole the generation-time lint + the inert (client-only) confirm: leave open. Opt-in: no generation block, no gate. The approved surface is two surfaces: - config.EnforceApprovedAction(name) gates a custom action on its NAME ONLY, not its source — an approved action legitimately targets a writable store kept out of generation.sources (PII's access_store is unbindable by design). - config.EnforceApprovedSource(name) gates a builtin write on the block's BOUND source, which a manifest app may only bind if approved. Three call sites (the paths share no chokepoint): GenericState.HandleAction's custom-action branch and its add/toggle/delete/update branch (config threaded in via SetPageConfig, now unconditional), and the webhook entry (→ 403). confirm: stays a client hint — a dialog can't be server-verified. Tests prove rejection on every path (config predicates; WS custom + builtin; webhook 403-and-not-executed; opt-in when no generation block) and the PII e2e passes with approved actions running through the gate. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
…kerdown lacks Audit finding: field-name validation introspects a source's schema via SQLiteSource.Schema() (PRAGMA on the live DB), but tinkerdown's sqlite *.db is gitignored and built from seed.sql at serve — so at validate time (CI, fresh checkout, generate->validate->serve) no DB exists, Schema() returns nil, and the check skips the entire corpus. That makes it the self-certifying-guard anti-pattern this project kept killing: it cannot fire where it is verified (the corpus 55/55 false-positive gate is vacuous) yet WOULD fire unvetted on a dev machine's stray .db — negative expected value, not harmless best-effort. So M3 ships as Phase 1 (the runtime approved-surface gate — the higher-value half). The M2 Phase 5 feed-forward #2 it was meant to close is re-filed to § Risks (deferred), not dropped: the real prerequisite is a validate-time schema source (parse CREATE TABLE from a seed, or a declared columns: schema on the source config) — a distinct introspection capability, backlog/M4. Walks back the plan's now-overclaiming M3 statements (Roadmap line, gap-row-2, deliverables, milestone-shape) to match; collapses the Phase 2 block to a de-scope note with the reasoning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
Review: M3 runtime approved-surface enforcementReviewed the diff ( Correctness gap: the
|
Milestone M3 — runtime approved-surface enforcement, executed per convention 9 (design expanded at kickoff, then implemented). Re-scoped tinkerdown-only at kickoff, and shipped as one phase — the runtime gate — with field-name validation honestly de-scoped.
The kickoff re-scope: tinkerdown-only, not upstream
The plan filed M3 as upstream (
WithActionPolicy+ introspection). A direct read of both repos showed both would be dead code: tinkerdown routes actions entirely through its ownGenericState.HandleAction→RunSQLAction, never throughlivetemplate.DispatchWithState, and its state is map-based (nothing to reflect over). Unlike M2 (which consumed the upstreamValidate()), an upstream hook here has zero tinkerdown consumption. The upstreamWithActionPolicyis deferred as separable framework work.What shipped — the runtime approved-surface gate (
a3513de)When a project declares a
generation:block, an action invoked outside the approved surface is rejected server-side on every path a caller can reach:EnforceApprovedAction(name only)EnforceApprovedSource(bound source)EnforceApprovedAction→ 403, not executedThis is the server-side gate
confirm:never was (a dialog can't be server-verified) — closing the M1 Risk 758 defense-in-depth hole. Opt-in: nogeneration:block, no gate.The key subtlety: the approved surface is two surfaces. A custom action gates on
ApprovedActiononly, not its source — because PII's approved actions deliberately targetaccess_store, a writable store kept out ofgeneration.sources(unbindable by any generated app). A builtin gates onApprovedSource(boundSource). Conflating them would have rejected the PII demo.Field-name validation — de-scoped (
871a9c0), not silently droppedThe Audit found it can't be built cleanly: it introspects a source's schema via
PRAGMAon the live DB, but tinkerdown's sqlite*.dbis gitignored and built at serve — so at validate time (CI, generate→validate→serve) no DB exists, the check skips the entire corpus, yet would fire unvetted on a dev machine's stray.db. That's the self-certifying-guard landmine this project kept killing (never-fires-in-CI + fires-unvetted-on-dev = negative expected value). The M2 feed-forward it was meant to close is re-filed to § Risks with the real prerequisite named: a validate-time schema source (parseCREATE TABLEfrom a seed, or a declaredcolumns:schema) — a distinct introspection capability, backlog/M4. The plan's now-overclaiming M3 statements are walked back to match.Verification
Every gate path has a reject test (config predicates; WS custom + builtin rejected before execution; webhook 403-and-not-executed; opt-in when no generation block), and
TestPIIAccessApprovalpasses with approved actions running through the gate. Full suite green. Simplifier: diff already clean.🤖 Generated with Claude Code
https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h