Skip to content

M1 Phase 3: attribute validation + the generation workflow - #303

Merged
adnaan merged 3 commits into
mainfrom
m1/generate-skill
Jul 22, 2026
Merged

M1 Phase 3: attribute validation + the generation workflow#303
adnaan merged 3 commits into
mainfrom
m1/generate-skill

Conversation

@adnaan

@adnaan adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Third phase of M1 in the ephemeral-UI reframe plan, following #301 and #302.

Makes validate a gate the generation loop can actually rely on, and gives the skill the workflow to use it.

Pulled M2's attribute allowlist forward

The skill's loop is "self-correct until validate is clean." M0 Phase 2 proved clean meant almost nothing: a document using lvt-filter, lvt-scroll and a literal lvt-totally-made-up validated with zero errors, because unknown lvt-* attributes are emitted as inert HTML. A hallucinated attribute survived every iteration.

✗ unknown attribute "lvt-scroll" (use lvt-fx:scroll)
✗ unknown attribute "lvt-sortable"

Three design calls: the allowlist is hand-maintained so validate stays fast, but TestKnownAttributesAreReal checks every entry against the vendored bundle and Tinkerdown's source — without it, it'd be the same unfalsifiable claim that left 8 of 11 documented attributes stale. Namespaces are validated, members aren't (lvt-on: takes arbitrary DOM events; hard-coding member lists would create a second list to rot) — documented as a deliberate limit. Hints only where a real migration exists, because a guessed suggestion sends a self-correcting agent somewhere specific and wrong.

Dogfooding found a third silent-failure class

I followed the workflow I'd just written and produced a page that validated clean, summarised as privileged, served without error, and rendered nothing. No JS exception, no server-log warning.

lvt-* markup only binds inside a ```lvt fence. In the markdown body it's ordinary HTML.

"Validates clean but doesn't work"
Unknown attributes closed in this PR
Unapproved sources/actions closed in #302
lvt-* outside a fence closed in this PR

Fixed at the gate, not just in prose:

lvt-columns, lvt-source used outside an ```lvt block — this markup renders
but nothing binds to it; move it inside a ```lvt fence

I'd just closed the first class and believed that made the loop honest. "Clean" is a claim about what was checked, and each layer revealed another thing that wasn't. Only dogfooding exercised the whole chain rather than the part I was thinking about.

Two operator decisions recorded

Serve via tinkerdown serve on a scratch directory, not the playground. The plan preferred the playground's ParseString path as "disk-free" — but it's an HTTP endpoint set needing a running server, a POST and a session ID, and it isn't disk-free anyway (websocket.go:495 still writes each block to /tmp). A directory the operator can re-run and inspect is more useful, and if the UI proves worth keeping it already is a file.

The validate loop stops after ~5 rounds. A request still failing likely needs a capability the vocabulary lacks; saying so beats substituting attributes until something passes.

A guard from M0 caught a regression from this phase

vocabulary.go's migration table names dead attributes to suggest replacements, and TestRemovedAttributesAreReallyGone can't distinguish mentioning from implementing — it reported six dead attributes as live. Excluding the file would have been a blind spot, so the exclusion is paid for by TestMigrationHintsAgreeWithRemovedLists, which asserts every migrated-away name is recorded dead and genuinely absent from the shipped client. That second assertion guards a failure mode that didn't exist until this PR: a hint pointing at a live attribute would send an agent away from working code.

Verification

  • Full GOWORK=off go test ./... green including the root package with all 32 //go:build !ci e2e files (852s)
  • End-to-end: a console generated by following the workflow serves and renders live sqlite data, verified by screenshot with zero JS exceptions
  • Every new guard verified to fail when its condition is violated, not merely to pass today

🤖 Generated with Claude Code

https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h

adnaan and others added 2 commits July 20, 2026 17:19
Pulls M2's attribute allowlist forward, per operator decision, because
Phase 3's skill loop is "self-correct until validate is clean" and clean
did not mean the page works. M0 Phase 2 proved a document using
lvt-filter, lvt-scroll and a literal lvt-totally-made-up validated with
zero errors: unknown lvt-* attributes are emitted as inert HTML. A
hallucinated attribute would have survived every iteration of the loop.

That document now fails, with a migration hint where a real one exists:

  unknown attribute "lvt-scroll" (use lvt-fx:scroll)
  unknown attribute "lvt-sortable"

Three design calls.

The allowlist is hand-maintained so validate stays fast and
dependency-free, but TestKnownAttributesAreReal checks every entry
against the vendored bundle and Tinkerdown's own source. Without that it
would be an unfalsifiable claim -- exactly what left 8 of 11 documented
attributes stale before M0 Phase 2.

Namespaces are validated, members are not. lvt-el:bogus:on:success still
passes: there is no enumerable member set for lvt-on:, which takes
arbitrary DOM events, and hard-coding one for the others would create a
second list to rot. Documented as a deliberate limit rather than left to
look like an oversight.

Hints only where a real migration exists. A guessed suggestion is worse
than none -- it sends a self-correcting agent somewhere specific and
wrong, which is harder to recover from than "unknown".

Also fixes a false positive this created in M0 Phase 2's guard.
vocabulary.go's migration table names dead attributes in order to suggest
replacements, and a substring scan cannot tell mentioning an attribute
from implementing one, so the guard reported six dead attributes as live.
Excluding the file would have been a blind spot -- the thing that guard
exists to prevent -- so the exclusion is paid for by
TestMigrationHintsAgreeWithRemovedLists, which asserts every migrated-away
name is recorded dead AND genuinely absent from the shipped client. The
second assertion covers a failure mode that did not exist until this
commit: a hint pointing away from a live attribute would send an agent
away from working code.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (835s). Both new guards verified to fail when
their conditions are violated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
…ure class

Extends skills/tinkerdown with the full generation workflow: read the
approved surface, write the document, validate and self-correct, check
the operation summary if privileged, serve.

Two operator decisions are recorded in it. Serving happens via
`tinkerdown serve` on a scratch directory, not the playground's
ParseString path -- the playground is an HTTP endpoint set requiring a
running server, a POST and a session ID, and it is not disk-free anyway
(websocket.go:495 still writes each block to /tmp). A directory the
operator can re-run and inspect is more useful, and if the UI turns out
to be worth keeping it already is a file. The validate loop stops after
about five rounds, because a request that still fails likely needs a
capability the vocabulary lacks, and saying so beats substituting
attributes until something passes.

Dogfooding the workflow found the phase's most valuable defect. Following
these instructions produced a page that validated clean, summarised as
privileged, served without error, and rendered nothing: lvt-* markup only
binds inside a ```lvt fence, and in the markdown body it is ordinary
HTML. No browser console error, no server-log warning. Every available
signal said success.

That is the third distinct way a document passes validation and does not
work, after unknown attributes and unapproved names -- and the hardest to
notice, since nothing anywhere reports it. InertAttributes now catches it
at the gate, naming the remedy, and the skill leads step 2 with the fence
requirement. Prose prevents the mistake; the gate catches it.

Worth recording that I had just closed the first class and believed that
made the loop honest. "Clean" is a claim about what was checked, and each
layer added revealed another thing that was not. Only dogfooding
exercised the whole chain rather than the part I was thinking about.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (852s). End-to-end: a console generated by
following the workflow serves and renders live sqlite data, verified by
screenshot with zero JS exceptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Reviewed the diff (checked out pull/303/merge locally and read through vocabulary.go, vocabulary_test.go, attribute_docs_test.go, and validate.go in full; go test/go vet weren't runnable in this sandbox, so this is a careful static read rather than a verified test run).

This is a strong PR — the "validates clean but does nothing" framing is right, and the three new gates (vocabulary, placement, policy) close real gaps. Two things below undercut the guarantees the PR is explicitly trying to establish, in the same spirit as the bugs the PR itself is fixing.

1. TestKnownAttributesAreReal can never fail (vocabulary_test.go:19,95)

readProductionGo (vocabulary_test.go:95-118) walks every non-_test.go file for evidence that a knownAttributes entry is real — but unlike collectGo in attribute_docs_test.go (which explicitly excludes vocabulary.go, with a comment explaining exactly why: "a substring scan cannot tell mentioning an attribute from implementing one"), readProductionGo does not exclude vocabulary.go.

Since knownAttributes' own map keys are literal strings like "lvt-source" living in vocabulary.go, and vocabulary.go is itself part of the "production Go source" being scanned, every entry in the allowlist trivially satisfies appearsAsWholeName(name, goSrc) — because the string is right there in the map declaration. You could add "lvt-totally-made-up": true to knownAttributes today and TestKnownAttributesAreReal would still pass, since the entry "appears" in goSrc as its own map key.

This is the exact unfalsifiable-claim failure mode the PR calls out ("Without it, it'd be the same unfalsifiable claim that left 8 of 11 documented attributes stale") — and the PR clearly already understood the mechanism, since it applies the correct fix (excluding vocabulary.go) in the sibling test file. It just didn't carry over to this one. Worth excluding vocabulary.go from readProductionGo the same way collectGo does.

2. data-lvt- is a blanket-allowed prefix, not a checked namespace (vocabulary.go:66)

knownPrefixes includes "data-lvt-" alongside the real dispatch namespaces (lvt-on:, lvt-el:, etc.). But lvt-on: etc. are legitimately unbounded (arbitrary DOM event names / lifecycle states), whereas data-lvt-* is a small, enumerable, closed set (data-lvt-force-update, data-lvt-target, data-lvt-redact, …, confirmed against the bundle). Because hasKnownPrefix matches on prefix alone, UnknownAttributes() will pass any data-lvt-* attribute through silently — data-lvt-sortable or data-lvt-nonsense would validate clean exactly like the lvt-sortable example the PR uses to motivate this whole feature. There's also no test asserting an invented data-lvt-* name gets flagged (the existing TestUnknownAttributes case only exercises a real one). Since the set is small and finite, it seems like it could just live in knownAttributes (or a second exact-match set) rather than as a wildcard prefix, closing this gap for the same reason the rest of the PR closes the others.

3. Minor: redundant double parse per file (cmd/tinkerdown/commands/validate.go:130,143,177)

page.InertAttributes() and page.UnknownAttributes() are each called twice per file — once to build error messages, again in the final else if to decide whether to print . Each call does a full html.Parse over every ServerBlock, InteractiveBlock, and StaticHTML. For the common case of an lvt-source table, the same processed content is also duplicated between an auto-generated ServerBlock and its paired InteractiveBlock (page.go:463-482), so a single UnknownAttributes() call already parses that markup twice — combined with the two call sites in validate.go, that's up to 4x redundant parsing per file. Not a correctness issue, but easy to avoid by hoisting both results into local variables once per file, especially since this runs in the self-correct loop the SKILL.md workflow describes (potentially several iterations per generated page).

Nits / things that look right

  • TestMigrationHintsAgreeWithRemovedLists and the vocabulary.go exclusion in attribute_docs_test.go are a genuinely good pairing — closing the blind spot the exclusion itself creates rather than just trusting the file.
  • Per CLAUDE.md's guidance to check whether an lvt-* attribute is already implemented before reimplementing: spot-checked several knownAttributes/knownPrefixes entries not currently documented in docs/reference/lvt-attributes.md (lvt-datatable, lvt-persist, lvt-upload, lvt-spy, lvt-redact, lvt-scroll-away) against the vendored bundle and Go source — all are genuinely implemented, just undocumented elsewhere. Not a problem with this PR.
  • SKILL.md's new workflow steps match the actual CLI behavior I read in validate.go (flag parsing, --summary JSON-only-on-stdout, file-vs-directory handling).

Nice write-up in the PR description, especially the dogfooding story — happy to take another pass once (1) is fixed since that's the one that would let this regress silently.

Review-bot catches on #303. The first undercut a guarantee this PR is
built on.

TestKnownAttributesAreReal could never fail. It scanned production Go for
evidence each allowlist entry was real, but the allowlist lives in
vocabulary.go, which is production Go -- so every entry matched its own
map key. Adding "lvt-totally-made-up": true passed cleanly. Fixed by
excluding vocabulary.go from the scan, exactly as collectGo already
excludes it in attribute_docs_test.go.

This is the third instance of this self-certifying bug, and the worst: I
fixed it in collectGo in this same commit, with a comment saying a
substring scan cannot tell mentioning an attribute from implementing one,
then did not apply that reasoning to the scanner guarding the new
allowlist. Knowing the failure mode was not enough; only running the
falsification test was. Verified now by adding a fictional entry and
watching the guard fail.

data-lvt- was a blanket-allowed prefix, so data-lvt-sortable validated
clean -- the same hole this file exists to close, one namespace over.
Unlike lvt-on:/lvt-el: (arbitrary events and states), data-lvt-* is a
closed enumerable set, so it is now checked exactly against the 22 names
in the bundle, with the invented-name test that was missing. The honesty
guard covers this set too.

Also hoists a double parse: InertAttributes and UnknownAttributes were
each called twice per file, and this runs in the self-correct loop the
skill describes, so the waste compounds per iteration.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (827s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
@adnaan

adnaan commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed — the first was a guarantee this PR is built on, and you were right.

TestKnownAttributesAreReal could never fail. Confirmed by adding "lvt-totally-made-up": true — it passed cleanly, because the allowlist's own map keys live in vocabulary.go, which the scan treated as production evidence. Excluded the file, exactly as collectGo already excludes it. Verified the guard now fails on a fictional entry.

The part worth naming: this is the third instance of this self-certifying bug, and I fixed it in collectGo in this same commit — comment and all — then didn't carry the reasoning to the scanner guarding the new allowlist. Knowing the failure mode wasn't enough; only running the falsification test caught it. I should be running that test on every guard I write, not just the ones where I remember the pattern.

data-lvt- blanket prefix — you're right it's the same hole one namespace over: data-lvt-sortable validated clean. It's a closed set, so it's now an exact-match map of the 22 names in the bundle, the honesty guard covers it, and the invented-data-lvt-* test you noted was missing is added.

Double parse — hoisted; it compounds inside the self-correct loop.

On your nit: confirmed the same, lvt-datatable/lvt-persist/lvt-upload/lvt-spy/lvt-redact/lvt-scroll-away are real-but-undocumented. TestKnownAttributesAreReal (now that it works) is what keeps them honest going forward.

Full suite green at 827s.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review

Reviewed attribute_docs_test.go, cmd/tinkerdown/commands/validate.go, vocabulary.go, vocabulary_test.go, and skills/tinkerdown/SKILL.md.

Overall this is a solid, well-motivated change — the falsifiability fix for TestKnownAttributesAreReal (excluding vocabulary.go from the scan, per the same pattern attribute_docs_test.go already used) and the InertAttributes/data-lvt-* closed-set checks are good catches, and the write-up in the PR description honestly documents the self-certifying-bug pattern recurring a third time. The design calls (namespace-only validation for lvt-on:/lvt-el:/etc., hint-only-when-real) are reasonable and well-commented.

Potential bug: knownAttributes is missing at least two real, currently-implemented attributes

Per CLAUDE.md, I checked the vendored client bundle (internal/assets/client/tinkerdown-client.browser.js) directly rather than trusting the allowlist. TestKnownAttributesAreReal only checks that every allowlist entry is implemented — it doesn't check the reverse direction (that everything implemented is in the allowlist), so the hand-maintained list can have coverage gaps that this test can't catch.

I found two bare attributes the client reads via hasAttribute that aren't in knownAttributes, knownPrefixes, or knownDataAttributes:

a.hasAttribute("lvt-input")||a.hasAttribute("lvt-change")

This is in the form auto-wiring code path — it looks like an author-facing opt-out: adding lvt-input/lvt-change to a form element skips the framework's automatic input/change wiring for that element (presumably because the author is wiring it manually, e.g. via lvt-on:input). Neither attribute is deprecated (unlike lvt-no-intercept, which the bundle explicitly warns is a deprecated shim for lvt-nav:no-intercept/lvt-form:no-intercept — that one's fine to leave out since it's actively being sunset).

If that reading is right, a document author legitimately using lvt-input or lvt-change would get validate failing with "unknown attribute" on working markup — exactly the false-positive class this PR is trying to eliminate, just in the opposite direction (rejecting something real instead of accepting something fake). Worth confirming against the livetemplate/client source (per CLAUDE.md, that's the authoritative implementation) and either adding them to knownAttributes or documenting why they're intentionally excluded from the generation vocabulary.

Minor / non-blocking

  • readProductionGo in vocabulary_test.go concatenates every production .go file's raw bytes with no separator between files before doing substring matching. Two adjacent files' tail/head bytes could in principle join into a false match. Vanishingly unlikely to matter in practice given the attribute-name alphabet, but a \n join would remove the theoretical gap for free.
  • InertAttributes and UnknownAttributes each independently call html.Parse on p.StaticHTML — the PR's third commit hoists the call-site duplication in validate.go (good), but the two functions still each re-parse StaticHTML internally. Not worth blocking on given this only runs at validate time, not hot-path, but flagging since the PR explicitly calls out the earlier duplication as wasteful in the self-correct loop.

Test coverage

The new tests (TestUnknownAttributes, TestInertAttributes, TestKnownAttributesAreReal, TestMigrationHintsAgreeWithRemovedLists) are thorough and, notably, each is written to fail under the specific regression it guards against (verified in the PR description) rather than just asserting the happy path — good practice given this PR's own theme of unfalsifiable guards.

No security concerns — this is local static analysis over already-parsed document content, no new I/O or untrusted-input handling beyond what ParseFileInSite already does.

@adnaan
adnaan merged commit 325bf02 into main Jul 22, 2026
4 checks passed
@adnaan
adnaan deleted the m1/generate-skill branch July 22, 2026 03:29
adnaan added a commit that referenced this pull request Jul 23, 2026
The M1 reference console — approve/deny PII data-export access requests — on
existing primitives plus two generic core additions, hardened across five review
rounds.

Generic core:
- Atomic multi-statement SQL actions (Action.Statements + transactional ExecTx on
  SQLExecutor), so a state change and its audit record commit or roll back
  together. One shared source.RunSQLAction/SubstituteParams for the runtime and
  webhook exec paths (no drift; :operator injected in one place).
- Policy-lint fix: built-in source affordances (Add/Delete/Toggle/Refresh) are no
  longer flagged as unapproved actions; exact-match IsBuiltinAction + custom
  actions dispatch ahead of the datatable-prefix fallback.

Reference app (examples/pii-access-approval/): server-authoritative bounded
export (row cap read from the row, not the client), durable idempotent audit
trail (status='pending' guards), reserved server-set :operator (no audit
spoofing), governed-writes-only design (read-only display sources + a
non-app-bindable writable store + a governed intake), orders_pii surfaced in the
operation summary.

Tests: ExecTx commit/rollback/readonly, ValidateActions, refs/policy, a
non-browser CI regression over the real manifest SQL (non-matching/replayed ids,
audit-spoof, intake-forge), and a four-channel chromedp e2e + the
frontmatter-shadowing runtime demo Phase 1 could not build.

Also reconstructs the M1 Phase 3 plan tracker + Learn (a #303 drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
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.

1 participant