Skip to content

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
cjimti merged 1 commit into
mainfrom
issue-1624-1625-script-calls-and-memory-staleness
Sep 5, 2026
Merged

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
cjimti merged 1 commit into
mainfrom
issue-1624-1625-script-calls-and-memory-staleness

Conversation

@cjimti

@cjimti cjimti commented Sep 5, 2026

Copy link
Copy Markdown
Member

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_at said 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 getGridpointForecast calls one hourly schedule makes, every one embedded for the calls search 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.PersonaExclusion becomes Exclusion and Excludes takes the persona and the source: a call whose audit event carries source: script is 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:

Reader What it does
Recorder.recordFrom Never writes the row
MCPCallReferenceMiddleware Hands back no mcp:call:<id> that would resolve to nothing
The retention sweep Third arm r.user_id LIKE 'script:%' clears the rows written before this existed, evidence clauses standing

The middleware's predicate widens from func(persona string) bool to func(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.export carry a provenance capture. platform.export takes no capture and never did — internal/platform/scriptexec/export.go writes an object, a version row and a script.RunOutput, and calls no capturer; the only capture sites are the portal toolkit's asset writes. test/acceptance/issue_1624_test.go therefore asserts the path that exists, a run's save_asset, which is also the one this change could have broken.

last_verified applies to records a catalog can be asked about

The 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_stale read 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, and last_verified stays null on the rest — which the portal and review_stale read as not subject to catalog verification rather than as a check nothing performed.

The filter is entity_urns @> '[]'::jsonb AND entity_urns <> '[]'::jsonb. The obvious jsonb_array_length(entity_urns) > 0 cannot be used: the column is NOT NULL DEFAULT '[]', but a record written with a nil slice holds the JSON scalar null, on which that function errors 22023, and a jsonb_typeof guard beside it does not help because PostgreSQL may evaluate the call first. Containment is true for every array, false for null, and reads through the GIN index the column already carries.

updated_at is the last content change

MarkVerified set updated_at alongside last_verified, so a pass every fifteen minutes re-dated every record it touched. All 52 records on the deployment measured carried an updated_at of 08:07:36Z or 08:07:19Z, and fifteen minutes later the same records read 08: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.

MarkVerified now sets last_verified alone. Update, Supersede, Forget and MarkStale move updated_at; verification, which reads the catalog and changes nothing about the record, does not.

The dev stack runs the watcher

memory.staleness is enabled in dev/platform.yaml at 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_check and warn_on_deprecated are not fields of RulesConfig — the first was removed with the warn-after-execution rule mechanism — and setting them produced an unrecognized-keys warning on every start.

Verification

make verify green, patch coverage 36/36 changed lines.

Both acceptance suites run against make dev through the real MCP client and the REST routes, with transcripts at build/1624/acceptance.md and build/1625/acceptance.md:

--- PASS: TestIssue1624_ARunsCallsAreAuditedAndNotCataloged (0.97s)
--- PASS: TestIssue1624_ARunIsHandedNoCallReference (0.94s)
--- PASS: TestIssue1624_APersonsCallInTheSamePersonaIsStillCataloged (1.50s)
--- PASS: TestIssue1624_AnAssetARunWritesStillRecordsItsCalls (0.91s)
--- PASS: TestIssue1625_UpdatedAtMovesOnAContentWrite (2.60s)
--- PASS: TestIssue1625_AnEntitylessRecordIsNotVerified (1.03s)
--- PASS: TestIssue1625_TheWatcherVerifiesOnlyWhatItCanCheck (6.99s)

TestIssue1625_TheWatcherVerifiesOnlyWhatItCanCheck waits for an actual watcher tick and fails rather than skips when none arrives. TestIssue1624_APersonsCallInTheSamePersonaIsStillCataloged runs 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.

TestCallReferenceRegistrationAppliesTheCatalogsOwnRule drives the shipped middleware-chain entry rather than a copy of it, so the predicate the platform builds from configuration is the one under test.

…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

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.56%. Comparing base (d847625) to head (70248d1).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cjimti
cjimti merged commit 87b272f into main Sep 5, 2026
10 checks passed
@cjimti
cjimti deleted the issue-1624-1625-script-calls-and-memory-staleness branch September 5, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant