OK, so Claude and I (ok, it was all Claude) sorted out the JS to remove the delegation when the “exec” decides to revoke it. Here is the code HEAVILY commented, hopefully it can help someone one day. Reach out of still unclear. Works perfectly. Case now closed.
/* =====================================================================
RECIPROCAL DELEGATION REVOCATION
=====================================================================
PURPOSE:
ACCOUNTS is connected to ACCOUNTS twice, forming an offer/accept
delegation model:
- field_978 "ALLOWED DELEGATES" -> lives on the DELEGATOR's
record ("ME"). This is the OFFER: a list of people ME is
willing to let act as delegate.
- field_965 "ACTIVE DELEGATIONS" -> lives on the DELEGATE's
record ("HER"). This is the ACCEPTANCE: HER choice to actually
activate one of the offers made to her (selected from a filtered
list where field_978 contains HER).
THE PROBLEM:
These two fields live on two different records and are updated
independently. If ME removes HER from field_978 (revoking the
offer), field_965 on HER record still contains ME — so she retains
access to ME's data even though the offer was withdrawn. Knack
record rules can only SET a connection field wholesale; there's no
native "remove just this one value" action, so this can't be fixed
with record rules alone.
THE FIX:
Watch ME's edit of field_978. When we detect delegate(s) were
removed (via before/after diff, since Knack doesn't tell us "what
changed", only "what it is now"), for each removed delegate we go
fetch THEIR record and strip ME's ID back out of their field_965.
DELIBERATELY ONE-DIRECTIONAL:
If HER instead deselects herself from field_965 (i.e. "I don't want
to act on this delegation right now"), that does NOT touch ME's
field_978 offer. The offer and the acceptance are treated as
distinct, independently revocable choices — e.g. she might step
away for parental leave and come back later without ME needing to
re-offer. Only a delegator-side removal cascades.
INFRASTRUCTURE REQUIRED:
- scene_372 / view_872: ME's edit screen/form for field_978.
This is where the snapshot + change-detection happens.
- scene_347 / view_877 "Accounts for Revoking Delegation": a grid
view, filtered to show only Accounts where field_965 contains the
logged-in user. Because Knack's page-context API endpoint only
allows writes through a view that itself has write capability,
INLINE EDITING was switched on for field_965 on this view purely
to grant that capability — the JS below does raw API calls and
never actually touches the inline-edit UI directly.
===================================================================== */
$(document).on('knack-scene-render.scene_372', function() {
// Knack's built-in helper — gives us the record ID of whoever is
// currently logged in. This IS the "ME" record ID throughout.
const myRecordId = Knack.getUserAttributes().id;
// This holds the list of delegate IDs from BEFORE the most recent
// save, so we can diff against what it becomes AFTER a save and
// figure out who was removed. Starts empty; populated by the
// snapshot function immediately on page render.
let previousDelegateIds = [];
/* ---------------------------------------------------------------
STEP 1: SNAPSHOT
On page render, fetch ME's own record fresh from the API and
record which delegate IDs are currently in field_978. We can't
rely on Knack's client-side cached view data because we need the
_raw connection format (array of {id, identifier} objects) to
compare IDs reliably.
--------------------------------------------------------------- */
function snapshotCurrentDelegates() {
$.ajax({
// Page-context endpoint pattern (object-level endpoints 403 in
// this app, per established RAIDDAR API access pattern).
url: `https://api.knack.com/v1/pages/scene_372/views/view_872/records/${myRecordId}`,
type: 'GET',
headers: {
'X-Knack-Application-Id': Knack.application_id,
'Authorization': Knack.getUserToken()
},
success: function(record) {
// field_978_raw is an array of {id, identifier} objects for
// a connection field. We only need the IDs for comparison,
// so map down to a plain array of ID strings.
previousDelegateIds = (record['field_978_raw'] || []).map(c => c.id);
}
// No error handler here deliberately — if this fails, worst
// case previousDelegateIds stays [], meaning the very next
// diff would treat ALL current delegates as "removed" and
// wrongly try to revoke everyone. Consider adding a retry or
// alert here if this proves to be a real risk in practice.
});
}
snapshotCurrentDelegates();
/* ---------------------------------------------------------------
STEP 2: DETECT THE CHANGE
This Knack event fires after view_872's record save completes
(i.e. after ME edits and saves field_978). The `record` argument
Knack passes in should reflect the NEW saved state.
--------------------------------------------------------------- */
$(document).on('knack-record-update.view_872', function(event, view, record) {
// What field_978 looks like NOW, after the save.
const newDelegateIds = (record['field_978_raw'] || []).map(c => c.id);
// Anyone in the OLD list but not the NEW list was just removed.
// This naturally supports removing multiple delegates in a single
// save — removedIds could contain 0, 1, or many IDs.
const removedIds = previousDelegateIds.filter(id => !newDelegateIds.includes(id));
// For each person ME just revoked, go clean up their side.
removedIds.forEach(function(delegateAccountId) {
revokeReciprocalAccess(delegateAccountId, myRecordId);
});
// IMPORTANT: reset the baseline to the new state. Without this,
// a second edit later in the same page-load would incorrectly
// diff against the ORIGINAL list from page-load, not the most
// recent saved state, and could try to "re-revoke" people who
// were already handled.
previousDelegateIds = newDelegateIds;
});
/* ---------------------------------------------------------------
STEP 3: CASCADE THE REVOCATION
Given one delegate's record ID, fetch their record via the
utility grid endpoint, strip the revoking user's ID out of their
field_965 (ACTIVE DELEGATIONS), and write it back — but only if
something actually changed, to avoid a pointless PUT.
--------------------------------------------------------------- */
function revokeReciprocalAccess(delegateAccountId, revokedFromId) {
// GET the delegate's record through view_877 (NOT view_872 — this
// view is filtered to show accounts where field_965 contains the
// logged-in user, and critically has inline-edit switched on for
// field_965, which is what makes the PUT below actually work
// rather than being rejected for lack of write permission).
$.ajax({
url: `https://api.knack.com/v1/pages/scene_347/views/view_877/records/${delegateAccountId}`,
type: 'GET',
headers: {
'X-Knack-Application-Id': Knack.application_id,
'Authorization': Knack.getUserToken()
},
success: function(delegateRecord) {
// Delegate's current ACTIVE DELEGATIONS list (could contain
// several other delegators too — we must only remove OUR ID,
// not wipe the whole field).
const currentActive = (delegateRecord['field_965_raw'] || []).map(c => c.id);
// Remove just the revoking delegator's ID, keep everyone else
// this delegate has an active delegation with.
const updatedActive = currentActive.filter(id => id !== revokedFromId);
// Only bother writing if the array actually shrank — avoids
// an unnecessary API call if, for some reason, the ID wasn't
// present (e.g. she'd already removed it herself).
if (updatedActive.length !== currentActive.length) {
$.ajax({
url: `https://api.knack.com/v1/pages/scene_347/views/view_877/records/${delegateAccountId}`,
type: 'PUT',
headers: {
'X-Knack-Application-Id': Knack.application_id,
'Authorization': Knack.getUserToken(),
'Content-Type': 'application/json'
},
// NOTE: writes take a plain array of ID strings, NOT the
// {id, identifier} object shape that reads return. This
// is a classic Knack gotcha — read shape != write shape.
data: JSON.stringify({ field_965: updatedActive }),
success: function() {
console.log(`Revoked ${revokedFromId} from delegate ${delegateAccountId}'s ACTIVE DELEGATIONS`);
},
error: function(err) {
// Silent failure would leave the delegate with lingering
// access ME thinks was revoked — logging is the minimum
// safety net; consider surfacing this to the user if it
// starts happening in practice.
console.error('Reciprocal revoke failed', err);
}
});
}
},
error: function(err) {
console.error('Could not fetch delegate record from view_877', err);
}
});
}
});