Branch
[DOC] ADR-013: say which duration call_tts returns Section 4 ratified call_tts's return as {'audio_bytes': bytes, 'duration_ms': int} without saying which duration it means. The key name reads naturally as playback length, and the implementation returns wall-clock HTTP latency, so a reader could reasonably build on either. The module keeps the two apart deliberately: duration_ms is how long the vendor took to answer, audio_duration_ms is how long a recording plays. A 3 second call can carry 60 seconds of audio, so a consumer confusing them is wrong by orders of magnitude. call_tts bills by characters and computes no playback length, so it reports none. Section 4 now says all of this, because the contract is what a coder reads before writing the consumer, while a docstring is what they read after something already looks wrong.
[DOC] ADR-013 + ADR-032: catalogue speech models, settle language resolution ADR-013 section 4 named model_type gaining only 'tts' and said nothing about speech-to-text. That left transcription without a catalogued model, so it had no pricing, no routing and no place in the model catalogue - while reusing 'tts' for it is forbidden by that same section's rule against merging the two directions. Section 4 now grants both 'tts' and 'transcription'. The existing 'audio' capability flag is still reused; no new capability flag is introduced. The decisions directory is append-only, so the in-place amendment is recorded in ADR-013's own header and on its index row rather than left silent. ADR-032 settles how any AI modality resolves a language, deliberately modality-agnostic so chat, editor and search can cite it too: a four-tier ladder, auto-detect as advisory only, coverage declared as res.lang data on the model, ISO 639-1 normalisation applied in both directions, and failure rather than silent fall-back with language_any as the sole wildcard. Adding a language becomes a configuration act rather than a code change. The roadmap records that full-duplex conversation is deferred to v19 and names the seam it will attach to, so the deferral is a decision on the record rather than an omission.
[DOC] roadmap + migration: reconcile with the relocated code The 50 AI/Brain modules now live in Viindoo/tvtmaaddons (47) and Viindoo/erponline-enterprise (3). Reading both repos back against this roadmap turned up two claims that were not true of the code, plus six counts or references that had drifted. Corrected, each on the evidence it rests on: - M7-4 goes back to [ ]. The ADR-029 decision stands, but the execution never landed: viin_ai_chatbot on odoo-tvtma17's 17.0 HEAD is still version 1.0.1, carries only migrations/16.0.1.0.1/, and holds zero references to viin_ai_livechat. The planned 1.0.2 bump and migrations/17.0.1.0.2/ do not exist. migration.md described the same work as already done and now reads as the design it is. - M7-5 claimed the asset gate asserts an AI bundle under 500KB gzip. No such assertion exists in that test or anywhere else; only the viin_brain 450KB budget is real, so the AI-side bundle is ungated. - be53a191 is not a commit in any repo. The fix it credits is cdfd038519. - The skill catalogue splits 72 kw-* / 41 viin-*, not 62 / 51. The totals 113/13 and 115/14 were right. - SQL templates total 35 today; 23 was the last figure ever recorded. - Eight work routines ship, not seven. routine_dunning_weekly is named elsewhere in the doc but was never added to the tally. - Four line references in 2A W3.2 had slid 1-3 lines; symbols unchanged. - Wave 4 covers 46 icon files of which only 39 are byte-distinct - five viin_brain_* modules share one identical file. Add 9.1, which records a rule that fell on the floor during the move. Commit 4bbdabb6bc dropped every migration script and pinned each module to 1.0.0 because the ai17 repo had no deployment to protect, and made its own removal conditional on that changing. The rule lived in ai17/AGENTS.md 0.1, and merge 8d238c74 states plainly that repo-level files stayed behind. Neither destination repo carries it; neither has an AGENTS.md at all. Bare 1.0.0 turns out to be the house style there - 126 sibling modules use bare manifest versions with series-prefixed migration dirs - so nothing needs renumbering. What is missing is the migrations. No back-migration is owed either: no tag, no customer repo and no SaaS config shows any of these modules installed anywhere. That is absence of evidence rather than proof, and 9.1 says so. The invariant bites at the next schema change, and ADR-031's translate=True is the first one due. Where to write the rule down in a shared repo is left to the owner. Also flag the audio capability as declared but dead: provider_google_gemini and vendor_google both seed it while call_transcription is a bare NotImplementedError, so the UI advertises what a call would fail on.
[DOC] roadmap + ADR-030 + ADR-031: close the observability gaps Reconciles the roadmap with the code now that both observability gaps are closed, and records the two decisions the work produced. ADR-030 covers the quality-cost report: the grain is the trace, whose UNIQUE(trace_id) makes it a database guarantee rather than a convention, and both n-sides collapse before the join because a naive one inflated the cost total up to twentyfold. It records why agent_id comes from the spine and not the usage log, why the base module declares empty seams the bridge fills, and why only a SQL string assertion can guard that boundary on a CI database that always carries the auto_install bridge. It also carries two corrections: the design's claim that an archived agent's label degrades for a plain user is false on 17.0, since a many2one label resolves as superuser; and its claim that the standalone guard runs correctly under a seam patch is false, because patching a seam changes the rendered SQL while the field stays in the registry. ADR-031 defers translate=True on the seeded-record fields and says why the deferral is deliberate: the columns holding what a user reads are varchar and text rather than jsonb, so a catalogue cannot reach them at all, and adding the flag converts a column on live customer data. It states the exclusion as a rule rather than a list - any field whose value is read by a machine must not be translatable, however much the text reads as prose - and records the first field that rule closes. The Phase-5 onboarding wizard item stays open; it was never in scope.
[REM] *: remove the 3 Enterprise bridge modules migrated to Viindoo/erponline-enterprise These modules now live in Viindoo/erponline-enterprise with full history preserved, imported by PR https://github.com/Viindoo/erponline-enterprise/pull/4179 (merge commit 64ec6dac there). This removal must NOT be merged before that PR has merged, otherwise the code would vanish from every repository at once.
[REM] *: remove the 47 modules migrated to Viindoo/tvtmaaddons These modules now live in Viindoo/tvtmaaddons with full history preserved, imported by PR https://github.com/Viindoo/tvtmaaddons/pull/14355 (merge commit 8d238c74 there). This removal must NOT be merged before that PR has merged, otherwise the code would vanish from every repository at once.
[FIX] viin_ai_brain: correct quote style in tour selector for ESLint compliance The selector string on line 154 was incorrectly single-quoted, which violates the module's ESLint/prettier convention (double quotes for selectors without inner attributes). This lint-gate regression was introduced in 8d5240fb.
[FIX] viin_ai_ops: an unprovable proposal gets the strictest outcome, not the default Acceptance rejected the previous fix on six High-tier scenarios, and it was right. That fix moved the unknown-model case from auto-executed to pending, and stopped there. But an administrator's model-scoped block yields rejected for a proposal that names its model, while the same proposal with the field empty yielded pending - which one approver can wave through, after which it executes. Omitting impacted_model still downgraded a hard block into an approvable request. Two code reviews read that as defensible; the rule requires monotone OVERSIGHT, and rank 1 is not rank 2. The scan no longer aborts at the first policy it cannot rule out. It runs to completion and takes the maximum over the oversight lattice of every outcome still reachable: each policy that agrees on all the proposal's OTHER axes and is scoped to a model that cannot be excluded, plus the first unscoped policy reached (nothing behind it is reachable for any model), or the unmatched fallback when no catch-all is reached. Taking the first rather than the max is exactly what made the earlier attempt wrong. Nothing is recorded as the matching policy. The status stays "model unidentified" and policy is None, because the resolved action is a statement about the policy SET - nothing configured here permits less than this - not an attribution to one rule. Naming the strictest candidate would assert that a rule scoped to a model the proposal may never have touched governed it, which is a false statement in an auditable subsystem, and it would break the workflow bridge that reads this status to tell "no orchestration configured" from "the governing policy could not be determined". The sudo gate generalises the same way: allow_runs_as_sudo is now the AND over candidates, since an opt-in must not be borrowed from a rule that may not govern. An unranked action falls back to maximum oversight rather than raising. Both directions are held. The over-correction set - a permissive rule legitimately in front of a block, a block filtered out by risk or scope, another company's policy - passes at HEAD AND after the fix, which is what proves the change is not a blanket block. Reverting the model file reds 14 tests and errors 2 more; the S9 discriminator names the twins and the strictest outcome in its failure message. Two consequences stated rather than buried. A single model-scoped block now blocks every proposal recording no impacted_model, not only those that might have hit that model - a real widening, and what the rule's wording forces. And two legacy assertions changed, because they encoded the earlier partial fix. 96 tests green on two databases, plus the bridge, approval, approval_account and workflow suites. Test setup now archives pre-existing policies inside its own rolled-back transaction. A shared test database carried leftover fixtures that reddened 13 tests before any of this; the suite should not depend on which rows a previous run forgot.
[FIX] viin_ai_approval: the AI advisory survives an upgrade on all three bridges This PR fixed install-only wiring for the nine Brain sidebar carriers and left the same defect standing in the three approval bridges. Half a class is worse than none - it reads as cleaned up. It was not theoretical. On a database built from this source, all three account approval types carried NO wiring at all, so the AI risk advisory was silently not installed on any of them, and no upgrade could repair it. The root cause is measurable, not inferred. viin_ai_approval_account finished installing at 11:02:21.228; the three account types were created at 11:02:27.327, the instant the chart of accounts landed - because the upstream generator skips a company with no chart_template. The install-only hook ran six seconds before the records it was meant to wire existed. The purchase bridge escaped by luck alone: its type was created at 11:02:22.757, before the bridge installed at 11:02:25.259. That ordering accident IS the defect class. Wiring now lives in one shared method on approval.request.type, called from a per-bridge data-file <function> - the pattern the Brain carriers proved, which Odoo runs on install AND on every upgrade. One detail is load-bearing: the <function> could NOT go in the existing seed file, because _tag_function returns early inside a noupdate block, which would have re-created the bug in silence. Each bridge gets a second, update-loaded file instead. The non-clobber guard is untouched - same has_executable_code, same two conditions, so a customer's own hook is still never overwritten. Only the caller moved. Idempotent by construction: once wired, the body IS executable code and the agent IS set, so a second pass writes nothing. Measured over three upgrade passes: the fragment appears exactly once. Guarded in both directions. Removing the mechanism reds five tests and leaves the database untouched, which is the defect reproduced. Dropping the file from the manifest while leaving it on disk reds the manifest assertion - and note the wiring test stays GREEN there, because convert_file drives the file regardless of the manifest. Without that assertion the bug would return with a green suite. test_advisory_account was not weakened to pass; its assertions are byte-identical. It is green because the three types now read wired after an upgrade, where they read unwired before. One gap deliberately left, so it is a decision rather than an oversight: a company created AFTER install gets fresh approval types with no advisory until the next upgrade. Same family, different trigger, and closing it needs a cross-bridge registry of which bridge owns which codes - the Brain cluster needed its own ADR for the equivalent.
[FIX] viin_ai_ops: duplicating an orphaned policy no longer widens it to every model A post-fix review found a fail-open the PREVIOUS fix created. When ondelete cascade became set null plus a stored mirror, duplicating a policy became possible for the first time - and copy_data passes model_id: False, so the create guard `if "model_id" in vals` fired and wiped the mirror. Duplicating an orphaned auto_execute rule produced one governing EVERY model, contradicting the help text that promised the mirror prevents exactly that. The guard is now `if vals.get("model_id")` on create: the picker wins whenever it names something, and an empty picker never clears an accompanying mirror. write() stays asymmetric on purpose - emptying the picker there IS the administrator widening the rule - and both docstrings say so. Two alternatives were checked against core rather than assumed. copy=False on the mirror is backwards: copy_data emits every copyable field, so copy=True is precisely what carries an orphan's scope across the duplicate. Preserving model_id is impossible for an orphan - the ir.model row is gone. The matcher's answer is now separate from its reason. Returning None conflated "no policy matched" with "matching stopped because the model is unknown", and the workflow bridge reads None as "no orchestration configured" - so a proposal a HUMAN HAD APPROVED stalled at approved forever. It now returns a status, and the bridge holds the approval rather than consuming it: filling in impacted_model and re-running resolves normally, and the stall logs what to do. Falling through to the unscoped catch-all was rejected - it would assert a governing policy the proposal never evidenced, and its chain can carry FEWER review steps than the model-scoped rule that could not be ruled out. The docstring claiming pending is never more permissive is corrected; it is monotone in OVERSIGHT, not in outcome, and replacing an unscoped block with a human decision is the one direction it is more permissive. match_summary now reflects the rule being built, in both directions - including the one nobody reported: emptying the picker on a saved rule is the administrator widening it, and the widget must say so before the save, not after. And the skipTest is gone. Both readings of the fleet tally were wrong: == 13 is falsely red on a partial fleet, while skipping protects nothing in the commonest install mode. It asserts a per-connector count for every installed connector, with a guard that those still sum to the signed-off 13. Nothing skipped, nothing falsely red. Proven falsifiable by deleting a template for real - the data file is noupdate, so the stale row had to go too.
[FIX] viin_ai_chat: 62 strings the extractor could never see Two assertions this PR added could not fail. 'Agent error: %s' is always a non-empty string and the controller never emits a traceback, so both held no matter what the code did - written while making tests locale-stable, and breaking the exact property that work existed to protect. They now assert the CAUSE survives into the reply. Proven twice: red when the content is emptied, and red when the friendly wording is kept but the cause dropped - the second is the regression the old form structurally could not catch. A fixture string was still wrapped in _(), shipping "No credits left." into the released catalogue. It is an opaque sentinel now, and the assertion binds to the exception CLASS surviving the round trip rather than to prose. The bigger item: 62 user-facing strings across six templates were written as t-esc="_t('...')" or t-att-title="...", neither of which Odoo's extractor can see - it reads element text and a fixed attribute allow-list, and the t-att- form is not in it. The tool-confirm dialog, the first-run banner, the session rail and the feedback widget were entirely absent from the catalogue. This PR had demonstrated the fix on ONE string and left the rest, and the new vi_VN.po then MASKED the gap by making the module look translated. All 62 move into component getters, and the escape hatch that enabled the pattern is deleted so it cannot return quietly. Catalogue: viin_ai_chat 95 -> 142 msgids, viin_ai_brain 77 -> 85, every new entry translated. Three comments this PR added were false. sql_template.description IS rendered - it has its own notebook page - so the decision to keep it non-translatable stands but the stated reason did not. And the note added to four manifests claimed a non-application module gets no Apps tile: core annotates 'images' as a WEBSITE key, its only reader is the theme-image updater, update_list never copies it, and the backend tile is icon_image for every module. The reviewer was right and the comment was wrong; it now says what is true. Two reds found while working and fixed: a dashboard test that had become ambient-DB-dependent since the usable-provider resolver landed - it resolves a provider before reaching the stub, and every provider on the test database is keyless - now carries its own fixture; and making "Reported" extractable would have reddened a tour selecting on that word, so the tour binds to a class instead.
[FIX] viin_ai_ops: an unidentified proposal cannot fall through to a permissive rule The model axis added earlier in this run skipped a model-scoped policy when the proposal recorded no impacted_model, and its docstring called that fail-closed. It was not. _match_proposal is first-match-wins over sequence, so skipping hands the proposal to the NEXT policy, which may be more permissive. With a block on account.move at sequence 10 and an unscoped auto_execute for low risk at 20, a proposal with no impacted_model was auto-executed - exactly what the administrator had blocked. That is the shape the feature's own motivating example requires, not a contrived config. A model-scoped policy that agrees on every other axis now STOPS the scan, and routing lands on the subsystem's documented default-safe pending. A policy whose own risk or scope filter already excludes the proposal does not stop it - it could not have governed the proposal under any model, so passing it by loses nothing. The result is monotone in oversight in both directions: where that policy would have governed, pending never auto-executes in its place; where it would not, pending is never more permissive than what the scan would have found. The old test created ONE policy, so it never reached the fall-through its own docstring described. The new one puts a model-scoped block ahead of an unscoped permissive rule and was watched to fail against the old code. Rejected alternatives, for the record: making the unknown model match every model-scoped policy would make the unknown case MORE permissive against an auto_execute rule; terminating at the model-scoped policy's own action would write a block record naming a model the proposal may never have touched, which is a false statement in a component whose purpose is auditable governance. model_id also loses ondelete=cascade. The same wave argued impacted_model must be a Char so a proposal survives uninstalling the application that provides its model - then let the POLICY be deleted on that event, and Odoo drops ir.model rows transiently during an upgrade too. An administrator's block rules were being destroyed with no audit trace, and reinstalling never brings them back. It is now set null plus a stored model_name mirror maintained in create/write, because PostgreSQL performs the SET NULL directly and never calls the ORM. The views show the technical name so an orphaned rule reads as orphaned rather than blank. A Vietnamese tooltip that promised the old cascade behaviour is corrected. Three more from the same review: The skill de-branding guard matched vendor names as WORDS, so the lowercase path reference/hubspot-scoring.md was invisible even to it - and three such companion files were shipping as live markdown links to documents that do not exist, injected verbatim into the system prompt. Fixed in the converter and regenerated; the guard now fails the build on any dangling companion reference anywhere in the catalogue, verified to flag exactly those three and to leave genuine customer-side vendor mentions alone. Two discuss-bot assertions had been loosened to "some new message appeared", which a generic error notice satisfies equally. The notice carries no author and no distinguishing subtype, so there is no language-independent discriminator; they use with_context(lang='en_US') like their siblings, and were proven still able to fail by replacing the production notice with a generic one. Twelve banned dash characters remained repo-wide, including one this run had propagated from a source file into two new catalogue entries. All fixed; upstream Apache-2.0 material under viin_ai_skill/research/ is left verbatim. Three test docstrings quoted the removed expression and cited a line that no longer holds it. They now describe what the matcher does.
[FIX] test_viin_ai: WoW-demo work item state draft, not in_progress Runbot's full multi-tenant post-install run reds viin_ai_workflow's TestStuckWorkflowCockpitProvider.test_stuck_workflow_is_non_negative_int_at_zero_state (1 != 0). That module's stuck-workflow cockpit metric counts ALL in_progress work items as the user (a deliberate NO-UNDER-REPORT governance floor), so this PR's new wow_demo_cfo demo work item - seeded state='in_progress' - made the module's zero-state contract (0 stuck on a demo install) false. The demo item is scene-setting CONTEXT only (Beat 4 of the compound test creates the LIVE governed work item at runtime via routine._run()); set it 'draft' so it no longer inflates the governance metric. Verified: TestStuckWorkflowCockpitProvider 6/6 + TestWowDemoCompound 1/1 green, demo in_progress work-item count 1 -> 0. The pre-existing test + the stuck-workflow provider are untouched. Signed-off-by: David Tran <davidtran.hp@gmail.com>
[FIX] test_viin_ai: mock the provider in the WoW-demo tour (offline chat reply) TestWowDemoCfoTour drove the AI Chat waypoint against a live provider: the demo Chat action carries no agent_id, so the controller resolves the lowest-sequence agent (the keyless OpenAI demo agent), which hits a live 401 and never renders an assistant message - so the tour's '.o_viin_ai_chat_role_assistant' waypoint would time out on Runbot (real headless Chrome). It never surfaced in the co-install gate because the tour chrome-skipped under machine contention; a real-Chrome capture pass exposed it. Wrap the tour in the CD-0 VIIN_AI_MOCK_PROVIDER env hook (MockProviderEnvHookMixin) so every provider call returns the deterministic offline mock reply - the 'offline mock reply' the waypoint already asserts, and ADR-016's no-real-key contract. Signed-off-by: David Tran <davidtran.hp@gmail.com>
[FIX] viin_brain_account_reports: move backlink patch into the lazy Brain bundle Runbot 'install all modules' crashed pregenerating the eager web.assets_backend: ValueError: Cannot create 'viin_brain_account_reports.None' because the template to inherit 'viin_brain.BacklinkPanel' is not found. viin_brain's CD-2 lazy split (654cd9d9) moved BacklinkPanel (JS component + QWeb template) into viin_brain.lazy_brain_app, but this module's backlink_panel_patch (JS patch + QWeb t-inherit) was still declared eager - an eager asset patching a now-lazy component/template, which fails eager-bundle compilation whenever both modules are co-installed. Move the patch into the SAME lazy bundle (the identical fix already applied to viin_ai_brain's fact-history override). A whole-repo sweep confirms these two were the ONLY cross-module eager->lazy BacklinkPanel edges. Signed-off-by: David Tran <davidtran.hp@gmail.com>