kb: the view/server split exists so clients can differ, and that is a test
User: 'The whole point of the view / server split is that multiple clients can have different views of the model.' Recorded as the RATIONALE rather than a fact, because it is the single premise the day's four rulings follow from -- client-local view stack, ephemeral drill, client-only view switching, and identical staging. gotcha 29 already said 'the model is shared and the UI is not'; this says why, and converts the split from a fact into a test: does the proposal force two clients into the same view? Plus the two integration doctrines: an autostash instruction is not permission to pop (the stash stack is shared across six other sessions), and a capability and its key can land in different tickets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
+3
-14
@@ -124,7 +124,6 @@ invariant(normal_mode_is_resolved_not_carried,'AppMode::Normal is resolved throu
|
||||
invariant(the_frame_basis_follows_active_view,'The cursor frame basis re-bases AT THE SWITCH, in the loop, as soon as active_view moves -- not at the next geometry effect. Guarding the COMPARISON on the two names agreeing (PR #78 round 2) exempted the first geometry effect after a mid-batch SwitchView from the check altogether, which is under-reporting and gotcha 35 expensive side. SwitchView stays ViewOnly: the basis is App own bookkeeping and none of it crosses the wire, so this needed no protocol change. Costs one extra view_frame per batch that both switches and moves geometry, DrillIntoCell paying most.').
|
||||
invariant(encoded_width_mirrors_every_preprocess_branch,'The preflight runs BEFORE preprocess and the exact check AFTER it, so the exact check bounds the error message and not the allocation. encoded_width must mirror EVERY arm of preprocess inference -- Numeric, Date (5 parts, 7 with a time) and Categorical (distinct plus the reserved (missing)) -- plus the .missing flag. It may over-count; it must never under-count. improvise-x1ns was TWO under-counts one inference-branch apart: the categorical (missing) one-hot, and the entire Date arm. The guard is that encoded_width matches Forced WITH NO WILDCARD, so a fourth variant is a compile error rather than a silent categorical fallback -- which is exactly how Date was missed.').
|
||||
invariant(opfs_sync_handles_are_dedicated_worker_only,'createSyncAccessHandle is spec-ed [Exposed=DedicatedWorker] and is ABSENT -- not rejected, not flagged, not transient -- on a file handle obtained inside a module SharedWorker. Measured 2026-09-21 on Chromium and Firefox: navigator.storage.getDirectory() and getFileHandle() both SUCCEED there and the method is simply not on the object; the real OpfsFs bundle, run unmodified in a shared worker, fails at exactly that call. Playwright WebKit has no OPFS in any context, so it is out one step earlier. Consequence: gotcha 28-s premise -- the handle-s read/write/truncate/flush ARE blocking, which is the whole reason Storage stayed synchronous -- is a DEDICATED-worker-only premise, so a SharedWorker cannot host OpfsFs as written and every \"browser worker\" in that argument means a dedicated one. The trap for anyone costing multi-tab: Sessions, the Addressed filter and the single frame pair are all ready and none of it helps, because the no is entirely on the storage seam. See improvise-12ha for the three probed ways out (async seam / nested worker / relay) and improvise-7gip for the guard that fails to enforce this.').
|
||||
invariant(global_keymap_binds_char_keys_only,'Every one of the seventeen modes inherits from one global (improvise-636d) carrying ?, : and the panel toggles -- CHAR KEYS ONLY, and that is load-bearing. KeyPattern::AnyChar matches KeyCode::Char and nothing else, so only a Char key is shadowed by a text-entry mode while the user is typing; a global F1 would reach the parent MID-EDIT and discard a half-typed cell. F1 therefore stays on Normal although it runs the same command as ?. Esc is excluded for a different reason: it means something different in nearly every mode. Help is the only mode writing an override, making ? close the overlay.').
|
||||
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
|
||||
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
|
||||
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
|
||||
@@ -310,16 +309,8 @@ doctrine(a_surface_that_paints_another_surface_hints_advertises_what_it_cannot_d
|
||||
doctrine(the_status_bar_and_an_overlay_are_two_writers_of_one_fact,'draw() stacks the tile bar and the import-wizard overlay ABOVE the status bar, so both are on screen together and each draws its own key hints. They disagree in notation and in content, and the wizard status tail is WRONG ON FOUR OF EIGHT STEPS -- on Done it says Esc cancels where Esc goes back, an outright contradiction the user can act on. The overlay hints are correct because they are local and per-step; the status tail is a second table keyed only by AppMode, which cannot see the step. Two writers of one fact is improvise-edp disease in the UI layer. The cheap repair is to stop drawing the second one where an overlay draws its own, not to maintain both.').
|
||||
doctrine(establish_a_platform_fact_by_running_it,'When a design turns on what a platform API does, measure it in the target engines before designing against it -- a recalled spec and a plausible inference are both worth less than one probe, and the cheapest instrument is usually already in the tree. Measured 2026-09-21: the SharedWorker/OPFS question was settled in minutes by booting the EXISTING harness/opfs-contract bundle in a shared worker, and it returned three results no amount of reading would have produced with confidence -- the method is absent rather than failing, WebKit fails one step earlier for an unrelated reason, and Firefox ALONE permits a nested dedicated worker that gets a working handle (Chromium and WebKit throw \"Worker is not defined\"). That last one is the point: the single shape that would have cost nothing works in exactly one engine, which is indistinguishable from \"it works\" to anyone reasoning from one browser or from the spec. Corollary: report per engine, and never let a per-engine gap hide behind the word \"browsers\".').
|
||||
doctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops,'An optimistic status effect emitted ALONGSIDE a fallible mutation reports success the mutation never achieved, and because status is ViewOnly the client folds it locally and no host ever contradicts it. Gotcha 20 already states the cure for one case -- CommitBuffer emits the fallible effect LAST so its failure message wins -- and improvise-g3no found the rule broken in BOTH front ends at once, in the same shape: delete_at_cat_cursor and DeleteCategoryAtCursor each compose [RemoveItem, SetStatus(\"Deleted item ..\")] with the fallible half FIRST. Model::remove_item returns unit, so nothing downstream could have known better. Two rules generalise. A mutation whose failure is reportable must RETURN whether it acted -- a unit-returning mutation makes the lie unavoidable rather than accidental. And the front ends are the wrong place to notice: the same defect appeared independently in a Cmd and in a resolver, which is what a shared derivation exists to prevent, so the fix belongs where both compose from. No test failed, because every test asserted the status string that the code composes unconditionally.').
|
||||
doctrine(a_resolution_test_cannot_see_a_missing_inheritance_edge,'MEASURED, not reasoned, on PR #112: removing SearchMode-s parent left every_global_key_resolves_in_every_mode GREEN, because SearchMode-s own AnyChar answers ? with append-char either way -- which is the EXPECTED answer. A mode can be silently disconnected from the global keymap and every behavioural check still passes, because the catch-all that shadows the global is indistinguishable from the absence of the global. Only a STRUCTURAL test sees it, and the one that does asserts Arc::ptr_eq rather than behaviour. Generalises past keymaps: wherever a fallback answers the same thing the inherited value would, a behavioural test cannot distinguish inherited from absent.').
|
||||
doctrine(one_table_is_proved_by_identity_not_equality,'PR #112 consolidated several keymap spines into one global. The defect removed was several tables that AGREE TODAY, so the guard has to be Arc::ptr_eq: injecting the RIVAL fix -- giving one mode its own equal-but-separate copy of the global -- leaves every behavioural test green and fails only the pointer check. That rival is the implementation a reasonable person would write, which is why it, and not the bug, was the injection that earned its keep.').
|
||||
doctrine(a_consolidation_is_justified_by_an_empty_diff,'PR #112 ran doctrine(binding_dump_diff) and the \`:\` key produced ZERO rows -- every mode that now inherits it already bound it locally. The ABSENCE is the finding: it says the duplicated spines had not yet drifted, so the change is a pure consolidation, and equally that nothing in the suite would have noticed had they drifted. A ticket justified by an asymmetry in the SOURCE is one whose fix should show up as no behavioural diff, and there is no way to state that without running the dump.').
|
||||
doctrine(a_completeness_check_closed_over_the_omitting_artefact_is_blind,'web/tests/render_surface.test.js asked whether every RenderModel field is painted, and its DOMAIN was RenderModel -- the very artefact that can omit a field. GridCache::totals and page_coords were projected, folded by the browser cache and painted by NOTHING from improvise-n10 to improvise-p7lh, and the guard truthfully reported an empty set the whole time; so did a hand grep run by a parity audit, which reported \"exposed-but-unpainted is EMPTY\" as good news. The honest question is one level up: every field of every WIDGET CACHE reaches a front end or is named as debt. Generalises: a completeness check closed over the thing that can omit cannot see the omission, and it reports a clean bill rather than an error.').
|
||||
doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous,'Both bugs in PR #113 own new cache-parity guard were in its \"or it is fine because X\" branches, never in the check itself. The whole-cache hand-off test matched the substring &state.grid, which &state.grid.row_frame CONTAINS, so every GridCache field read as covered -- vacuous for precisely the cache the ticket was about. And rustfmt splits state.grid.totals over three lines, so a substring search calls a field that IS read unread. Neither was visible by reading and both fell out of injection. A third hatch remains merely incomplete (improvise-t3oh). When writing a new check, spend the injection budget on its exemptions, not on its main path: an escape hatch fails in the direction that looks like success.').
|
||||
doctrine(a_ticket_can_be_one_level_too_high_and_its_fix_unimplementable,'improvise-g3no read the bug as gotcha 20 ordering violated and asked for a Removed answer the front ends could let win, on the premise that \"both ends can already see the tree\". The browser can see the tree and NOT which rows are derived, and SetStatus is ViewOnly so no host-side refusal ever reaches a client -- so the stated fix is unavailable on that side twice over, and following the acceptance criteria literally would have produced a half-fix discovered at the browser boundary. The actual defect was one level down and an ASYMMETRY: remove_category always swept its formulas, remove_item did not. Restoring the sweep makes the optimistic status both ends already compose TRUE, with no wire change and no second classification. Read an acceptance criterion as a hypothesis about the mechanism, not as the mechanism.').
|
||||
doctrine(a_cursor_rule_copies_between_panels_only_if_the_list_is_homogeneous,'improvise-uej8. delete-view-at-cursor and delete-formula-at-cursor step the cursor back whenever it is above zero and are RIGHT to, because their lists are one-kind so a cursor that does not move cannot escalate. The category panel interleaves headers and items, and there the unconditional step-back is WORSE THAN THE BUG: it retreats the first item of a category onto its own header -- the same escalation one row earlier -- and a held d walks backwards. The conditional form (step back only when the row below is not a sibling item) is total because CatTreeEntry has two variants. The property to check when copying a cursor rule is HOMOGENEITY, not the panel it came from -- and \"check the other panels for the same shape\" has the answer no, by argument rather than by inspection.').
|
||||
doctrine(deliberate_slack_can_become_load_bearing_with_nobody_editing_it,'Dirty::of(CellData) reporting formulas: true was written as deliberate over-reporting -- gotcha 35 records that over-reporting costs one redundant projection while under-reporting leaves a client wrong forever. improvise-g3no made effect::RemoveItem actually able to remove a formula, so that arm is now LOAD-BEARING and narrowing it would strand every client formula panel permanently. Nothing guards it; the existing test covers the LayoutGeometry twin only (improvise-en4s). The line was not edited and its comment still reads as slack. So a change elsewhere can promote an over-approximation into an invariant, and the comment that justified the slack becomes the thing that invites its removal.').
|
||||
doctrine(two_tickets_at_different_priorities_can_be_one_bug,'improvise-25bf was filed P3 as \"the category panel Space cycles the axis silently\" and improvise-z336 carried, as its third finding, a P2 \"the Filter: X = Y status is stale exactly while the user tries to lift the filter\". They are the SAME bug: the status persists BECAUSE the key that moved the axis writes nothing over it. Fixing the P3 fixed the P2 most user-visible symptom, and the tickets treat them as unrelated. When a cosmetic bead and a mechanism bead touch the same surface, check whether the cosmetic one is the mechanism seen from the user side.').
|
||||
doctrine(a_coordination_check_is_not_an_intent_abstraction,'Two mechanisms can both make a write safe and still not substitute for each other. improvise-gd28: the basis check answers whether a client picture is CURRENT; staging answers whether the USER wants to keep the edit. Proposing write-through because the writes are already basis-checked confuses a concurrency guarantee with a user-facing capability, and the loss -- being able to abandon a drill -- is invisible to any test that asks only whether an edit lands. When arguing that mechanism A makes B unnecessary, name the question each answers before agreeing.').
|
||||
doctrine(an_autostash_instruction_is_not_permission_to_pop,'A merge with merge.autoStash enabled prints \"When finished, apply stashed changes with git stash pop\" on a conflict. In this repo the stash stack is SHARED across worktrees and sessions -- the integration agent found SIX other sessions entries on it -- so a reflexive pop takes someone else work. What git had stashed was not even the agent own: bd auto-export of .beads/issues.jsonl, triggered by its own earlier bd list. Identify the entry BY SHA, apply it with \`git stash apply <sha>\` and never \`pop\`, then drop that one entry by re-finding its index by SHA. AGENTS.md warns about git stash; it does not warn that an autostash merge PRINTS THE DANGEROUS INSTRUCTION FOR YOU, as advice from git rather than as a choice you made. That is the same shape as the conflicted stash-pop that corrupted .beads/issues.jsonl earlier the same day.').
|
||||
doctrine(a_capability_and_its_key_can_land_in_different_tickets,'improvise-gd28 (PR #115) landed the client-side view history -- ViewBack/ViewForward on the wire, per-client stacks, dead frames pruned -- while resolve.rs declined() still refused view-back, because that file belonged to another agent who had no reason to look at it. The merged wave therefore contained the capability and not the key: a user pressing < is told the feature does not exist over machinery that fully supports it. Neither branch is defective and neither agent could see it; reading one you conclude it works, reading the other you never look. So when a wave ENABLES something, check the decline list against what it enabled -- and check the wave PR body does not claim the capability is reachable.').
|
||||
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
|
||||
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
|
||||
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
|
||||
@@ -430,9 +421,7 @@ decision('improvise-ni6k',cursor_invalidation_is_observed_not_declared,'No effec
|
||||
decision('improvise-864a',pages_artifact_reaped_by_a_foreign_successful_deploy,'Measured over 100 push-to-main runs 2026-09-19..2026-09-21: 12 of 12 artifact-not-found deploy failures had ANOTHER run SUCCESSFUL Pages deployment complete inside their own upload-to-deploy window, against 2 of 56 successes. Direction proved by the oldest run of the 17:47Z batch failing BEFORE the next deploy started and before any deployment existed, since deploy-pages fetches artifact metadata before creating one. NOT queue depth, NOT supersession, NOT retention, NOT the action deleting it. PR #46 tip-of-main guard aimed at the right quantity for the WRONG REASON and still must not ship: the association is not a mechanism, and 5f871f7 survived a foreign deploy inside its own window. Reproduce deliberately before fixing.').
|
||||
decision('improvise-zv8t',a_document_category_is_never_inferred,'fit_measure now builds its Overrides from the same header[..category_columns] slice it already handed Features::of, forcing each to Forced::Categorical, so the one place the document own answer was known is the only place either caller reads it from. Closes Artefact::NumericCategory and DatePart-over-a-category from the only production entry point. The Artefact arms STAY: crate::dataset is pub and takes the caller Overrides, so a caller that builds one without forcing still produces a Numeric role over a category column -- deleting the arm would derive the classification from one caller habits rather than from the roles. Cost accepted and filed as improvise-e8no: a wide numeric category now one-hots past MAX_FEATURES and refuses a fit that used to ignore it. Do NOT close e8no by making the forcing conditional on cardinality -- that is inference by another name, and makes the ENCODING depend on a budget rather than on what the document says.').
|
||||
decision('improvise-e5pf',the_mode_settles_where_the_grid_does,'resolve::changed_mode is DELETED. Every composition site emits the plain AppMode::Normal, and ClientState::fold settles against the grid it HOLDS via improvise_protocol::settled_mode -- one rule, also called by ClientSession::fold and App::settle_normal_mode, with App::normal_mode deleted as its host-side duplicate. Composition-time settling was PREDICTION: switch_view_at composes [SwitchView(target), ChangeMode(Normal)] and read the OUTGOING grid. Read-time derivation was rejected for having the same window and for moving the enumeration from composition sites to READERS, where a missed reader is silent. settled_mode settles the Normal pair ONLY -- Editing/RecordsEditing pass through -- which is why a records decision must ask the grid and never the mode. Same shape the repo already prefers: cursor_epoch over matching on CycleAxis, document_generation over matching on LoadModel, the dirty flag over a table of command variants.').
|
||||
decision('improvise-b8ot',carry_the_answer_on_the_node,'A delegated handler reads its operands off data-row/data-col/data-edit and its branch off the existing editable class, and derives NOTHING from DOM structure. gridPlan already counts DATA rows because a group header is not one, so walking up the table to find the row would be a second derivation of that and would disagree the moment a group header appeared. Ids name SURFACES only -- a per-row id must be unique per document and these are rebuilt every frame. The editor keydown/blur stay direct: one transient node at a time, and blur does not bubble. Five surfaces, one handler each, on the stable roots replaceChildren leaves in place.').
|
||||
decision('improvise-p7lh',the_totals_label_is_spelled_twice_on_purpose,'src/ui/grid.rs and web/shell.js both spell \"Total\", which fails design-principles section 5 -- would a third front end write this again. Kept local on the indicator() precedent, where the collapse glyph is spelled at both ends while the FACT is shared, rather than building a protocol string table for one word. Named as debt at the call site and in context/code/clients.md. ORCHESTRATOR RULING: this stands until a SECOND such string appears, at which point it wants a protocol home; one word is not a table.').
|
||||
decision('improvise-gd28',browser_drill_stages_identically_to_the_tui,'User ruling 2026-09-21: \"the staging scheme should be identical, because they are solving different problems.\" A wave agent proposed that a browser write through with ordinary SetCell commands rather than staging, since those are already basis-checked. The ruling rejects the substitution: the BASIS CHECK is anti-staleness coordination between clients and answers \"is your picture of this record current\" (ADR 001, gotcha 43, explicitly not a security boundary and not about intent); STAGING is an editing-session abstraction for one user and answers \"does this person want to keep what they typed\". Write-through would silently remove the ability to ABANDON a drill -- a user-facing capability, invisible to any test that only asks whether an edit lands. So pending_edits becomes client state, consistent with the view stack being client-local and the drill being ephemeral: all three say a drill is one person transient workspace. The commit is then a batch of SetCells taking its atomicity from clause 4, which improvise-yqpd delivered -- before that the batch could have half-applied, which is exactly what a staged commit must not do.').
|
||||
decision(view_server_split,multiple_clients_hold_different_views_of_one_model,'USER, 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\" This is the RATIONALE, not an implementation convenience, and it is the single premise from which the four rulings of that day follow: the view stack is client-local; drill is an ephemeral view that should not sit in Workbook::views; switching a persisted view is client-only; and browser drill stages identically to the TUI, because staging answers a question about one user intent while the basis check answers one about concurrency. gotcha 29 already recorded \"the model is shared and the UI is not\" as a ruling; this states WHY, and it converts the split from a fact into a TEST. The test to apply to any proposal: does it force two clients into the same view? If it does, it is wrong against the purpose, however convenient. Two corollaries that have already bitten: a view-scoped ModelCommand must NAME its view or it lands on whichever view the host happens to be on (improvise-lltt, fixed -- SetAxis, CycleAxis, TransposeAxes and SetPageSelection all carry \`view\` now); and a server that REJECTS a client switch is the server holding an opinion about a client UI, which is what the client-only ruling removes.').
|
||||
wire_carries(command,both_directions,'user intent up, projection commands down').
|
||||
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
|
||||
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
||||
|
||||
Reference in New Issue
Block a user