fix(calls,memory): a managed script run's calls are audited and not cataloged, and verifying a memory record is not editing it (#1624) (#1625) - #1637
Merged
Conversation
…ataloged, and verifying a memory record is not editing it (#1624) (#1625) The call catalog answers one question about a recorded call: is this worth running again. A managed script run never produces that answer, and #1614's persona exclusion could not keep its calls out, because a run presents the persona of the person who wrote the script. On the deployment measured, 1,973 of 2,588 records were the six calls one hourly schedule makes, every one embedded for the calls search source, every one with a reuse count of zero, all under the persona that person also calls under. The rule is now read off how the call arrived. `callrecord.PersonaExclusion` becomes `Exclusion` and `Excludes` takes the persona and the source, so a call whose audit event carries `source: script` is excluded whatever persona it presents, with no knob: a deployment that wants a run's calls cataloged has no such case, since the run record and the produced asset already hold them. The source is the audit event's own field, set on the run's server context by the script runner (`internal/platform/scriptexec/runner.go:139`), so a caller cannot present it and a run cannot omit it. One statement, read in three places: the recorder never writes the row, the call reference hands back no `mcp:call:<id>` an agent could cite to nothing, and the sweep gains a third arm (`r.user_id LIKE 'script:%'`) that clears the rows written before this existed, evidence clauses standing. `MCPCallReferenceMiddleware`'s predicate widens from the persona to the persona and the source, which is a hard cut rather than an added parameter with a default. Nothing else about a run changes. The calls are audited in full, with their retention and the API gateway's metrics untouched, so what a schedule did stays visible. An asset a run writes still records the calls that fed it: provenance capture reads audit rows and not catalog rows (#1320), so a run's `save_asset` captures through the run's own session window. `platform.export` takes no provenance capture and never did; it writes an object, a version row and a `RunOutput`, so the acceptance test asserts the capture path the platform has. The memory staleness watcher, meanwhile, was marking records verified that it never checked and re-dating every record it touched. `MarkVerified` set `updated_at` alongside `last_verified`, so a background pass every fifteen minutes made every record in a library look edited minutes ago, on a column the listing sorts by and every reader takes as the last content change; it is the defect class #1466 removed from assets. And a record carrying no entity URN was appended to the verified set without a check, since there is no catalog to ask about it, which `review_stale` then read as freshly verified. `MarkVerified` now sets `last_verified` alone. The writes that change a record move `updated_at`. The watcher's batch is drawn from entity-linked records through a new `Filter.EntityLinked`, so a batch of fifty is fifty checkable records rather than fifty rows the cursor merely passed, and `last_verified` stays null on a record no catalog can be asked about, which is what the portal and `review_stale` should read as not subject to verification. The filter is `entity_urns @> '[]'::jsonb AND entity_urns <> '[]'::jsonb` rather than `jsonb_array_length(...) > 0`. The column is `NOT NULL DEFAULT '[]'`, but a record written with a nil slice holds the JSON scalar `null`, on which `jsonb_array_length` errors 22023, and a `jsonb_typeof` guard beside it does not help because PostgreSQL may evaluate the call first. The real-database gate is what refused all three earlier forms; sqlmock accepted every one. The dev stack now runs the staleness watcher, at a fifteen-second interval rather than the fifteen minutes a deployment uses. It never ran it before, so a criterion about what a background pass does to a record would have passed locally by never running one. Two keys the platform removed are gone from the same file: `tuning.rules.require_datahub_check` and `warn_on_deprecated` are not fields of `RulesConfig`, and setting them warned on every start. `make verify` passes on this tree. `test/acceptance/issue_1624_test.go` runs a script through `run_script` and reads the admin calls, audit events, calls search and asset provenance; `test/acceptance/issue_1625_test.go` waits for a real watcher tick. Transcripts are at `build/1624/acceptance.md` and `build/1625/acceptance.md`. Closes #1624 Closes #1625 Claude-Session: https://claude.ai/code/session_019JMyHNgVWUi7SgRctEdDQF
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1637 +/- ##
==========================================
+ Coverage 91.51% 91.56% +0.04%
==========================================
Files 776 776
Lines 77765 77767 +2
==========================================
+ Hits 71168 71205 +37
+ Misses 4322 4288 -34
+ Partials 2275 2274 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1624
Closes #1625
Two records of what a system did, each of which had started saying something else. The call catalog was mostly one hourly schedule's refresh calls, and a memory record's
updated_atsaid a background pass had run rather than that anybody had edited it.A run's calls are audited, not cataloged
The catalog answers one question about a recorded call: is this worth running again. A managed script run never produces that answer. Its statement is the script's source, its outputs are on the run record and in the provenance of the assets it wrote, and nobody fetches one of its calls to run it again. On the deployment measured, 1,973 of 2,588 records were the six
getGridpointForecastcalls one hourly schedule makes, every one embedded for thecallssearch source, every one with a reuse count of zero.calls.exclude_personas(#1614) could not name it. A run presents the persona of the person who wrote the script, so a schedule's calls are indistinguishable by persona from that person's own, and naming the persona would have excluded both.So the rule reads how the call arrived instead.
callrecord.PersonaExclusionbecomesExclusionandExcludestakes the persona and the source: a call whose audit event carriessource: scriptis excluded whatever persona it presents. There is no new setting. The source is the audit event's own field, set on the run's server context by the runner (internal/platform/scriptexec/runner.go:139) and carried to the audit row, so a caller cannot present it and a run cannot omit it.One statement, read in three places:
Recorder.recordFromMCPCallReferenceMiddlewaremcp:call:<id>that would resolve to nothingr.user_id LIKE 'script:%'clears the rows written before this existed, evidence clauses standingThe middleware's predicate widens from
func(persona string) booltofunc(persona, source string) bool— a hard cut, not an added parameter with a default, so no caller can pass the old rule by accident.What a run keeps
Everything except the catalog row. The calls are audited in full, with their retention and the API gateway's metrics untouched, so what a schedule did stays visible to an operator. A person's call in the same persona, in an ordinary session, is cataloged and searchable exactly as before.
An asset a run writes still records the calls that fed it. Provenance capture resolves from audit rows and not from catalog rows (#1320), and since the run is handed no reference to cite, the capture comes from the run's own session window.
One correction to the ticket: it asks that an asset written by
platform.exportcarry a provenance capture.platform.exporttakes no capture and never did —internal/platform/scriptexec/export.gowrites an object, a version row and ascript.RunOutput, and calls no capturer; the only capture sites are the portal toolkit's asset writes.test/acceptance/issue_1624_test.gotherefore asserts the path that exists, a run'ssave_asset, which is also the one this change could have broken.last_verifiedapplies to records a catalog can be asked aboutThe staleness watcher asks the catalog whether the entities a record names still say what the record says. A record naming no entity is not something it can ask about, and 34 of the 52 records on the deployment measured were of that kind: business knowledge, operational rules, preferences, episodic events. Each was appended to the verified set without a check, and
review_staleread the result as freshly verified.The watcher's batch is now drawn from entity-linked records through a new
memory.Filter.EntityLinked, so a batch of fifty is fifty checkable records rather than fifty rows the cursor merely passed, andlast_verifiedstays null on the rest — which the portal andreview_staleread as not subject to catalog verification rather than as a check nothing performed.The filter is
entity_urns @> '[]'::jsonb AND entity_urns <> '[]'::jsonb. The obviousjsonb_array_length(entity_urns) > 0cannot be used: the column isNOT NULL DEFAULT '[]', but a record written with a nil slice holds the JSON scalarnull, on which that function errors 22023, and ajsonb_typeofguard beside it does not help because PostgreSQL may evaluate the call first. Containment is true for every array, false fornull, and reads through the GIN index the column already carries.updated_atis the last content changeMarkVerifiedsetupdated_atalongsidelast_verified, so a pass every fifteen minutes re-dated every record it touched. All 52 records on the deployment measured carried anupdated_atof08:07:36Zor08:07:19Z, and fifteen minutes later the same records read08:36:37Z, while the newest actual content edit on most of them was weeks old. It is the defect class #1466 removed from assets, on a column the memory listing sorts by.MarkVerifiednow setslast_verifiedalone.Update,Supersede,ForgetandMarkStalemoveupdated_at; verification, which reads the catalog and changes nothing about the record, does not.The dev stack runs the watcher
memory.stalenessis enabled indev/platform.yamlat a fifteen-second interval, against the fifteen minutes a deployment uses. Nothing ran the watcher locally before, so a criterion about what a background pass does to a record would have held by never running one.Two dead keys go from the same file.
tuning.rules.require_datahub_checkandwarn_on_deprecatedare not fields ofRulesConfig— the first was removed with the warn-after-execution rule mechanism — and setting them produced an unrecognized-keys warning on every start.Verification
make verifygreen, patch coverage 36/36 changed lines.Both acceptance suites run against
make devthrough the real MCP client and the REST routes, with transcripts atbuild/1624/acceptance.mdandbuild/1625/acceptance.md:TestIssue1625_TheWatcherVerifiesOnlyWhatItCanCheckwaits for an actual watcher tick and fails rather than skips when none arrives.TestIssue1624_APersonsCallInTheSamePersonaIsStillCatalogedruns its script as an ordinary person so the control call is made in the persona the run itself presented, which is what makes the result about the source and not about the persona.Four RealDB tests cover the SQL, since the batch query, the marks and the sweep are statements a mock cannot judge:
TestStalenessWatcher_RealDB_VerifiesOnlyEntityLinkedRecords,TestStalenessWatcher_RealDB_BatchIsCheckableRecords,TestCallCatalogRealDBDoesNotCatalogAManagedScriptRun,TestCallCatalogRealDBSweepsAScriptRunsExistingRecords. The last seeds a run's rows, has an asset cite one of them, and asserts the sweep removes the other and keeps both the cited record and another person's draft.TestCallReferenceRegistrationAppliesTheCatalogsOwnRuledrives the shipped middleware-chain entry rather than a copy of it, so the predicate the platform builds from configuration is the one under test.