Repository navigation
feat(review): the review queue, accept as the only writer, thumbs-up proposals (Q1) - #337
Conversation
Review of Q1 (#337)Rebase needed. #336 (K1) squash-merged as Tests run locally, on the Q1 head with K1's entry point registered: The admin check, scope visibility ( Findings
VerdictFix first.
|
… thumbs-up proposals (Q1)
…oposals export (Q1)
…rated hash, complete tables, current value (Q1)
7f5d286 to
90c475f
Compare
|
Thanks for the review. The branch is rebased onto
Checks run locally:
|
Design:
docs/architecture/wave_b.md(Wave B, release 0.8), row Q1 of the PR plan (section 16); section 5 and decisions D41, D42, D43, D60 and D61.Rebased onto
mainafter K1 (#336) and C1 (#332) merged. Q1 uses K1's glossary appliers (nl2sql/knowledge/apply.py) andwrite_glossary.What changed
The queue (
nl2sql/review/queue.py)propose(ctx, proposal)is how every track files a finding. It handles three cases:proposal.createdwhen a row is new or revived.Scopereaches its datasource and every name intables.getof a hidden proposal isNOT_FOUND.bywith an admin role.Accept and reject (
nl2sql/review/accept.py)accept:<real path>, waiting up to 10 s for another caller), re-reads each item as still proposed, and checks every file's hash. A call refused by those checks writes nothing.proposal.decidedpublished) under the locks, right after that file's write.rejecttakes the same locks. So an id is accepted only once, and if a later file's write fails, the items already written stay accepted and the rest stay proposed.apply_proposalandwrite_glossary, then the knowledge is reloaded.verified_queryitems run the curated checks on the edited section, thenwrite_curated, thenRuntime.replace_curated, then the curated index refresh. The refresh goes throughrebuild_index(curated_only=True), which callsrefresh_curated.refused.proposal.decidedis published for each decision; the audit observer keeps it.The curated file (
nl2sql/review/curated_file.py)curated_path(config)returns None when the section is inline, absent, or the config was built in code.canonical_curatedgives the canonical text, andwrite_curated(path, curated, expected_sha256)writes it: atomically, through symlinks, refusing withFILE_CHANGEDwhen the file moved since it was loaded.Runtime.curated_sha256isloaded_sha256(config): the file is read once at build, hashed and parsed. When it no longer holds the section the config loaded, the hash isSTALE, so accept refuses withFILE_CHANGEDinstead of writing over the edit.Runtime.replace_curated(curated, *, sha256)swaps the section and its hash.The review file (
nl2sql/review/review_file.py):renderwrites one,readreads it back.exportandapplyare built on them.The facade:
engine.proposals.list,get,accept,reject,exportandapplyare built (api/proposals_api.py).current(id, *, user_context)is new: what a proposal would replace, as the caller may see it.applyreplaces each item'schangewith the file's;accept(edits=...)still merges.Thumbs-up
engine.feedback.record(..., rating="up")files averified_queryproposal. Itstablescome frominspect_verified_sql, and its origin isthumbs_up. If filing fails, the failure is logged and the stored rating is still returned.feedback/proposals.pynow only builds the change:skip_reasonandverified_query.build_proposals,render_proposals,write_proposalsandnl2sql curated proposals exportare deleted.Retention:
engine.storage.purge()returns aproposalscount: decided rows older thansettings.review.max_age_days.nl2sql storage purgeprints it.CLI:
nl2sql review list,show,accept(with--all),reject,exportandapply. Each takes--asand--user, through K1'scaller.Where the code differs from wave_b.md (small gaps filled)
allow_all: any caller may decide, as K1'sreloadalready does. There are no roles to hold, and the CLI's--as adminis refused underallow_all.AcceptReport.rejectedis new (a field added to B0's model): it lists the proposalsapplyrejected.applyrejects per item. It makes oneacceptcall, but rejects each item with its own reason, so it does not make the singlerejectcall the design describes.acceptlists an unknown or already decided id inrefusedand does not raise.rejectrefuses the whole call withNOT_FOUND, because it returns only a list.tablesnames. It does not check text mentions the way K1's knowledge filter does.proposecompletestablesfrom the target and change (named_tables), so a missing name cannot leak a denied table.engine.proposals.currentis a new read method (it is in the boundary test's D60 list). It replaces logic the CLI would otherwise hold, and the playground and REST will need it in Wave D.components()), not inside the config loader. That keeps the loader andconfig/models.pyuntouched (D59).rebuild_index(..., curated_only=True), so it holds the index lock. A datasource it cannot refresh (for example, one not indexed yet) becomes a warning, not a failure.FILE_CHANGED.${...}reference incurated.yamlreads as stale. The parsed file is compared with the interpolated section, so a file that holds one refuses every accept. This is documented.review/queue.pyjoinsACCESS_DECISIONSin the boundary test.observers/audit.py. It needed no change: B0 already markedproposal.decidedas audited in the catalog.Tests (written first)
tests/unit/test_review_queue.py(26). It has every test the row names, plus the CLI and the review fixes (listed after the named tests). Named tests:test_accept_needs_an_admin_roletest_accept_verified_query_writes_curated_yaml_and_refreshestest_accept_runs_the_curated_checks_firsttest_accept_refuses_an_inline_curated_section(also: nocuratedsection, and noyamlknowledge source)test_accept_refuses_a_changed_filetest_concurrent_accepts_do_not_lose_a_write(threads in one engine, and two engines)test_accepting_many_writes_each_file_oncetest_accept_warns_when_a_source_shadows_ittest_rejected_change_is_not_proposed_againtest_new_change_supersedes_the_open_proposaltest_proposals_list_hides_denied_tablestest_get_of_a_hidden_proposal_is_not_foundtest_thumbs_up_creates_one_proposal_per_changetest_review_file_round_triptest_apply_reports_unknown_idstest_decided_proposals_are_purgedtest_curated_proposals_export_is_gonetest_review_commands_list_show_accept_and_rejecttest_the_same_proposal_accepted_twice_at_once_is_accepted_onceandtest_a_reject_waits_for_an_accept_in_flight. Both are deterministic: the first call is held inside its decision step, and the second is at the lock before it is released.test_a_failed_second_write_keeps_the_first_files_decisionstest_curated_yaml_edited_before_the_engine_was_built_is_not_overwrittentest_proposal_tables_cover_every_table_its_change_namestest_current_value_is_shown_only_when_the_caller_may_see_ittest_a_failed_proposal_never_fails_the_ratingtest_apply_replaces_a_change_rather_than_mergingtest_only_accept_writes_a_persons_fileintests/architecture/test_boundaries.py.test_wave_b_shapes.py: the Q1 stubs are removed.test_readme_cli_drift.pytest_trace_result_stores.pyandtest_phase2_events.py: theproposalspurge count.cli/test_config_flag.py: checksreview listin place of the deleted command.test_curated_proposals.py, whose subject is deleted.Docs
configuration/review.md(the queue, who sees and who decides, the facade, accept as the only writer, the review file, the CLI), with its nav entry.configuration/curated.md: proposals go through the queue, and accept rewritescurated.yamlcanonically (a warning that comments are not kept).observability/feedback.mdarchitecture/curated_answers.md: the writer sentence now reads "Nothing writescurated.yamlbut a person's accept", and the old export section is marked as replaced.architecture/invariants.md: the new invariant Only an Accept Writes a Person's File.observability/events.md,configuration/storage.md,api/core/public-facade.md,configuration/knowledge.md, and the README's CLI reference.architecture/wave_b.md: the Q1 Built note under the PR plan.Checks
tests/architecture/test_boundaries.pyand the touched or related files:test_review_queue,test_wave_b_shapes,test_readme_cli_drift,test_trace_result_stores,test_phase2_events,cli/test_storage_purge,cli/test_feedback_command,test_feedback_store,test_cli_curated_validate,test_cli_curated_test,test_curated_startup,test_knowledge_sources,test_cli_knowledge,test_playground_feedbackandtest_index_curated_refresh. Result: 257 passed, 2 skipped.mkdocs build --strict: OK..envis read.For later rows (exact names)
The
tablesrule, for F1, P1 and L2: a proposal'stablesmust list everydatasource.schema.tableits target and change name. A caller sees a proposal only when their scope reaches all of them (D60).proposecompletes the list from the target and change (named_tables: a table, a column's table, both sides of a join, a term'stablesand itscolumns' tables, and the tables a verified query's SQL reads). Passtablesyourself anyway, and add a test that a caller denied one of them does not see the proposal.F1 (corrections):
Proposal.build(kind="verified_query", origin="correction", change={id, description, questions, sql}, tables=table_names(ctx, ds, inspect_verified_sql(sql, dialect).tables), ...)and file it withnl2sql.review.queue.propose(ctx, proposal).curated.yaml.api/feedback_api.pyafter this PR.recordnow callsself._propose(stored).P1 (profiler): build
join,columnandtableproposals the same way, withorigin="profiler"andevidencecounts, and callpropose. Usetable_namesfortables. Re-profiling updates an open row'sevidence, and never revives a rejected one.K3: the curated refresh is the call
rebuild_index(ctx, datasource_ids=..., curated_only=True)in_refreshed()innl2sql/review/accept.py. Rename it torefreshthere.L2:
save_choicefiles atermproposal (target= the term's name,tablesfrom the term) throughpropose. Accept replaces an existing term of the same name (K1'sapply_change).nl2sql review showalready shows the current term beside the proposed one.Shared names:
nl2sql.review.queue:caller,require_admin,visible,who,split_table.nl2sql.review.accept:accept(ctx, ids, by, edits),reject(ctx, reasons, by).