DEV Community

Cover image for My Notification List Has 94 Rows, 25 Have No App Path
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

My Notification List Has 94 Rows, 25 Have No App Path

Turning a notification off in the settings pane is easy. What I actually
wondered was something else: when I turn it off, does the thing leave the list,
or does it just go quiet? The question looks innocent, but the answer isn't in
the pane, because the pane shows you the switch, not the ledger.

So I opened the ledger. On my laptop running macOS 26.6.2, notification
permissions live in ~/Library/Preferences/com.apple.ncprefs.plist. I found 94
rows in it. My first reaction on seeing that number was "so I've granted
permission to 94 apps." Wrong reaction. Twenty-five of those rows don't even
carry an app path, and three point to a file that no longer exists on disk.

The real finding isn't in the numbers. It's that this ledger is an
append-only one. Everything that asks for permission leaves a row behind;
nothing ever removes one.

Counting the file is not counting the pane

First I asked the same question two different ways, because on macOS
preferences aren't written straight to disk — a daemon called cfprefsd sits
in between, and the file can lag behind the in-memory state. If the two
answers disagreed, I needed to know which one to trust.

# (1) via cfprefsd: the state the operating system sees
defaults export com.apple.ncprefs - > /tmp/nc.plist

# (2) the file on disk, directly
python3 -c "import plistlib,os;d=plistlib.load(open(os.path.expanduser(
'~/Library/Preferences/com.apple.ncprefs.plist'),'rb'));print(len(d['apps']))"
Enter fullscreen mode Exit fullscreen mode

Both said 94. Better yet, the sets of bundle-id values from the two sources
matched exactly: nothing extra, nothing missing. So the file on disk isn't
stale; this is the living state.

Then I looked at the file itself, and that's where it got interesting:

stat -f "mtime=%Sm size=%z" -t "%Y-%m-%d %H:%M:%S" \
  ~/Library/Preferences/com.apple.ncprefs.plist
# mtime=2025-09-23 16:22:05 size=30324
Enter fullscreen mode Exit fullscreen mode

This file hasn't been rewritten in thirteen months. Ask file what it is and
it answers "Apple binary property list": thirty kilobytes of binary record,
sitting perfectly still for thirteen months, and still today's effective
setting. Black boxes tend to spend their quietest stretches without telling
anybody.

25 of the 94 rows have no app path

When I split the rows by the prefix on their bundle-id, the picture came out
like this:

Row type Count What it means
_SYSTEM_CENTER_: 24 The system's own alert sources
_WEB_CENTER_: 1 A permission granted to a website
Ordinary bundle-id 69 Actual applications

None of the twenty-four _SYSTEM_CENTER_ rows has a path field. Their names
give them away:
com.apple.diskspaced (the disk-is-filling-up warning),
com.apple.SoftwareUpdateNotification, com.apple.BluetoothUIServer,
com.apple.security.keychain-circle-notification,
com.apple.DiskArbitration.DiskArbitrationAgent (the warning you get for
yanking a drive without ejecting it), com.apple.EscrowSecurityAlert. Four of
them are purely Wi‑Fi business: diagnostics, joining a network, personal
hotspot, peer-to-peer.

A distinction is needed here: "no path" and "no code behind it" are not the
same thing. Twenty of these twenty-four rows carry the signature field I'll get
to shortly, and it names a real signed binary. One of them even corresponds to
an app outright: _SYSTEM_CENTER_:com.apple.FaceTime. FaceTime is recorded
twice in the ledger, once as a system alert source with no path, once as an app
with the path /System/Applications/FaceTime.app. The measurement here is the
absence of a path; saying "no app" would claim more than the data shows.

These rows still aren't permissions I granted: there's no app doing the asking,
and you wouldn't expect a keychain alert to petition you. But in the ledger they sit in exactly the same shape as the apps,
with the same fields. This is where reading the row count as "I granted
permission to 94 apps" starts going wrong.

Of the remaining 69 rows, 67 have a path. Two don't: com.apple.iChat and
com.apple.TelephonyUtilities. The first is the Messages app's old identifier, a name abandoned
years ago, still holding its own row in the ledger. With no path, I couldn't
tell at first glance what these two rows belong to, but it can be worked out:
the signature field I'll open shortly names them both. The com.apple.iChat
row points at com.apple.identityservicesd, and the
com.apple.TelephonyUtilities row at com.apple.imagent. Both are system
services, so these two rows belong not to an app but to the plumbing underneath
Messages.

Fourteen rows also carry a universal-app-id field, the one that unifies the
iPhone and Mac versions of the same app under a single identity. So the ledger
tracks not just "which app" but "which app family."

The spread of the paths says something too:

  • 41 rows point under /System/ (the apps that ship with the operating system),
  • 25 rows under /Applications/,
  • 1 row at an updater under /Library/Application Support/.

A notification permission granted to localhost

The single _WEB_CENTER_ row stopped me. It's a notification permission
granted to a website, and the site's address is web.<project>.localhost, a
development server running on my own machine. The row is the record of my
having tried notifications at that address once: the browser asked, permission
was given.

That address doesn't lead anywhere now. The name doesn't resolve, the request
goes unanswered, nothing on the machine is listening:

host web.<project>.localhost
# Host web.<project>.localhost not found: 3(NXDOMAIN)

curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://web.<project>.localhost/
# 000
Enter fullscreen mode Exit fullscreen mode

The server is gone. The permission stayed.

There's an interesting asymmetry here. On the web side, permission is granted
per origin. MDN's page for Notification.requestPermission() states
plainly that permission is requested "for the current origin." The W3C Push API
specification goes further. When permission is revoked, the user agent must
deactivate the affected subscriptions and delete the stored details. So the
web's own model has a revocation mechanism defined.

But when that origin is a localhost address, there's no site owner to revoke
anything and no app to uninstall. The row outlives the thing it points at.

I also found a push section on eight rows: the ones registered for remote
notifications. One of them held a real 32-byte device token. I'm not printing
its value here, but I am printing where it lives: the file you assumed held
permissions also holds tokens.

Three paths that aren't on disk

I checked how many of the 67 paths actually point at a file. Three don't:

python3 - <<'PY'
import plistlib, os
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
for a in plistlib.load(open(p, 'rb'))['apps']:
    if 'path' in a and not os.path.exists(a['path']):
        print(a['bundle-id'], '->', a['path'])
PY
Enter fullscreen mode Exit fullscreen mode

All three end up in the same place, but they got there by different routes, and
the difference matters.

Minecraft is the simple case: /Applications/Minecraft.app isn't there.
The app is gone from the machine; the row stayed.

Chrome and Opera are more roundabout. Both applications are installed and
working on my machine. But the notification row doesn't point at the app — it
points at a helper program inside the framework, with the version number
baked into the path
:

Browser Version in the row Versions on disk
Chrome 140.0.7339.185 154.0.8037.98, 155.0.8059.85, 155.0.8059.86
Opera 120.0.5543.161 136.0.6008.80, 137.0.6036.63

Chrome's own version today is 155.0.8059.86. So my notification ledger knows
Chrome fifteen major versions back and Opera seventeen. Each update creates a
new version directory and removes the old one; the path in the ledger points
into empty space.

And now the file's frozen timestamp means something: the row was written while
Chrome was at 140, and nobody has touched it since September 2025. The browser
advanced fifteen major versions. The ledger was never written again.

Which raises a question. If the permission still works, and there's no sign
that it broke, then the path in the ledger can't be what makes it work. So
what carries the identity?

The row is bound to a signature, not a path

The answer is in another field. 88 of the 94 rows have a field called src,
holding 89 blobs in total. Every single one of them starts with the same four
bytes: fade0c00.

python3 - <<'PY'
import plistlib, os, collections
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
m = collections.Counter()
for a in plistlib.load(open(p, 'rb'))['apps']:
    for s in a.get('src', []):
        if 'req' in s:
            m[s['req'][:4].hex()] += 1
print(dict(m))   # {'fade0c00': 89}
PY
Enter fullscreen mode Exit fullscreen mode

I found where that number comes from in Apple's own open-source Security
distribution. In requirement.h there's this line:
class Requirement: public Blob<Requirement, 0xfade0c00>. So these blobs are
code signing requirement blobs.

Searching inside them for readable strings confirmed it: in 40 of the 89 blobs,
the row's own bundle-id appears as plain text. The blob on the Mail row holds
a single string: com.apple.mail.

Chrome confirms it exactly. The identity in the row is
com.google.Chrome.framework.AlertNotificationService; when I open today's
version of the helper and read its identifier, the same string comes back:

defaults read "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome \
Framework.framework/Versions/155.0.8059.86/Helpers/Google Chrome Helper (Alerts).app/Contents/Info.plist" \
  CFBundleIdentifier
# com.google.Chrome.framework.AlertNotificationService
Enter fullscreen mode Exit fullscreen mode

The path is stale, the identity is live. That is precisely why the row kept
working across fifteen versions.

So the ledger's logic is this: the permission is bound to a signed code
identity
, not to a path on disk. The path field is a convenience, a hint,
maybe a way to find the icon for the pane. The thing that carries identity is
the signing requirement.

That's news in two directions. The good direction: because it doesn't work by
path, another program can't take over /Applications/Minecraft.app and inherit
Minecraft's permission; if the signature doesn't match, the row is useless to
it. The bad direction: nothing checks whether the thing a row points at still
exists
, because looking at the path was never needed in the first place.
Nobody comes to repair what isn't broken.

I can't read the state in the file

Here's a boundary I need to be honest about. Every row has a flags field,
most have auth, all have content_visibility. What the numeric values of
those fields mean isn't documented by Apple, and I'm not going to guess.

What I can say is that the values don't fit the documented model. Apple's
UNAuthorizationStatus documentation defines exactly five constants for
permission state: notDetermined, denied, authorized, provisional,
ephemeral. The auth field in my file contains these values: 0, 1, 4, 6, 7,
38, 47, 54, 71, 102, 263, 271, 326, 327, 775, 791. Twenty-four rows have no
auth field at all.

Those twenty-four are not the same set as the twenty-four system rows from
earlier. Five come from elsewhere: FaceTime, iChat, TelephonyUtilities,
Freeform and that localhost row. In exchange, five system rows do have an
auth field (the software-update notification and four Wi‑Fi rows). The two
numbers matching here is a coincidence; they aren't counting the same thing.

263 or 791 cannot be a value of a five-case enumeration. The field is probably
a bit set packing the state together with the options, but "probably" is no
basis for a technical claim. The practical upshot: the file is not an audit
surface.
I can count the rows and verify the paths; I cannot read the state.

Let me say why I'm drawing that line so firmly. If I set out to guess the bit
meanings, I could look at a number and write "this app is allowed to show
notifications on the lock screen." It would sound competent. And if it were
wrong, nobody would catch it; no reader can read their own auth value and
correct me, because there's no document to check it against. That's exactly why
I'm not writing it: an unverifiable claim looks more convincing than a
verifiable one.

Instead of filling in an undocumented field with guesses, I go back to the
measurable question: what does this row point at, and does that thing exist?
The file answers both plainly.

The content_visibility field reads 0 on 63 rows and 3 on 31. The grouping
field is 0 on all 94. I don't know which pane option those numbers correspond
to either.

The only documented surface is the pane itself. Apple's macOS user guide lists
what's on offer for an app: "Allow notifications," alert style, critical alerts,
time-sensitive alerts, badge, sound, previews, grouping. Eight controls. The one thing missing from that list is the thing I was
looking for: there is no option that removes a row. The guide describes no
such control, because there isn't one.

Diagram

The other direction: 44 of my 64 installed apps aren't listed

Seeing this many accumulated rows, you might assume the ledger covers "every
app." The opposite is true. I have 64 application bundles under
/Applications; only 20 of them appear in the notification ledger. Forty-four
don't appear at all.

Once it clicks, it's simple: this isn't an inventory of what's installed, it's
a record of requests. Only the things that asked once get in. Forty-four
apps never asked: either they don't send notifications, or they haven't tried
yet.

Which is why reading the list as "what's on my machine" gives you a wrong
answer in both directions: things that aren't there are listed, and most of the
things that are there aren't. The two sets aren't even nested; they just
overlap.

One more detail: of the 67 rows carrying a path, 41 point under /System/, the
apps that ship with the operating system. Only 25 sit under /Applications/,
among the apps I installed. So 41 of the 67 rows carrying a path, more than
three-fifths, aren't my decisions at all; they're the machine's own defaults. Most of what I thought of as "my permission
list" was never mine. That's also the discovery that most quickly drains your
enthusiasm for reviewing it.

Running the same measurement on your own machine

Four commands are enough. None of them changes anything; they all just read.

# 1) How many rows?
defaults export com.apple.ncprefs - | plutil -extract apps raw -

# 2) Split the rows by type
python3 - <<'PY'
import plistlib, os, collections
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
apps = plistlib.load(open(p, 'rb'))['apps']
t = collections.Counter(
    '_SYSTEM_CENTER_' if a['bundle-id'].startswith('_SYSTEM_CENTER_:')
    else '_WEB_CENTER_' if a['bundle-id'].startswith('_WEB_CENTER_:')
    else 'app' for a in apps)
print(len(apps), dict(t))
PY

# 3) Paths that aren't on disk
python3 - <<'PY'
import plistlib, os
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
for a in plistlib.load(open(p, 'rb'))['apps']:
    if 'path' in a and not os.path.exists(a['path']):
        print('ORPHAN:', a['bundle-id'], '->', a['path'])
PY

# 4) When was the ledger last written?
stat -f "%Sm" -t "%Y-%m-%d %H:%M" ~/Library/Preferences/com.apple.ncprefs.plist
Enter fullscreen mode Exit fullscreen mode

The fourth command's output tells you the most, I think. If the date is recent,
your machine is still accumulating new permissions. If it's very old, thirteen
months ago like mine, the ledger has frozen, but it's still carrying your old
decisions where it froze.

A warning: this file is not a documented interface, it's an implementation
detail. Its structure can change in any macOS release. Reading it is safe, but
don't write to it by hand; cfprefsd will overwrite from its in-memory
state and you'll be left with an inconsistent setting.

What I'm going to do about it

The honest answer: very little, and I'm choosing that deliberately.

There's nothing to be done about the three unremovable rows: the pane offers no
delete, and touching the file by hand isn't a good idea. The Minecraft row will
stay where it is. It does no harm either: with no app behind it, it can't
produce a notification. With one caveat: since the permission is bound to a
signature, if I ever reinstall a signed Minecraft I won't be asked again. That
row isn't harmless, it's dormant.

Two things are real work for me. The first is that localhost permission: next
time I'm testing notifications on a development server, I'll revoke the
permission from the browser's site settings when the test is done. The
revocation mechanism exists there, the Push API defines it; I just never used
it. The second is that device token: knowing that tokens sit next to permissions
is reason enough for me to think about where this file goes in my backups.

Beyond that, this measurement left me with a habit. When I grant permission I
think "I'll turn this off later" — but turning it off doesn't delete the row. I
ran into the same thing with data left on disk after
deleting apps:
I removed eighteen apps and 661 MiB of data stayed behind. Background jobs look
similar:
launchd has been retrying for years
and I never once watched it.

Same pattern in all three: removal removes the visible part and leaves the
record.

It would be easy to present that as a defect, but I don't think it is one.
Binding the row to a signature is the right design decision, and not checking
the path is the consistent consequence of it. Nobody wrote a garbage collector
that sweeps up notification rows for deleted apps, because that row harms
nobody. From the engineering side there's nothing to object to.

What bothers me isn't the engineering, it's the bookkeeping. When I flip a
switch off in the pane, I think "right, that's settled." It isn't: the decision
is recorded, the record is permanent, and the record itself is never shown to
me. Using a machine for years means carrying an archive of decisions you don't
remember making. Nobody shows you the archive; nobody promised to, either.

The lesson I'm taking: being careful when you grant is cheaper than revoking
later. The revocation mechanism often doesn't exist, and when it does, as with
web permissions, using it is left to you. The ledger itself is thirty
kilobytes, written thirteen months ago, still in force. And it'll stay that way until
something writes it again.

Official Sources

Top comments (1)

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

Bro can you tell my why you add your blogs link in canonical URL place?
like I'm learning a new skill SEO for my Akhouri Systems & I want to know it's benefits, how to use them and more please explain it to me like explaining to a 13 year kid (but non founder & ceo ones 🤣)